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:')
+ }
+}