From 54912ad37e79df64432b85fd15b7a3f25cf5146f Mon Sep 17 00:00:00 2001 From: Uwe Oestermeier Date: Tue, 16 Jul 2019 09:21:28 +0200 Subject: [PATCH] Moved files from src/htmlcards to iwmlib. --- lib/card/card.js | 2559 +++++++++++++++++++++++++++++++++++++++++++++ lib/card/theme.js | 46 + 2 files changed, 2605 insertions(+) create mode 100644 lib/card/card.js create mode 100644 lib/card/theme.js diff --git a/lib/card/card.js b/lib/card/card.js new file mode 100644 index 0000000..dbad014 --- /dev/null +++ b/lib/card/card.js @@ -0,0 +1,2559 @@ + + +/** To avoid problems with relative URL paths, we use inline data URI to load svg icons. */ +const closeIconDataURI = `data:image/svg+xml;utf8, + + + + + + +` + +const resizeIconDataURI = `data:image/svg+xml;utf8, + + + + + + +` + +const enableNearestNeighborTaps = false + +/** + * A class that collects static methods to maintain the states and parts of + * EyeVisit like cards. + * + * The class is used as a namespace and should never called with new. + */ +class Card { + + static setup(context, modules = []) { + console.log("Setup Card...", modules) + context.modules = [] + modules.forEach(module => { + if (module.apply(context)) + context.modules.push(module.constructor.name) + }) + } + + /** + * + * + * @static + * @param {*} event + * @memberof Card + */ + static close(event) { + let context = this.getContext(event.target) + if (context) { + if (context.onClose) { + context.onClose(event) + } else context.parentNode.removeChild(context) + } else console.error("Could not find context!", event.target) + } + + /** + * Replaces a set of attributes using a provided function. + * + * @static + * @param {*} html + * @param {*} attribute + * @param {*} replaceFunc + * @memberof Card + */ + static _replaceAttributes(html, attribute, replaceFunc) { + let clickables = html.querySelectorAll(`[${attribute}]`) + clickables.forEach(element => { + + let attributeVal = element.getAttribute(attribute) + element.removeAttribute(attribute) + replaceFunc.call(this, element, attributeVal) + }) + } + + /** + * Replaces the onClick callback of the element with an + * Interaction mapper event. + * + * @static + * @param {*} element + * @param {*} attributeVal + * @returns + * @memberof Card + */ + static _replaceCallback(element, attributeVal) { + + if (element.tagName == "A") { + element.addEventListener("click", event => { event.preventDefault() }) + } + + + let callbackParts = attributeVal.split("(") + let funcPart = callbackParts[0].trim() + let trimmedArgs = callbackParts[1].trim() + + //Remove the closing ')' + trimmedArgs = trimmedArgs.substring(0, trimmedArgs.length - 1) + + + let callParts = funcPart.split(".") + let argsStrings = trimmedArgs.split(",").filter(entry => { return entry.trim() != "" }) + + + let callStack = window + do { + callStack = callStack[callParts.shift().trim()] + } while (callParts.length > 0) + + let that = this + + let interactionType = this.interactionType + + //Remove the events on the circle. + // These are 'hardcoded' inside the convert.js. + if (element.tagName == "circle") return false + + + InteractionMapper.on(interactionType, element, (event) => { + + /** + * Replaces the strings from the listener with the cooresponding variables. + */ + let args = [] + argsStrings.forEach(arg => { + arg = arg.trim() + if (arg == "this") + args.push(event.target) + else if (arg == "event") + args.push(event) + else { + const firstCharacter = arg[0] + if (firstCharacter == "\"" || firstCharacter == "'") { + arg = arg.substring(1) + } + const lastCharacter = arg[arg.length - 1] + if (lastCharacter == "\"" || lastCharacter == "'") { + arg = arg.substring(0, arg.length - 1) + } + + args.push(arg) + } + }) + event.stopPropagation() + if (callStack) + callStack.call(that, ...args) + else { + console.error("Could not call callback function " + attributeVal, ...args) + } + }) + } + + + /** + * Transform the relative links to absolute ones. + * + * Currently covers: + * Tags: a,img, image, circle + * Attributes: xlink:href,href,src (first occurence only) + * + * @static + * @param {DomElement} element - The children of the element are inspected, if there are relative paths, that has to be adjusted to absolute ones. + * @returns + * @memberof Card + */ + static _adjustRelativeLinks(html) { + const that = this + /* + This RegEx finds all requested tags[1], and all requested attributes[3] and replaces the relative path [4] with the absolute one. + while all other attributes [2],[5] are preserved. + */ + return html.replace(/<\s*(a|video|img|image|circle)\s(.*?)(xlink:href|href|src)\s*=\s*["'](\..*?)["']\s*(.*?)>/g, function (data) { + let path = that._getRelativePath(arguments[4]) + const tag = `<${arguments[1]} ${arguments[2]} ${arguments[3]}="${path}" ${arguments[5]}>` + /* if (that.debug) */ console.log("Adjusted: ", tag) + return tag + }) + } + + /** + * Concats the given path with the relative path specified in the Card (as static variable). + */ + static _getRelativePath(src) { + let path = (this.relativePath != "") ? this.relativePath + "/" + src : src + return path + } + + + /** + * Loads the card text using an ajax request. + * + * @static + * @param {*} path + * @returns + * @memberof Card + */ + static loadHTML(path) { + return new Promise((resolve, reject) => { + let request = new XMLHttpRequest() + + request.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200 || Card._isLocal()) { + try { + resolve(this.responseText) + } catch (e) { + reject(e) + } + } else reject(`Request failed '${path}'. Returned status ${this.status} and ready state ${this.readyState}.`) + } + } + + request.open("GET", path, true) + request.send() + }) + } + + /** + * TODO: Maybe put this in a utility script. + */ + static _isLocal() { + return (window.location.protocol == "file:") + } + + /** + * Returns true if the card belongs to an editable page / context. + * + * @static + * @returns + * @memberof Card + */ + static isEditable() { + return typeof mainController != 'undefined' && mainController.mode + } + + /** + * Finds the closest element with the given css class name along the parent chain. + * + * @static + * @param {any} node - The node to start on + * @param {any} klass - The css class name + * @returns {element} - The DOM element with the given class name + * @memberof Card + */ + static closestWithClass(node, klass) { + if (node && node.classList) { + if (node.classList.contains(klass)) + return node + return this.closestWithClass(node.parentNode, klass) + } + return null + } + + /** + * Returns a computed style property. Similar to the jQuery.css method. + * + * @param {any} node + * @param {any} attr + * @returns {any} + * @memberof Card + */ + static css(node, attr) { + return window.getComputedStyle(node, null).getPropertyValue(attr) + } + + /** + * Defines the standard drag behavior of cards. Copies the outer HTML of + * the card to the dataTransfer object of the drag event. + * + * Since dragover does not have the right to see the content we must specify + * additional type information to ensure that only accepted content + * can be dropped. Use the optional type parameter to specify custom types. + * + * The optional effectAllowed is set to 'all'. This is necessary to ensure + * that elements can be copied and moved. Unfortunately this + * adds a plus marker to the mouse that might be misleading. Use + * DataTransfer.dropEffect in dragover events to provide correct feedback instead. + * + * @static + * @param {any} event + * @param {string} [type="card"] + * @param {string} [effectAllowed="all"] + * @memberof Card + */ + static dragStart(event, type = "card", effectAllowed = 'all') { + event.dataTransfer.effectAllowed = effectAllowed + let html = event.target.outerHTML + event.dataTransfer.setData("text/html", html) + // https://stackoverflow.com/questions/11065803/determine-what-is-being-dragged-from-dragenter-dragover-events + event.dataTransfer.setData('iwmbrowser/' + type, '') + } + + /** + * Opens a popup by calling the Popup.open method. + * + * @static + * @param {DOM element} parent - The DOM node to add the popup + * @param {any} position - The x,y position of the popup + * @param {any} content - The content to show within the popup + * @param {any} callback - A callback that is called on closing the popup + * @memberof Card + */ + static _openPopup(context, src, position, content, options = {}) { + + let maxWidth = null + if (this.debug) console.log("Card._openPopup", position) + + //logging + if (src) { + let strparts = src.split("/") + let cardID = strparts[strparts.length - 2] + let cardName = strparts[strparts.length - 1] + //console.log('open popup:',cardID,cardName,context,content) + let msg = 'ShowPopup: ' + cardID + '/' + cardName + console.log('Logging:', msg) + Logging.log(msg) + } + + let highlight = null + if (options.highlight) { + highlight = options.highlight + delete options.highlight + } + + this._createPopup(context, position, content, options).then((popup) => { + if ( + //Test if meanwhile another popup was registered... + this._getPopup(context) || + // Or if an highlight was loaded, if the highlight already was closed. + highlight !== null && !this._isHighlightActive(context, highlight) + ) { + //.. if so remove the create popup instantly. + popup.remove() + } else { + // Otherwise set the popup regularly. + let popupParagraphs = popup.element.querySelectorAll(".popupContent > *") + + // Remove a design error of naming two adjacent elements popup. + // Todo: fix this properly and remove this code. + // let unnecessaryPopupElement = popup.element.querySelector(".popupContent > .popup") + // unnecessaryPopupElement.classList.remove("popup") + + popupParagraphs.forEach(popupParagraph => { + popupParagraph.setAttribute("draggable", false) + popupParagraph.addEventListener("mousedown", (event) => { + event.preventDefault() + }) + }) + + + this._setPopup(context, popup, src) + } + }).catch(e => console.error(e)) + } + + + /** + * Closes a provided popup and unsets it on the context. + * + * @static + * @param {*} context + * @param {*} popup + * @memberof Card + */ + static closePopup(context, popup) { + if (popup) { + if (this.debug) console.log("Close Popup.", context, popup) + window.popup = popup + popup.close() + this._unsetPopup(context) + } else { + console.error("Requested to close popup, but popup was not found.") + } + } + + /** + * Creates a new Popup instance. + * + * @static + * @param {DomElement} context - The element on which the Popup should be created. + * @param {Point} position - The position, where the popup will be shown. + * @param {object} content - The content of the popup as required by the Popup class. + * @param {object} [options={}] - Additional options for the popup (optional). + * @returns {Promise} - Returns a Promise, which is resolved, when the Popup is fully loaded. + * @memberof Card + */ + static _createPopup(context, position, content, options = {}) { + + if (this.debug) console.log("Create Popup.", context, position, content, options) + let popup = new Popup(Object.assign({ + parent: context, + content + }, Object.assign({ + noStyle: true, + // TODO: Remove offset when positioning according to element position + // is working. + posOffset: 10 + }, options))) + + // Placing the popup when it required loading, + // it resulted in flahing up at the default position. + // We manually prevent this here. + popup.element.style.display = "none" + + + let promise = new Promise((resolve) => { + if (popup.loaded) + resolve(popup) + else { + popup.onload = () => { + resolve(popup) + } + } + }) + + promise.then((popup) => { + + popup.element.style.display = "block" + popup.element.style.visibility = "hidden" + popup.element.style.opacity = 0 + + popup.placeAt(position) + + InteractionMapper.on(this.interactionType, popup.element, e => { + this._cleanup(context) + }) + + Object.assign(popup.element.style, { + zIndex: this.zIndices.popup + }) + + TweenMax.to(popup.element, this.animation.popup, { + autoAlpha: 1, + ease: Power2.easeIn + }) + + }) + + return promise + } + + + static _overlayCleanup(context, overlay) { + + /** + * The cleanup functionality is now covered by the _cleanup function. + * It cleans up zoomables, popups and open image highlights. + * + * TEST if this intereferes with the editor. + */ + if (overlay) { + TweenMax.to(overlay, 0.2, { + autoAlpha: 0, onComplete: () => { + popup.remove() + //this._cleanup(context) + //overlay.parentNode.removeChild(overlay) + } + }) + } + } + + /** + * Opens a popup for a glossary. Typically used as a onlick handler of a link. + * + * @static + * @param {any} event - The trigger event, typically a click event + * @returns {bool} false - Returns false to prevent default click action + * @memberof Card + */ + static loadPopup(event, context = null, node = null, local = null) { + + let editable = Card.isEditable() + if (context == null) { + context = this.getContext(event.target) + } + if (node == null) { + node = event.target + } + if (local == null) { + let globalClick = (event.center) ? event.center : { x: event.x, y: event.y } + local = Points.fromPageToNode(context, globalClick) + } + if (this.debug) console.log("loadPopup", event) + + // Prevents loading the link in the current tab. + // Prevents loading the link in the current tab. + if (event.type != "Follow") + event.preventDefault() + + if (editable && event.type == 'click') { + return false + } + + let overlay = document.createElement('div') + let src = node.getAttribute("href") + let parentArticle = node.closest('article') + + const that = this + let xhr = new XMLHttpRequest() + xhr.open('get', src, true) + xhr.onreadystatechange = () => { + + if (this.debug) console.log("Popup Source: ", src) + if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 0)) { + if (editable) { + if (this.debug) console.log("Append overlay.", context) + overlay.classList.add('overlay') + TweenMax.set(overlay, { autoAlpha: 0 }) + context.appendChild(overlay) + TweenMax.to(overlay, 0.5, { autoAlpha: 0.25 }) + } + + // Extract the body from the Popup site. + let parser = new DOMParser() + let popupPage = parser.parseFromString(xhr.responseText, "text/html") + + //Fix the relative path of loaded images in the popup. + popupPage.querySelectorAll("img").forEach(node => { + node.setAttribute("src", that._getRelativePath(node.getAttribute("src"))) + }) + let html = popupPage.body.innerHTML + /** + * We do not want to use the popup class again. + * This results in problems when styling the popup. + * (You could style the .unselectable.popup, but that + * wouldn't be clean). + * + * Therefore the selector was changed: + * '.popup' => '.popupHtml' + * + * Which describes the construct even better than the + * '.popup' class. + */ + let selector = Card.popupHtmlSelector + let content = { html, selector } + + + let isSame = Card._checkForActiveSource(context, src) + Card._cleanup(context) + + + if (!isSame) { + Card._activateCorrespondingHighlights(context, node, parentArticle) + + + let callback = (popup, callback) => { + if (this.debug) console.log("Close popup (Editable = " + editable + ").", popup) + if (editable) { + let isDirty = mainController.askSaveNode() + if (isDirty) + mainController.saveNode(html.innerHTML, url => { + if (callback) { + callback() + } + saved(url) + this._overlayCleanup(context, overlay) + }) + else { + if (callback) { + callback() + } + this._overlayCleanup(context, overlay) + } + mainController.popController() + } + /** This may be in conflice with the cleanup method. */ + else { + //this._overlayCleanup(context, overlay) + popup.remove() + } + } + + Card._openPopup(context, src, local, content, { + closeCommand: callback + }) + } + } + + overlay.onclick = e => { + if (editable) + e.preventDefault() + } + //console.log("onreadystatechange", editable) + if (editable) { + if (this.debug) console.log("pushController", src, popup.insertedNode) + mainController.pushController(popup.insertedNode, src) + } + } + xhr.send() + return false + } + + /** + * When an highlight link is clicked, this method activates all + * corresponding highlights. + * + * @static + * @param {DomElement} context - The context of the element. + * @param {DomElement} node - The node that (may) contain a highlightId. + * @param {DomElement} parent - The parent element that may contain more highlightIds. + * @memberof Card + */ + static _activateCorrespondingHighlights(context, node, parent) { + let highlightId = node.getAttribute('data-highlight-id') + // console.log("Request Highlight: " + highlightId) + + let correspondingHighlights = [] + if (highlightId) { + let queryString = 'circle[data-highlight-id="' + highlightId + '"]' + if (parent) { + correspondingHighlights = parent.querySelectorAll(queryString) + } + if (correspondingHighlights.length > 0) { + for (let highlight of correspondingHighlights) { + if (highlight.parentNode && highlight.parentNode.nodeName.toLowerCase() == 'g') { + Highlight.openHighlight(highlight, { animation: Card.highlightAnimation }) + this._addHighlight(context, highlight) + } + } + } + } + } + + /** + * Tests if any open item already contains the requested Source. + * + * @static + * @param {DomElement} context - Dom context we are in. + * @param {string} src - Source as dataUrl. + * @returns {boolean} - True if source is already active, false otherwise. + * @memberof Card + */ + static _checkForActiveSource(context, src) { + + let requestedSame = false + let activePopup = Card._getPopup(context) + let activeHighlights = Card._getHighlights(context) + + if (activePopup) { + let activeSrc = Card._getPopupSource(activePopup) + if (activeSrc && src == activeSrc) { + requestedSame = true + } + } + + if (!requestedSame) { + for (let highlight of activeHighlights) { + let activeSrc = Card._getHighlightSource(highlight) + if (activeSrc == src) { + requestedSame = true + break + } + } + } + + return requestedSame + } + + /** + * Opens a popup for a highlight. Typically used as a onlick handler of a link. + * + * @static + * @param {any} event - The trigger event, typically a click event + * @returns {bool} false - Returns false to prevent default click action + * @memberof Card + */ + static loadHighlightPopup(event) { + + if (this.debug) console.log("Load Highlight Popup: ", event) + let node + if (event.firstTarget) { + node = event.firstTarget + } else { + node = event.target + } + let context = this.getContext(node) + event.stopPropagation() + + /** + * This node is the documents body, as events wont work + * on svg elements properly. We need a workaround for that. + */ + let src = node.getAttribute("xlink:href") + let isSame = this._checkForActiveSource(context, src) + + this._cleanup(context) + if (!isSame) { + this._addHighlight(context, node) + + Highlight.openHighlight(node, { + animation: Card.highlightAnimation, + onExpanded: () => { + // We assume it's always a circle. This may break, when other svg shapes are used. + let x = node.getAttribute("cx") + let y = node.getAttribute("cy") + let position = { x, y } + + let radius = parseFloat(node.getAttribute("r")) + + /* + As the popup is appended directly to the card. We have to + transform the location from the subcard-/svg-space to the + card space. + */ + + let svgRoot = node.closest("svg") + + let svgPoint = svgRoot.createSVGPoint() + svgPoint.x = position.x + svgPoint.y = position.y - radius + let matrix = node.getCTM() + + let point = svgPoint.matrixTransform(matrix) + let global = Points.fromNodeToPage(node.closest("div"), point) + let local = Points.fromPageToNode(context, global) + + let overlay = document.createElement('div') + //TODO: Check if this is necessary for the editor. + // overlay.onclick = e =>{} + + // This is not really efficient, as + // we could load the data while the circle is animating. + // but for simplicity it's just done here for now. + // TODO: Adjust to load while animating (Problem: Unload when cancelled). + console.log(src) + this._loadPopupContent(src) + .then(content => { + this._openPopup(context, src, local, content, { + highlight: node, + closeCommand: (popup, callback) => { + this._overlayCleanup(context, overlay) + popup.remove() + } + }) + }) + .catch(err => { console.error(err) }) + } + }) + } + } + + /** + * Loads the popup from a provided source. + * + * @static + * @private + * @param {string} source - Url to a popup file. + * @returns {Promise} - Returns a promise, that's resolved when the data is loaded. + * @memberof Card + */ + static _loadPopupContent(source) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + xhr.open('get', source, true) + xhr.onreadystatechange = () => { + if (xhr.readyState == 4) { + + if ((xhr.status == 200 || xhr.status == 0)) { + let html = this.postProcessResponseText(xhr.responseText) + let selector = Card.popupHtmlSelector + let content = { html: html.body.innerHTML, selector } + resolve(content) + } else { + reject(` Popup request failed (Code: ${xhr.status}): Could not load resource: ${src}`) + } + } + } + xhr.send() + }) + } + + static openZoomable(event) { + + let node = event.target + + //console.log("Open zoomable: ", node, node.classList) + + let editable = Card.isEditable() + if (editable) { + if (node.classList.contains('zoomable-icon')) + this._openZoomable(event) + return + } + this._openZoomable(event) + } + + + /** + * Retrieve a Rectangle object from the a given zoomable. + * + * @static + * @param {DomElement} zoomable - The zoomable, the rectangle should be calculated for. + * @param {DomElement} wrapper - Specifies the coordinate system of the generated rectangle. + * @returns {object} - Returns an object of the rectangle in the wrapper's coordinate system in the form of {x ,y ,width , height}. + * @memberof Card + */ + static zoomableCurrentGeometry(zoomable, wrapper) { + + + /* + I don't think it's wise, that the zoomable calculation relies on + some icon that may or may not be present. When the same calculation can be + done using the bounding box of the desired element. + - SO + */ + + //The div is cloned and animated, therefore we need it's style! + let actuallyZoomedItem = zoomable.querySelector("div") + + let zoomableStyle = window.getComputedStyle(actuallyZoomedItem) + + let svgElement = zoomable.querySelector("svg") + let videoElement = zoomable.querySelector("video") + let curStyle = null + if (svgElement) { + curStyle = window.getComputedStyle(svgElement) + } + if (videoElement) { + curStyle = window.getComputedStyle(videoElement) + } + + let imageWidth = parseFloat(curStyle.width) + let imageHeight = parseFloat(curStyle.height) + + let globalBottomRight = { + x: imageWidth, + y: imageHeight + } + + globalBottomRight = Points.fromNodeToPage(zoomable, globalBottomRight) + let globalFigurePos = Points.fromNodeToPage(zoomable, { x: 0, y: 0 }) + let localFigurePos = Points.fromPageToNode(wrapper, globalFigurePos) + let relativeBottomRight = Points.fromPageToNode(zoomable, globalBottomRight) + + let width = relativeBottomRight.x + parseFloat(zoomableStyle.borderLeftWidth) + parseFloat(zoomableStyle.borderRightWidth)/*+ zoomIconRight*/ + let height = relativeBottomRight.y + parseFloat(zoomableStyle.borderTopWidth) + parseFloat(zoomableStyle.borderBottomWidth) /*+ zoomIconRight*/ + return { x: localFigurePos.x, y: localFigurePos.y, width, height } + } + + + /** + * Opens a zoomable object, which can be a figure containing an image or video or an image group + * + * @static + * @param {any} event - The trigger event, typically a click event + * @memberof Card + */ + static _openZoomable(event, editable = false) { + event.stopPropagation() + let node = event.target + let wrapper = this.getContext(node) + + let zoomable = node.closest('figure') + + // load mainimg - if none exists, there is nothing to open + let img = zoomable.querySelector('.mainimg') + if (img == null) { + return + } + + //close other open elements before opening the zoomable: + //let removeDelay = this.removeActiveZoomable(wrapper, true) ? 0 : 0 + this._cleanup(wrapper) + + // Clone the zoomable and create a clone that is zoomed instead. + let current = this.zoomableCurrentGeometry(zoomable, wrapper) + let zoomedFig = zoomable.cloneNode(true) + let caption = zoomedFig.querySelector("figcaption.cap") + caption.parentNode.removeChild(caption) + this._toggleNextIcon(zoomedFig) + this._setZoomable(wrapper, zoomable, zoomedFig) + + if (!editable) { + /** + * I don't remember why we need yet another wrapper for the zoomable. + * This should be investigated. + * -SO + */ + let zoomContainer = document.createElement('div') + zoomContainer.classList.add("zoomable-wrapper") + Object.assign(zoomContainer.style, { + position: "absolute", + top: 0, + left: 0, + zIndex: 200 + }) + + wrapper.appendChild(zoomContainer) + + //Note: Remaned ZoomedFigure to zoomed-figure + zoomedFig.className = 'zoomed-figure' + zoomedFig.style.zIndex = this.zIndices.zoomedFigure + let zoomedG = zoomedFig.querySelector('g') + if (zoomedG && !editable) + zoomedG.parentNode.removeChild(zoomedG) + zoomContainer.appendChild(zoomedFig) + + let zoomedImg = zoomedFig.querySelector('.mainimg') + zoomedImg.removeAttribute('onclick') + + //set the values of corresponding zoomable element (needed when the zoomable is closed indirectly via removeActiveZoomable) + zoomedFig.zoomable = zoomable + zoomedFig.zoomableGeometry = current + + //play video + let videoElement = zoomedFig.querySelector("video") + if (videoElement) { + videoElement.play() + } + + //logging + let imgElt = zoomedFig.querySelector("image") + let videoElt = zoomedFig.querySelector("video") + //console.log('open zoomable:',imgElt,videoElt) + if (imgElt) { + let imgSrc = imgElt.getAttribute('xlink:href') + let strparts = imgSrc.split("/") + let cardID = strparts[strparts.length - 2] + let cardName = strparts[strparts.length - 1] + let msg = 'ShowImage: ' + cardID + '/' + cardName + console.log('Logging:', msg) + Logging.log(msg) + } + if (videoElt) { + let videoSrc = videoElt.getAttribute('src') + let strparts = videoSrc.split("/") + let cardID = strparts[strparts.length - 2] + let cardName = strparts[strparts.length - 1] + let msg = 'ShowVideo: ' + cardID + '/' + cardName + console.log('Logging:', msg) + Logging.log(msg) + } + + // (At least in this style) We don't want the user to 'hit' the icon + // It's just an indicator that an action is possible. The click must be + // captured from the whole subcard. + + InteractionMapper.on(this.interactionType, zoomedFig, (event) => { + this._cleanup(wrapper) + }) + + let zoomedFigStyle = window.getComputedStyle(zoomedFig) + let borderX = parseFloat(zoomedFigStyle.borderLeftWidth) + parseFloat(zoomedFigStyle.borderRightWidth) + let borderY = parseFloat(zoomedFigStyle.borderBottomWidth) + parseFloat(zoomedFigStyle.borderTopWidth) + + + const scaleFactor = 2 + const transformOrigin = 'bottom right' + + TweenMax.set(zoomedFig, { + x: current.x, + y: current.y, + width: current.width + borderX, + height: current.height + borderY, + transformOrigin + }) + TweenMax.set(zoomable, { opacity: 0 }) + + let icon = zoomedFig.querySelector(".icon") + TweenMax.set(icon, { + transformOrigin + }) + zoomedFig.style.transformOrigin = "calc(100% - " + parseFloat(zoomedFigStyle.borderRightWidth) + "px) calc(100% - " + parseFloat(zoomedFigStyle.borderBottomWidth) + "px)" + + + let tl = new TimelineLite() + + let zoomCaption = zoomedFig.querySelector(".zoomcap") + + tl.to(zoomedFig, Card.animation.zoomable, { + ease: Power2.easeIn, + css: { + scaleX: scaleFactor, + scaleY: scaleFactor + } + }, 0).set(zoomCaption, { + css: { + display: "block", + opacity: 0, + x: -parseFloat(zoomedFigStyle.borderLeftWidth), + width: current.width + borderX + } + }).to(zoomCaption, this.animation.fade, { + autoAlpha: 1 + }) + + } else this._openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current) + } + + /** + * Selects and transforms the zoomicon from a zoomicon to a closeicon + * or the other way around. + * + * @static + * @param {DomElement} parent - Parent to be searched for an zoomicon. + * @memberof Card + */ + static _toggleNextIcon(parent) { + let zoomIcon = Card._findNextIcon(parent) + const closeClass = "close" + const zoomClass = "zoom" + + if (zoomIcon.classList.contains(closeClass)) { + zoomIcon.classList.remove(closeClass) + zoomIcon.classList.add(zoomClass) + } else if (zoomIcon.classList.contains(zoomClass)) { + zoomIcon.classList.remove(zoomClass) + zoomIcon.classList.add(closeClass) + } else { + console.error(`Error Toggleing Zoomicon: It did neither contain a class named ${closeClass} or ${zoomClass}.`) + } + } + + static _findNextIcon(parent) { + return parent.querySelector('.icon') + } + + + static _openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current) { + + let zoomContainer = document.createElement('div') + let zoomIcon = zoomable.querySelector('.zoom-icon') + zoomContainer.style.position = 'relative' + zoomContainer.style.zIndex = this.zIndices.zoomable + wrapper.appendChild(zoomContainer) + let zoomParent = zoomable.parentNode + zoomParent.appendChild(zoomedFig) + zoomedFig.style.opacity = 0.5 + zoomContainer.appendChild(zoomable) + TweenMax.set(zoomable, { x: current.x, y: current.y, width: current.width, height: current.height }) + let editor = mainController.topController().ensureEditor(img) + let savedDisplay = zoomIcon.style.display + let iconClone = zoomIcon.cloneNode(true) + zoomIcon.style.display = 'none' + let savedOnClick = zoomIcon.getAttribute('onclick') + zoomIcon.removeAttribute('onclick') + iconClone.src = iconClone.src.replace('info.svg', 'close.svg') + iconClone.style.zIndex = 1000 + iconClone.classList.add('zoomed-icon') + iconClone.onclick = event => { + zoomIcon.style.display = savedDisplay + iconClone.remove() + zoomIcon.setAttribute('onclick', savedOnClick) + zoomedFig.remove() + zoomContainer.remove() + zoomParent.appendChild(zoomable) + TweenMax.set(zoomable, { x: 0, y: 0 }) + zoomable.onmousedown = null + zoomable.onmousemove = null + zoomable.onmouseup = null + zoomable.onmousewheel = null + } + wrapper.appendChild(iconClone) + TweenMax.set(iconClone, { x: current.iconPos.x, y: current.iconPos.y }) + + zoomable.onmousedown = event => { + if (this.debug) console.log('mousedown', event.target) + event.preventDefault() + zoomable.dragging = true + zoomable.dragStartPos = { x: event.pageX, y: event.pageY } + } + zoomable.onmousemove = event => { + if (this.debug) console.log('onmousemove', event.target) + if (zoomable.dragging) { + event.preventDefault() + let dx = event.pageX - zoomable.dragStartPos.x + let dy = event.pageY - zoomable.dragStartPos.y + TweenMax.set([zoomable, iconClone], { x: '+=' + dx, y: '+=' + dy }) + zoomable.dragStartPos = { x: event.pageX, y: event.pageY } + if (editor) { + editor.showControls() + } + } + } + zoomable.onmouseup = event => { + zoomable.dragging = false + } + + let startZoom = 1 + zoomable.onmousewheel = event => { + event.preventDefault() + let direction = event.detail < 0 || event.wheelDelta > 0 + const zoomFactor = 1.1 + let zoom = direction ? zoomFactor : 1 / zoomFactor + startZoom *= zoom + + TweenMax.set(zoomable, { scale: startZoom }) + if (editor) { + editor.showControls() + } + } + return + } + + + /** + * Closes a zoomable object with animation + * + * @static + * @param {any} wrapper - the wrapper containing the index card + * @param {any} div - the figure containing the relevant elements + * @param {any} zoomable - the zoomable element, from which the zoomed figure originates + * @param {any} rect - the target rect for the tween (typically the top left width height of the zoomable) + * @memberof Card + */ + static closeZoomable(context, zoomable, zoomedFig) { + + if (this.debug) console.log("Close Zoomable", context, zoomable, zoomedFig) + + if (zoomable) { + this._unsetZoomable(context) + let caption = zoomable.querySelector("figcaption.cap") + zoomable.removeChild(caption) + zoomable.appendChild(caption) + let zoomedCaption = zoomedFig.querySelector("figcaption.zoomcap") + + + TweenMax.to(zoomedCaption, this.animation.fade, { + autoAlpha: 0, + }) + + TweenMax.to(zoomedFig, this.animation.zoomable, { + css: { + scaleX: 1, + scaleY: 1 + }, + onComplete: () => { + TweenMax.set(zoomable, { + opacity: 1 + }) + let div = zoomedFig.parentNode + let videoElement = div.querySelector('video') + if (videoElement) + videoElement.pause() + div.parentNode.removeChild(div) + } + }) + } + } + + /** + * Starts and controls the expansion animation of an index card. + * The assumed card structure is as follows: + *
+ *
+ *

...

+ *
+ *
...
+ * + *
+ *
+ *
+ * The basic idea is that the card is cloned. In the animation the clone si expanded while the original is faded out. + * + * @param {*} card - The card to expand + * @param {string} html - The original HTML of the card + * @param {*} tagName - The tagname of the element that is used as exanded element + * @param {*} src - The src of the expanded element + * @param {*} callback - A callback that is called when the expanded element is closed + */ + static expandIndexCard(card, html, tagName = 'article', src = null, callback = null) { + let editable = Card.isEditable() + + let context = this.getContext(card) + let indexbox = this.closestWithClass(card, 'mainview') + + this._cleanup(context) + + + let angle = 0 // UO: out of context, to be computed from the scatter + let clone = card.cloneNode(true) + + + /** + * We have to reorder the clone, as it still contains the + * preview text image. And the new html is + * inserted before everything else. + */ + let cloneWrapper = clone.querySelector(".wrapper") + const article = html.body.querySelector("article") + + let subcardContent = clone.querySelector(".subcard-content") + subcardContent.appendChild(article) + cloneWrapper.parentNode.removeChild(cloneWrapper) + + /* Removes the 'default' cleanup on the card */ + clone.removeAttribute("onclick") + InteractionMapper.on(this.interactionType, clone, (event) => { + this._cleanup(context) + }) + + let articleClone = clone.querySelector(tagName) + + + let globalPreviewRect = Card._getGlobalRect(card) + let globalIndexCardRect = Card._getGlobalRect(indexbox) + let localOrigin = Points.fromPageToNode(indexbox, Rect.getPosition(globalPreviewRect)) + + let scaleX = globalPreviewRect.width / globalIndexCardRect.width + let scaleY = globalPreviewRect.height / globalIndexCardRect.height + + let padding = parseInt(this.css(indexbox, 'padding')) + let maxWidth = this.css(card, 'max-width') + + TweenMax.set(clone, { + css: { + position: 'absolute', + width: globalIndexCardRect.width, + height: globalIndexCardRect.height, + maxWidth: '100%', + margin: 0, + zIndex: this.zIndices.article + } + }) + + TweenMax.set(articleClone, { + autoAlpha: 0 + }) + + TweenMax.set(card, { css: { maxWidth: '100%' } }) + TweenMax.set(clone, { + x: localOrigin.x - padding, + y: localOrigin.y - padding, + scaleX, + scaleY, + transformOrigin: '0% 0%', + rotation: angle, + }) + indexbox.prepend(clone) + + let titlebar = clone.querySelector(".titlebar") + let title = titlebar.querySelector("h2") + let titlebarStyle = window.getComputedStyle(titlebar) + let start = { + height: parseInt(titlebarStyle.height) + } + + if (this.dynamicHeight) { + let targetHeight = subcardContent.offsetHeight; + console.log(targetHeight) + subcardContent.classList.add("dynamic-height") + /** + * Scale the content from 100% to it's target size. + */ + // TweenMax.set(subcardContent, { + // height: "100%" + // }) + // TweenMax.to(subcardContent, Card.animation.articleTransition, { + // height: targetHeight + "px" + // }) + } + + //jquery hyphenate below + $('.column').not('.overview').children('p').hyphenate('de') + + //logging + if (src) { + let strparts = src.split("/") + let cardID = strparts[strparts.length - 2] + let cardName = strparts[strparts.length - 1] + strparts = card.className.split(" ") + let cardType = strparts[1] + let msg = 'Card: ' + cardID + ': openTopic: ' + cardType + ', ' + cardName + console.log('Logging:', msg) + Logging.log(msg) + } + + let desiredBorderBottomWidth = parseInt(window.getComputedStyle(titlebar).borderBottomWidth) + TweenMax.to(clone, Card.animation.articleTransition, { + x: -padding, + y: -padding, + ease: ExpoScaleEase.config(scaleX, 1), + scale: 1, + rotation: 0, + onComplete: () => { + card.classList.add("visited") + }, + onUpdateParams: ['{self}'], + onUpdate: (self) => { + let transform = self.target._gsTransform + TweenMax.set(title, { + scale: 1 / transform.scaleX + }) + TweenMax.set(titlebar, { + height: start.height * 1 / transform.scaleY + }) + + // Retain the border at same visual thickness. + titlebar.style.borderBottomWidth = desiredBorderBottomWidth / transform.scaleY + "px" + } + }) + + TweenMax.to([articleClone], this.animation.articleTransition / 2, { + delay: this.animation.articleTransition / 2, + autoAlpha: 1 + }) + + if (editable) { + mainController.pushController(articleClone, src) + } + + Card._disableCardCloseButton(context) + + const closeAnimation = () => { + //logging + if (src) { + let strparts = src.split("/") + let cardID = strparts[strparts.length - 2] + let cardName = strparts[strparts.length - 1] + strparts = card.className.split(" ") + let cardType = strparts[1] + let msg = 'Card: ' + cardID + ': closeTopic: ' + cardType + ', ' + cardName + console.log('Logging:', msg) + Logging.log(msg) + } + + Card._cleanup(context) + Card._unsetSubcard(context) + + this._enableCardCloseButton(context) + + let previewTitlebar = card.querySelector(".titlebar") + let titlebarStyle = window.getComputedStyle(previewTitlebar) + let titlebar = clone.querySelector(".titlebar") + + TweenMax.to(titlebar, this.animation.articleTransition, { + height: parseInt(titlebarStyle.height) + }) + + TweenMax.to(articleClone, this.animation.articleTransition / 2, { + autoAlpha: 0 + }) + + let title = titlebar.querySelector("h2") + let original = { + height: parseInt(titlebarStyle.height) + } + + if (this.dynamicHeight) { + TweenMax.to(subcardContent, this.animation.articleTransition, { + height: "100%" + }) + } + + TweenMax.set(card, { autoAlpha: 1, css: { maxWidth } }) + TweenMax.to(clone, this.animation.articleTransition, { + x: localOrigin.x - padding, + y: localOrigin.y - padding, + scaleX, + scaleY, + ease: ExpoScaleEase.config(1, scaleX), + rotation: angle, + onComplete: () => { + // article.remove() + TweenMax.to(clone, this.animation.fade, + { + //delay: 0.2, + autoAlpha: 0, + onComplete: + () => { + if (editable) { + mainController.popController() + } + clone.remove() + } + }) + }, + onUpdateParams: ["{self}"], + onUpdate: function (self) { + let transform = self.target._gsTransform + + TweenMax.set(title, { + scale: 1 / transform.scaleX + }) + + TweenMax.set(titlebar, { + height: original.height * 1 / transform.scaleY + }) + + // Retain the border at same visual thickness. + titlebar.style.borderBottomWidth = desiredBorderBottomWidth / transform.scaleY + "px" + } + }) + } + + //TODO consider renaming it to something more intuitive. + let iconClone = clone.querySelector('.card-icon') + + if (iconClone == null) { + iconClone = clone.querySelector(".cardicon") + console.warn("Legacy selector. Change it to 'card-icon' and find a more suitable name.") + } + + if (iconClone.tagName == "img") { + iconClone.src = iconClone.src.replace('info.svg', 'close.svg') + } + //console.log("ICON: ", iconClone) + iconClone.classList.remove("info") + iconClone.classList.add("close", "view-button", "transparent-background") + + // We append the icon clone to the subcard-content. + // Then it's always on the content and not on the background + // (If there is one). + if (this.dynamicHeight) { + article.appendChild(iconClone) + } + + if (enableNearestNeighborTaps) { + //look for nearby popups on tap + InteractionMapper.on("tap", indexbox, () => { + console.log("Tap handler called", editable) + if (!editable) { + this.findNearbyPopups(event, card) + } + }) + } + // Use the 'tap' event for closing. + // Otherwise the subcard cannot be closed, + // when another subcard is touched. + InteractionMapper.on("tap", iconClone, () => { + if (editable) { + let isDirty = mainController.askSaveNode() + if (isDirty) { + mainController.saveNode(html.innerHTML, url => { + callback(url) + closeAnimation() + }) + } + else { + closeAnimation() + } + } + else { + closeAnimation() + } + }) + } + + /** + * Opens the index card. Called by the zoom icon click handler. + * The assumed card structure is as follows: + *
+ *
+ *

...

+ *
+ *
...
+ * + *
+ *
+ *
+ * Loads the DOM element from a file source and adds it before the card-content to ensure that the article content + * can be positioned below the heading. + * + *
+ *
INSERTED CONTENT
+ *
+ * ... + *
+ *
+ * + * @static + * @param {any} event - The trigger event, typically a click event + * @param {string} src - The file source, typically a relative url + * @memberof Card + */ + static openIndexCard(event, src) { + //console.log("openIndexCard", src) + /* + * Called by the expandIndexCard(...) + */ + let target = event.target + const saveCallback = (url) => { + let handler = `Card.openIndexCard(event, '${url}')` + console.log("File has changed", target, handler) + + //TODO If this is required, it should be accessing the interaction type. + target.setAttribute("onclick", handler) + } + + let context = this.getContext(target) + let subcard = this._getSubcard(context) + //console.log("openIndexCard", { context, subcard }) + //Dont proceeed if a subcard is active + if (subcard != null) + return + + // In edit mode we only accept icon clicks + let editable = Card.isEditable() + if (editable && !target.classList.contains('icon')) { + return + } + + let card = this.closestWithClass(target, 'subcard') + this._setSubcard(context, card) + const relativeSource = this._getRelativePath(src) + + let xhr = new XMLHttpRequest() + xhr.open('get', relativeSource, true) + xhr.onreadystatechange = () => { + if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 0)) { + let html = xhr.responseText + let parsedHTML = this.postProcessResponseText(html) + + // TODO: What is this good for? + // let article = parsedHTML.querySelector('article') + // card.insertAdjacentElement('afterbegin', article) + // TweenMax.set(article, { autoAlpha: 0 }) + + Card.expandIndexCard(card, parsedHTML, 'article', relativeSource, saveCallback) + } + } + xhr.onerror = () => { + alert('Unable to load ' + src) + } + xhr.send() + } + + + static _selectCardCloseButton(context) { + return context.querySelector(".mainview > .button.close") + } + + static _enableCardCloseButton(context) { + //console.log("ENABLE") + let btn = this._selectCardCloseButton(context) + //console.log(btn) + btn.classList.remove("disabled") + } + + static _disableCardCloseButton(context) { + let btn = this._selectCardCloseButton(context) + btn.classList.add("disabled") + } + + + /** + * Provides the nearest info-card of the provided item. + * + * @static + * @param {DomElement} child - A dom element for which the context shall be retrieved. + * @returns {DomElement} - The containing context / info-card. + * @memberof Card + */ + static getContext(child) { + let dom = child.closest(".info-card") + if (!dom) { + dom = child.querySelector(".wrapper") + console.warn("Change the 'wrapper' class to 'info-card' it's more suitable.") + } + return dom // (dom == null) ? document.body : dom + } + + /** + * The cleanup method closes all active Popups, Zoomables and Highlights. + * + * @static + * @param {DomElement} context - The dom element to inspect. + * @memberof Card + */ + static _cleanup(context) { + + let [zoomable, zoomedFig] = this._getZoomable(context) + if (zoomable && zoomedFig) { + this.closeZoomable(context, zoomable, zoomedFig) + } + + let popup = this._getPopup(context) + if (popup) { + this.closePopup(context, popup) + } + + let highlights = this._getHighlights(context) + highlights.forEach(highlight => { + Highlight.closeHighlight(highlight, { animation: Card.highlightAnimation }) + }) + + this._unsetHighlights(context) + } + + + + /** + * Retrieves an Rectangle for an element in the local space of a provided context. + * + * Note: This works also for rotated DomElements unlike the Element.getBoundingClientRectangle method. + * + * @static + * @param {DomElement} context - The element that defines the space, we want the rectangle to be in. + * @param {DomElement} element - The element, we want to get the rectangle for. + * @returns {DomRect} - Returns a DomRect representing the element outline in the space of context. + * @memberof Card + */ + static _getContextRect(context, element) { + let global = this._getGlobalRect(element) + let localPosition = Points.fromPageToNode(context, { x: global.x, y: global.y }) + return DOMRectReadOnly.fromRect({ + x: localPosition.x, + y: localPosition.y, + width: global.width, + height: global.height + }) + } + + + /** + * Gets a rectangle in global space for a provided element. + * + * Note: This works also for rotated DomElements unlike the Element.getBoundingClientRectangle method. + * + * @static + * @param {DomElement} element + * @returns {DomRect} - Returns a rectangle that specifies the location in global space. + * @memberof Card + */ + static _getGlobalRect(element) { + let cardStyle = window.getComputedStyle(element) + let cardWidth = parseInt(cardStyle.width) + let cardHeight = parseInt(cardStyle.height) + let globalPosition = Points.fromNodeToPage(element, { x: 0, y: 0 }) + return DOMRectReadOnly.fromRect({ + x: globalPosition.x, + y: globalPosition.y, + width: cardWidth, + height: cardHeight + }) + } + + /** + * Adjusts all links + * + * @static + * @param {*} htmlString + * @returns + * @memberof Card + */ + static postProcessResponseText(htmlString) { + let editable = this.isEditable() + + htmlString = this._adjustRelativeLinks(htmlString) + //console.log(htmlString) + let parser = new DOMParser() + let html = parser.parseFromString(htmlString, "text/html") + if (!editable) { + this._replaceAttributes(html, "onclick", this._replaceCallback) + } + let zoomableWrappers = html.querySelectorAll(".svg-wrapper") + zoomableWrappers.forEach(wrapper => { + let svg = wrapper.querySelector("svg") + Object.assign(wrapper.style, { + width: svg.getAttribute("width") + "px", + height: svg.getAttribute("height") + "px" + }) + }) + let zoomableVideoWrappers = html.querySelectorAll(".video-wrapper") + zoomableVideoWrappers.forEach(wrapper => { + let video = wrapper.querySelector("video") + Object.assign(wrapper.style, { + width: video.getAttribute("width") + "px", + height: video.getAttribute("height") + "px" + }) + }) + + return html + } + + /** + * There occured a problem with the tap event, as it was canceled by the event on the svg element + * therefore we restructured the interaction, that only the svg listens for events and if the target is + * a circle (more are not supported yet) the target is adjusted using the .firstTarget of the event, + * that is provided by the Hammer.propergate plugin. + */ + static openPopupOrZoomable(event) { + let target = this._preferFirstTarget(event) + if (target.tagName == "circle") { + Card.loadHighlightPopup(event) + event.stopPropagation() + } else { + Card.openZoomable(event) + } + } + + static pointRectDist(p, r) { + var cx = Math.max(Math.min(p.x, r.x + r.width), r.x) + var cy = Math.max(Math.min(p.y, r.y + r.height), r.y) + return Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy)) + } + + static findNearbyPopups(event, card) { + if (event.target) { + //let column = event.target.closest(".column") + let indexbox = this.closestWithClass(card, 'mainview') + if (indexbox != null) { // column != null || + let links = Array.from(indexbox.getElementsByTagName("a")) + let globalClick = (event.center) ? event.center : { x: event.x, y: event.y } + let localClick = Points.fromPageToNode(indexbox, globalClick) + + let linkRects = links.map(link => { + let rect = link.getBoundingClientRect() + let topLeft = Points.fromPageToNode(indexbox, rect) + let center = Points.fromPageToNode(indexbox, {x: rect.x + rect.width/2, y: rect.y + rect.height/2}) + return { x: topLeft.x, y: topLeft.y, width: rect.width, height: rect.height, center, link } + }) + + let distances = [] + linkRects.forEach(rect => { + let distance = Card.pointRectDist(localClick, rect) + if (distance == 0.0) { + // Distance == 0.0 indicates an inside relation. Since these + // are already handled by the links themselves we put these cases + // to the end of the sorted list below + distance = 100000.0 + } + distances.push(parseInt(distance)) + }) + + let closestLinkIndex = distances.indexOf(Math.min(...distances)) + let closestLink = links[closestLinkIndex] + + console.log("finding closest links", closestLink, closestLink.getClientRects()) + + if (distances[closestLinkIndex] < 44 && closestLink.getAttribute("href")) { + // Adapt context and local position + let context = this.getContext(closestLink) + let local = Points.fromPageToNode(context, globalClick) + let rect = linkRects[closestLinkIndex] + local.y = rect.center.y + + Card.loadPopup(event, context, closestLink, local) + } + } + } + } + + + /** + * Evaluates an event, if it has a first target. If so it returns the first target, + * otherwise it returns the target. + */ + static _preferFirstTarget(event) { + return (event.firstTarget) ? event.firstTarget : event.target + } + + + /** + * Getter, Setter and 'Unsetter' for the properties hooked onto the context element. + */ + + static _getPopup(context) { + return context.popup + } + + static _setPopup(context, popup, source) { + this._setPopupSource(popup, source) + context.popup = popup + + //jquery hyphenate below + console.log('hyphenated popup:', $('span').hyphenate('de')) + } + + static _unsetPopup(context) { + context.popup = null + } + + static _setPopupSource(popup, source) { + popup.element.setAttribute("data-src", source) + } + + static _getPopupSource(popup) { + return popup.element.getAttribute("data-src") + } + + static _unsetZoomable(context) { + context.zoomable = null + } + + static _setZoomable(context, zoomable, zoomedFig) { + context.zoomable = [zoomable, zoomedFig] + } + + static _getZoomable(context) { + return (context.zoomable) ? context.zoomable : [] + } + + /** + * Helper function to determine if a proided element is still + * an active highlight. + */ + static _isHighlightActive(context, element) { + let highlights = this._getHighlights(context) + return (highlights.indexOf(element) != -1) + } + + static _getHighlights(context) { + return (context.highlights) ? context.highlights : [] + } + + static _addHighlight(context, highlight) { + if (context.highlights == null) this._unsetHighlight(context) + context.highlights.push(highlight) + } + + static _unsetHighlights(context) { + context.highlights = [] + } + + static _getHighlightSource(highlight) { + return highlight.getAttribute("xlink:href") + } + + static _getActiveSubcard(context) { + return context.querySelector(".mainview > .subcard") + } + + static _setSubcard(context, subcard) { + //console.trace("_setSubcard", context) + context.subcard = subcard + } + + static _getSubcard(context) { + return context.subcard + } + + static _unsetSubcard(context) { + context.subcard = null + } + + static incrementZIndex(context) { + if (!context.zIndex) context.zIndex = 0 + context.zIndex++ + } + + static getZIndex(context) { + return (context.zIndex || 0) + } + + /** + * This getter and setter pair solves the problem, + * when we assign 'relativePath' to a child class, the assign is routed + * to this base class. And we ensure, that we always set the same + * private variable. + * + * Not doing this lead to some cases, when Card-/ScatterCard.func + * was called and depending on context their were different values + * inside the relativePath variable. + */ + static set relativePath(value) { + Card._relativePath = value + } + + static get relativePath() { + return Card._relativePath + } + +} + +Card.debug = true +Card._relativePath = "" +Card.scatterContainer = null +Card.interactionType = "tap" +Card.popupHtmlSelector = ".popupHtml" +Card.dynamicHeight = false + +Card.popupYOffset = -15 + +Card.zIndices = { + article: 10, + popup: 100, + zoomable: 101, + zoomedFigure: 102 +} + +Card.animation = { + articleTransition: 0.6, + fade: 0.2, + popup: 0.1, + highlight: 0.4, + zoomable: 0.5 +} + + +/** Make the class available in the global namespace */ +window.Card = Card + +/** + * Extends the card with scatter functionality. + * + * @class ScatterCard + */ +class ScatterCard extends Card { + + + /** + * TODO: Find a more suitable name. + * Adjusts the HTML to work in the new context. + * + * @static + * @param {*} domElement + * @param {*} htmlString + * @param {*} basePath + * @param {*} [opts={}] + * @memberof Card + */ + static setup(context, htmlString, { + basePath = "./", + modules = [] + } = {}) { + context.classList.add("info-card") + + this.relativePath = basePath + htmlString = this._adjustRelativeLinks(htmlString) + + let parser = new DOMParser() + let html = parser.parseFromString(htmlString, "text/html") + + /** + * Conflicts with the FindTarget method of the Abstract scatter. + */ + this._replaceAttributes(html, "onclick", this._replaceCallback) + + + let content = html.querySelector(".mainview") + context.appendChild(content) + + super.setup(context, modules) + return context + } + + + /** + * Appends a close listener to the scatter element. + * + * @static + * @param {*} element + * @param {*} callback + * @memberof Card + */ + static addOnCloseListener(element, callback) { + if (callback) { + element.onClose = callback + } + } + + + /** + * Creates a scatter for the card and applies the card to it, + * + * @static + * @param {*} html + * @param {*} scatterContainer + * @param {string} [basePath=""] + * @param {*} [opts={}] + * @returns + * @memberof Card + */ + static createCardScatter(html, scatterContainer, { + basePath = "./", + modules = [] + } = {}) { + let element = document.createElement("div") + + scatterContainer.element.appendChild(element) + new DOMScatter(element, scatterContainer, { + width: 1400, + height: 1200 + }) + + this.setup(element, html, { + basePath, + modules + }) + return element + } + + + + /** + *Utility function to create a fully functional card scatter. + * + * @static + * @param {*} scatterContainer + * @param {*} path + * @param {string} [basePath="."] + * @param {*} opts + * @returns + * @memberof CardScatter + */ + static loadAndCreateScatterCard(scatterContainer, item, { + basePath = "../", + modules = null, + onClose = null + } = {}) { + console.log(basePath) + return new Promise((resolve, reject) => { + this.loadHTML(basePath + "/" + item + "/index.html") + .then(html => { + let element = this.createCardScatter(html, scatterContainer, { + basePath, + modules + }) + if (onClose) + this.addOnCloseListener(element, onClose) + resolve(element) + }) + .catch(e => reject(e)) + }) + } + + static _setLanguage(context, language) { + context.language = language + } + + static _getLanguage(context) { + return context.language + } + +} + +window.ScatterCard = ScatterCard + +ScatterCard.selectedLanguage = 0 +ScatterCard.languages = ["Deutsch", "English"] +ScatterCard.languageTags = { + Deutsch: "de", + English: "en" +} +ScatterCard.scatterContainer = null + +var CardPlugin = CardPlugin || {} + +class CardPluginBase { + + apply(context) { + if (this.verify(context)) { + this.append(context) + console.log("Plugin " + this.name + " was verified successfully.") + return true + } else console.error("Could not verify module " + this.name + ".") + return false + } + + get name() { + return this.constructor.name + } + + verify(context) { + let funcs = this._getVerificationFunctions(context) + for (let func of funcs) { + if (!func()) return false + } + return true + } + + _verifyElementsExist(context, ...selectors) { + let missing = [] + + for (let selector of selectors) { + let requiredElement = context.querySelector(selector) + if (requiredElement == null) { + missing.push(selector) + } + } + const valid = (missing.length == 0) + if (!valid) console.error("Elements were missing: ", missing.join(", ")) + return valid + } + + + /** + * Appends the Plugin to the context. + * + * @memberof CardPlugin + */ + append(context) { + console.error("Call of abstract method CardPlugin.prototype.append(context). Plugins need to overwrite the append method!") + } + + _getVerificationFunctions(context) { + return [ + this._verifyContext.bind(this, context), + this._verifyRequirements.bind(this, context) + ] + } + + _verifyContext(context) { + if (!(context instanceof HTMLElement)) { + console.error("Context is not of type HTML Element.", context) + return false + } else return true + } + + _verifyRequirements(context) { + let requirements = this._collectAllRequirements() + let missing = [] + + requirements.forEach(module => { + if (context.modules.indexOf(module.name) == -1) { + missing.push(module.name) + } + }) + + const valid = (missing.length == 0) + if (!valid) console.error("Could not apply module '" + this.name + "'. Following modules are required but were missing: " + missing.join(",")) + else console.log("All requirements were met! Well done!") + return valid + } + + + _collectAllRequirements() { + let requirements = [] + let klass = this.__proto__ + while (klass) { + if (klass.require != null) { + requirements = requirements.concat(klass.require) + } + klass = klass.__proto__ + } + return requirements + } +} + + + + +CardPlugin.LightBox = class LightBox extends CardPluginBase { + constructor(className, style = {}) { + super() + this.className = className + this.style = style + } + + append(context) { + let wrapper = document.createElement("div") + wrapper.className = this.className + + Object.assign(wrapper.style, { + zIndex: 1000, + // backgroundColor: "black", + top: 0, + left: 0, + width: "100%", + height: "100%" + }, this.style, { + display: "none", + position: "absolute", + }) + + context.appendChild(wrapper) + } + +} + + +/** + * The Enlargeable Overlay module allows the user to click on the thumbnail image, + * and the images gets enlarged inside the card. + * + * @class EnlargeableThumbnail + * @extends {CardPlugin} + */ +CardPlugin.EnlargeableThumbnail = class EnlargeableThumbnail extends CardPluginBase { + + constructor(wrapperSelector, overlaySelector = null, { + zoomAnimationDuration = 0.4, + fadeAnimationDuration = 0.4, + interactionType = "tap" + } = {}) { + super() + this.wrapperSelector = wrapperSelector + this.overlaySelector = overlaySelector + + this.zoomAnimationDuration = zoomAnimationDuration + this.fadeAnimationDuration = fadeAnimationDuration + this.interactionType = interactionType + } + + get require() { + return [ + CardPlugin.LightBox + ] + } + + _getVerificationFunctions(context) { + let arr = super._getVerificationFunctions(context) + let funcs = [ + this._verifyElementsExist.bind(this, context, this.wrapperSelector, this.overlaySelector) + ] + return arr.concat(funcs) + } + + append(context) { + let source = this._retrieveSource(context) + this.setupEnlargeableThumbnail(context, source) + } + + + /** + * Get the preview image. + * + * It depends on the fact, that the thumbnail image is in the same directory + * + * + * @param {*} context + * @returns + * @memberof EnlargeableThumbnail + */ + _retrieveSource(context) { + let img = context.querySelector(this.wrapperSelector + " img") + let src = img.getAttribute("src") + let parts = src.split("/") + parts.pop() + parts.push(parts[parts.length - 1]) + let imagePath = parts.join("/") + ".jpg" + return imagePath + } + + + setupEnlargeableThumbnail(context, src) { + let wrapper = context.querySelector(this.wrapperSelector) + let overlay = context.querySelector(this.overlaySelector) + + let icon = document.createElement("div") + icon.className = "button corner-button bottom-right icon zoom" + wrapper.appendChild(icon) + + Object.assign(wrapper.style, { + cursor: "pointer" + }) + + InteractionMapper.on(this.interactionType, wrapper, () => { + this.openThumbnailDetail(context, src) + }) + + InteractionMapper.on(this.interactionType, overlay, () => { + this.closeThumnailDetail(context) + }) + } + + openThumbnailDetail(context, src) { + let overlay = context.querySelector(".img-overlay") + overlay.innerHTML = "" + let source = context.querySelector(this.wrapperSelector) + let sourceStyle = window.getComputedStyle(source) + let imageWrapper = source.cloneNode(true) + let image = imageWrapper.querySelector("img") + + Object.assign(imageWrapper.style, { + maxWidth: "none", + maxHeight: "none" + }) + + Object.assign(image.style, { + width: "100%", + height: "100%", + objectFit: "cover" + }) + + this._replaceIcon(imageWrapper) + + image.onload = () => { + let header = context.querySelector("header") + let headerStlye = window.getComputedStyle(header) + + /** + * First the maxFillRatio is considered. + * It describes how much the image is allowed to exceed the context element. + */ + const maxFillRatio = 1.5 + + /** + * The minor side should not exceed the height of the context window. + */ + const maxMinorSize = context.offsetHeight - 2 * parseInt(headerStlye.paddingTop) - 2 * parseInt(headerStlye.marginTop) + + + + const max = { + width: context.offsetWidth * maxFillRatio, + height: context.offsetHeight * maxFillRatio + } + + + let majorSide + let minorSide + const _width = { name: "width", axis: "x" } + const _height = { name: "height", axis: "y" } + if (image.naturalHeight > image.naturalWidth) { + majorSide = _height + minorSide = _width + } else { + majorSide = _width + minorSide = _height + } + + function capitalize(string) { + return string.charAt(0).toUpperCase() + string.slice(1) + } + function getImageSize(side) { + return image["natural" + capitalize(side.name)] + } + + const majorImageSize = getImageSize(majorSide) + // const minorImageSize = getImageSize(minorSide) + + let ratio = getImageSize(minorSide) / getImageSize(majorSide) + let size = (majorImageSize > max[majorSide.name]) ? max[majorSide.name] : majorImageSize + + if (size * ratio > maxMinorSize) { + size = maxMinorSize / ratio + } + + let targetDimensions = { + width: 0, + height: 0 + } + + + let position = Points.fromPageToNode(context, Points.fromNodeToPage(source, { x: 0, y: 0 })) + + let targetOffset = { + x: 0, + y: 0 + } + + targetDimensions[majorSide.name] = size + targetDimensions[minorSide.name] = size * ratio + + targetOffset[majorSide.axis] = (context["offset" + capitalize(majorSide.name)] - targetDimensions[majorSide.name]) / 2 + targetOffset[minorSide.axis] = (context["offset" + capitalize(minorSide.name)] - targetDimensions[minorSide.name]) / 2 + + overlay.appendChild(imageWrapper) + + TweenMax.set(imageWrapper, { + left: 0, + top: 0, + x: position.x, + y: position.y, + position: "absolute", + width: parseInt(sourceStyle.width), + height: parseInt(sourceStyle.height) + }) + + + TweenMax.set(overlay, { + display: "flex", + autoAlpha: 0 + }) + + TweenMax.to(imageWrapper, this.zoomAnimationDuration, { + x: targetOffset.x, + y: targetOffset.y, + width: targetDimensions.width, + height: targetDimensions.height, + }) + TweenMax.to(overlay, this.fadeAnimationTime, { + autoAlpha: 1 + }) + } + + image.src = src + } + + _replaceIcon(clone) { + let zoomIcon = clone.querySelector(".icon.zoom") + zoomIcon.classList.remove("zoom") + zoomIcon.classList.add("close") + } + + getBorderHeight(style) { + const borderWidth = parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth) + const padding = parseInt(style.paddingTop) + parseInt(style.paddingBottom) + return parseInt(style.width) + borderWidth + padding + } + + getBorderWidth(style) { + const borderWidth = parseInt(style.borderLeftWidth) + parseInt(style.borderRightWidth) + const padding = parseInt(style.paddingLeft) + parseInt(style.paddingRight) + return parseInt(style.width) + borderWidth + padding + } + + closeThumnailDetail(context) { + let overlay = context.querySelector(".img-overlay") + + let timeline = new TimelineLite() + + timeline.to(overlay, this.fadeAnimationDuration, { + autoAlpha: 0 + }).set(overlay, { + display: "none" + }) + } + +} + +CardPlugin.Ui = class UiPlugin extends CardPluginBase { + constructor(className, parent = null) { + super() + this.parent = parent + this.className = className + } + + _getVerificationFunctions(context) { + let arr = super._getVerificationFunctions(context) + let func = [ + this._doesParentExist.bind(this, context, this.parent) + ] + return arr.concat(func) + } + + _doesParentExist(context, parent) { + if (parent == null) return true + let valid = (context.querySelector(parent) != null) + if (!valid) console.error("Could not find parent on context.", context, parent) + return valid + } + + append(context) { + parent = (this.parent == null) ? context : context.querySelector(this.parent).appendChild(container) + let container = document.createElement("div") + container.className = this.className + parent.appendChild(container) + } + +} + +CardPlugin.Speech = class SpeechPlugin extends CardPluginBase { + + constructor(parentSelector, className, interactionType = "tap") { + super() + this.className = className + this.parentSelector = parentSelector + this.interactionType = interactionType + + } + + get require() { + return [ + CardPlugin.Ui + ] + } + + append(context) { + let container = context.querySelector(this.parentSelector) + this.button = document.createElement("div") + this.button.className = "icon button " + this.className + container.appendChild(this.button) + + InteractionMapper.on(this.interactionType, this.button, () => { + let subcard = context.querySelector(".mainview > .subcard") + let target = (subcard) ? subcard : context + + this.speak(target) + + }) + } + + _activate() { + this._disableActive() + this.active = this + this._activateButton() + } + + _activateButton() { + if (this.button) + this.button.classList.add("active") + } + + _deactivate() { + this._deactivateButton() + } + + _deactivateButton() { + if (this.button) + this.button.classList.remove("active") + } + + _isSameNode(node) { + //console.log(this.currentText, node.innerText) + return (this.currentText == node.innerText) + } + + speak(node) { + + console.log(this._isSameNode(node)) + + if (!window.speechSynthesis.speaking) { + console.log("Noone talking!") + this._start(node) + } else if (this._isSameNode(node)) { + console.log("Requested same!") + this._stop() + + } else { + console.log("Requested Different!") + this._stop() + this._start(node) + } + + } + + _disableActive() { + console.log("disableActive:", this.active) + if (this.active) { + this.active._deactivate() + } + } + + _start(node) { + this.currentText = node.innerText + let utterance = new SpeechSynthesisUtterance(node.innerText) + + let voices = window.speechSynthesis.getVoices() + console.log(voices) + let voice = voices.filter((val) => { + //console.log(val) + return val.name == "Microsoft Hedda Desktop - German" + })[0] + + //console.log(voice) + + utterance.voice = voice + console.log("TALK: ", utterance) + window.speechSynthesis.speak(utterance) + this._activate() + window.speechSynthesis.resume() + + + utterance.onboundary = () => { console.log("onboundary", node.innerText); if (this.currentText.substring(0, 5) != node.innerText.substring(0, 5)) { console.log("text for speech synth changed!", this.currentText, node.innerText); this._stop() } } + utterance.onend = () => console.log("onend", node.innerText) + utterance.onerror = () => console.log("onerror", node.innerText) + utterance.onmark = () => console.log("onmark", node.innerText) + utterance.onpause = () => console.log("onpause", node.innerText) + utterance.onresume = () => console.log("onresume", node.innerText) + utterance.onstart = () => console.log("onstart", node.innerText) + utterance.onerror = () => console.log("onerror", node.innerText) + } + + _stop() { + window.speechSynthesis.cancel() + this.currentText = null + this._deactivate() + } + + get active() { + return this.constructor.active + } + + set active(val) { this.constructor.active = val } + + get currentText() { + return this.constructor.text + } + + set currentText(val) { + this.constructor.text = val + } + +} diff --git a/lib/card/theme.js b/lib/card/theme.js new file mode 100644 index 0000000..76b4381 --- /dev/null +++ b/lib/card/theme.js @@ -0,0 +1,46 @@ +/* eslint-disable no-unused-vars */ + +/** + * The Theme class helps manage the card themes. + * Usually it overrides or enhances aspects of the card.js. + * + * @class Theme + */ +class Theme { + + /** + * Loads a config file and parses it to JSON. + * + * @static + * @param {string} [path=null] - Optional: A path where the config file is located at (including the filename and extension). + * @returns {Promise} - Returns a promise, that returns the parsed json file when resolved. + * @memberof Theme + */ + static loadConfig(path = null) { + return new Promise((resolve, reject) => { + path = (path) ? path : './config.json' + + let xhttp = new XMLHttpRequest() + xhttp.onreadystatechange = function () { + if (this.readyState == 4) { + + if (this.status == 200 || Theme._isLocal()) { + try { + const json = JSON.parse(this.responseText) + resolve(json) + + } catch (e) { + reject(e) + } + } else reject('Invalid request : ' + this.status) + } + } + xhttp.open('GET', path, true) + xhttp.send() + }) + } + + static _isLocal() { + return (window.location.protocol == 'file:') + } +}