diff --git a/dist/iwmlib.js b/dist/iwmlib.js index 5112503..561296d 100644 --- a/dist/iwmlib.js +++ b/dist/iwmlib.js @@ -924,7 +924,7 @@ /** Static methods to compute 2D points with x and y coordinates. */ - class Points { + class Points$1 { static length(a) { return Math.sqrt(a.x * a.x + a.y * a.y) } @@ -1286,7 +1286,7 @@ absolutePoints() { let result = new Array(); for (let p of this.points) { - result.push(Points.add(p, this.center)); + result.push(Points$1.add(p, this.center)); } return result } @@ -1294,7 +1294,7 @@ flatAbsolutePoints() { let result = new Array(); for (let p of this.points) { - let a = Points.add(p, this.center); + let a = Points$1.add(p, this.center); result.push(a.x); result.push(a.y); } @@ -1338,10 +1338,10 @@ } multiplyScalar(scale) { - let center = Points.multiplyScalar(this.center, scale); + let center = Points$1.multiplyScalar(this.center, scale); let clone = new Polygon(center); for (let p of this.points) { - clone.addPoint(Points.multiplyScalar(p, scale)); + clone.addPoint(Points$1.multiplyScalar(p, scale)); } return clone } @@ -1485,7 +1485,7 @@ min.y = Math.min(p.y, min.y); max.y = Math.max(p.y, max.y); } - let center = Points.mean(min, max); + let center = Points$1.mean(min, max); let polygon = new Polygon(center); for (let p of points) { polygon.addAbsolutePoint(p); @@ -1498,7 +1498,7 @@ /** * Util functions to deal with DOMRects. */ - class Rect { + class Rect$1 { /** @@ -1667,7 +1667,7 @@ * The logging handlers can be overwritten by calling the static * setup method. */ - class Logging { + class Logging$1 { /** Static log function. * @param {*} message @@ -1806,7 +1806,7 @@ } } let sorted = pairs.sort((a, b) => { - return Points.distance(b[0], b[1]) - Points.distance(a[0], a[1]) + return Points$1.distance(b[0], b[1]) - Points$1.distance(a[0], a[1]) }); return sorted[0] } @@ -1873,13 +1873,13 @@ moved(key) { let current = this.current.get(key); let previous = this.previous.get(key); - return Points.subtract(current, previous) + return Points$1.subtract(current, previous) } move() { let current = this.current.mean(); let previous = this.previous.mean(); - return Points.subtract(current, previous) + return Points$1.subtract(current, previous) } /** @@ -1916,21 +1916,21 @@ let p1 = this.previous.get(c1.key); let p2 = this.previous.get(c2.key); - let d1 = Points.subtract(c1, p1); - let d2 = Points.subtract(c2, p2); - let cm = Points.mean(c1, c2); + let d1 = Points$1.subtract(c1, p1); + let d2 = Points$1.subtract(c2, p2); + let cm = Points$1.mean(c1, c2); // Using the mean leads to jumps between time slices with 3 and 2 fingers // We use the mean of deltas instead - let delta = Points.mean(d1, d2); + let delta = Points$1.mean(d1, d2); let zoom = 1.0; - let distance1 = Points.distance(p1, p2); - let distance2 = Points.distance(c1, c2); + let distance1 = Points$1.distance(p1, p2); + let distance2 = Points$1.distance(c1, c2); if (distance1 != 0 && distance2 != 0) { zoom = distance2 / distance1; } - let currentAngle = Points.angle(c1, c2); - let previousAngle = Points.angle(p1, p2); + let currentAngle = Points$1.angle(c1, c2); + let previousAngle = Points$1.angle(p1, p2); let alpha = this.diffAngle(currentAngle, previousAngle); return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, csize) } else if (csize == 1 && psize == 1 && this.current.firstKey() == this.previous.firstKey()) { @@ -1938,7 +1938,7 @@ // can jump let current = this.current.first(); let previous = this.previous.first(); - let delta = Points.subtract(current, previous); + let delta = Points$1.subtract(current, previous); return new InteractionDelta(delta.x, delta.y, 1.0, 0.0, current, csize) } return null @@ -2097,7 +2097,7 @@ if ( start && ended && - Points.distance(ended, start) < this.tapDistance + Points$1.distance(ended, start) < this.tapDistance ) { let t1 = this.timestamps.get(key); let tookLong = performance.now() > t1 + this.longPressTime; @@ -2116,7 +2116,7 @@ } if (this.tapPositions.has(key)) { let pos = this.tapPositions.get(key); - if (Points.distance(ended, pos) > this.tapDistance) { + if (Points$1.distance(ended, pos) > this.tapDistance) { this.unregisterTap(key); } } @@ -2149,7 +2149,7 @@ if ( start && ended && - Points.distance(ended, start) < this.tapDistance + Points$1.distance(ended, start) < this.tapDistance ) { let t1 = this.timestamps.get(key); let tookLong = performance.now() > t1 + this.longPressTime; @@ -2656,7 +2656,7 @@ let size = this.interaction.current.size; let limit = this.logInteractionsAbove; if (size > limit) { - Logging.log(`Number of interactions ${size} exceeds ${limit}`); + Logging$1.log(`Number of interactions ${size} exceeds ${limit}`); } } @@ -3129,14 +3129,14 @@ let v = this.velocities[i]; t += v.dt; let nv = { x: v.dx / v.dt, y: v.dy / v.dt }; - sum = Points.add(sum, nv); + sum = Points$1.add(sum, nv); count += 1; if (t > milliseconds) { break } } if (count === 0) return sum // empty vector - return Points.multiplyScalar(sum, 1 / count) + return Points$1.multiplyScalar(sum, 1 / count) } killAnimation() { @@ -3168,15 +3168,15 @@ let dt = this._throwDeltaTime(); // console.log("animateThrow", dt) let next = this.nextVelocity(this.velocity); - let prevLength = Points.length(this.velocity); - let nextLength = Points.length(next); + let prevLength = Points$1.length(this.velocity); + let nextLength = Points$1.length(next); if (nextLength > prevLength) { let factor = nextLength / prevLength; - next = Points.multiplyScalar(next, 1 / factor); + next = Points$1.multiplyScalar(next, 1 / factor); console.log('Prevent acceleration', factor, this.velocity, next); } this.velocity = next; - let d = Points.multiplyScalar(this.velocity, dt); + let d = Points$1.multiplyScalar(this.velocity, dt); this._move(d); this.onDragUpdate(d); @@ -3200,13 +3200,13 @@ if (this.velocity == null) { return false } - return Points.length(this.velocity) > 0.01 + return Points$1.length(this.velocity) > 0.01 } nextVelocity(velocity) { // Must be overwritten: computes the changed velocity. Implement // damping, collison detection, etc. here - let next = Points.multiplyScalar(velocity, this.throwDamping); + let next = Points$1.multiplyScalar(velocity, this.throwDamping); return { x: (this.movableX) ? next.x : 0, y: (this.movableY) ? next.y : 0 @@ -3390,8 +3390,8 @@ // towards the center of the stage let center = this.center; let target = this.container.center; - let delta = Points.subtract(target, center); - return Points.normalize(delta) + let delta = Points$1.subtract(target, center); + return Points$1.normalize(delta) } nextVelocity(velocity) { @@ -3453,7 +3453,7 @@ factor = collision; } // } - return Points.multiplyScalar({ x: dx, y: dy }, factor) + return Points$1.multiplyScalar({ x: dx, y: dy }, factor) } return super.nextVelocity(velocity) } @@ -3505,13 +3505,13 @@ moveTo(p, { animate = 0 } = {}) { let c = this.origin; - let delta = Points.subtract(p, c); + let delta = Points$1.subtract(p, c); this.move(delta, { animate: animate }); } centerAt(p, { animate = 0 } = {}) { let c = this.center; - let delta = Points.subtract(p, c); + let delta = Points$1.subtract(p, c); this.move(delta, { animate: animate }); } @@ -3574,19 +3574,19 @@ return } let origin = this.rotationOrigin; - let beta = Points.angle(origin, anchor); - let distance = Points.distance(origin, anchor); + let beta = Points$1.angle(origin, anchor); + let distance = Points$1.distance(origin, anchor); let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale(zoom); - let newOrigin = Points.arc(anchor, beta + rotate, distance * thresholdedZoom); - let extra = Points.subtract(newOrigin, origin); - let offset = Points.subtract(anchor, origin); + let newOrigin = Points$1.arc(anchor, beta + rotate, distance * thresholdedZoom); + let extra = Points$1.subtract(newOrigin, origin); + let offset = Points$1.subtract(anchor, origin); this._move(offset); this.scale = newScale; this.rotation += rotate; - offset = Points.negate(offset); - offset = Points.add(offset, extra); - offset = Points.add(offset, translate); + offset = Points$1.negate(offset); + offset = Points$1.add(offset, extra); + offset = Points$1.add(offset, translate); this._move(offset); delta.x += extra.x; @@ -3957,7 +3957,7 @@ } mapPositionToPoint(point) { - return Points.fromPageToNode(this.element, point) + return Points$1.fromPageToNode(this.element, point) } isDescendant(parent, child, clickable = false) { @@ -4018,7 +4018,7 @@ - class DOMScatter extends AbstractScatter { + class DOMScatter$1 extends AbstractScatter { constructor( element, container, @@ -4199,7 +4199,7 @@ get origin() { let p = this.fromNodeToPage(0, 0); - return Points.fromPageToNode(this.container.element, p) + return Points$1.fromPageToNode(this.container.element, p) } get bounds() { @@ -4305,7 +4305,7 @@ bringToFront() { // this.element.parentNode.appendChild(this.element) // uo: On Chome and Electon appendChild leads to flicker - TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ }); + TweenLite.set(this.element, { zIndex: DOMScatter$1.zIndex++ }); } onTap(event, interaction, point) { @@ -4327,11 +4327,11 @@ } fromPageToNode(x, y) { - return Points.fromPageToNode(this.element, { x, y }) + return Points$1.fromPageToNode(this.element, { x, y }) } fromNodeToPage(x, y) { - return Points.fromNodeToPage(this.element, { x, y }) + return Points$1.fromNodeToPage(this.element, { x, y }) } _move(delta) { @@ -4370,7 +4370,7 @@ let newPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top }; - let offset = Points.subtract(oldPostition, newPostition); + let offset = Points$1.subtract(oldPostition, newPostition); this.oldX = e.clientX; this.oldY = e.clientY; @@ -4424,7 +4424,7 @@ let oldPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top }; this.element.style.transformOrigin = '50% 50%'; let newPostition = { x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top }; - let offset = Points.subtract(oldPostition, newPostition); + let offset = Points$1.subtract(oldPostition, newPostition); TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } }); TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } }); @@ -4435,7 +4435,7 @@ } } - DOMScatter.zIndex = 1000; + DOMScatter$1.zIndex = 1000; class CardLoader { constructor( @@ -4693,7 +4693,7 @@ frontLoaded(loader) { return new Promise((resolve, reject) => { - let scatter = new DOMScatter( + let scatter = new DOMScatter$1( this.cardWrapper, this.domScatterContainer, { @@ -4917,7 +4917,7 @@ bringToFront() { this.scatter.bringToFront(); - TweenLite.set(this.element, { zIndex: DOMScatter.zIndex++ }); + TweenLite.set(this.element, { zIndex: DOMScatter$1.zIndex++ }); } clickInfo() { @@ -5231,7 +5231,7 @@ /** A Popup that shows text labels, images, or html */ - class Popup extends Poppable { + class Popup$1 extends Poppable { /** * Creates an instance of Popup. * @param {any} [{ @@ -6095,7 +6095,7 @@ let notchPosition = (switchPos && point.y < 50) ? "topCenter" : "bottomCenter"; - let popup = new Popup({ + let popup = new Popup$1({ parent, context, fontFamily, @@ -6183,7 +6183,7 @@ let target = event.target; let x = event.pageX; let y = event.pageY; - let rect = Popup.targetRect(event); + let rect = Popup$1.targetRect(event); if (rect != null) { x = rect.left + rect.width / 2; y = rect.top + rect.height / 2; @@ -6194,7 +6194,7 @@ /** A Popup Menu that shows text labels in a vertical row. */ - class PopupMenu extends Popup { + class PopupMenu extends Popup$1 { /** * The constructor. * @constructor @@ -6411,7 +6411,7 @@ let found = document.elementFromPoint(global.x, global.y); let iframe = found.querySelector('iframe'); if (iframe) { - let p = Points.fromPageToNode(found, global); + let p = Points$1.fromPageToNode(found, global); let doc = iframe.contentWindow.document; let target = doc.elementFromPoint(p.x, p.y); if (target != null) { @@ -6436,7 +6436,7 @@ } simulateMouseEvent(type, point) { - let p = Points.fromPageToNode(this.frame, point); + let p = Points$1.fromPageToNode(this.frame, point); let event = new MouseEvent(type, { view: this.frame.contentWindow, bubbles: true, @@ -6451,7 +6451,7 @@ let doc = this.frame.contentWindow.document; for(let key of pointMap.keys()) { let point = pointMap.get(key); - let p = Points.fromPageToNode(this.frame, point); + let p = Points$1.fromPageToNode(this.frame, point); let touchTarget = doc.elementFromPoint(p.x, p.y); let touch = new Touch(undefined, touchTarget, key, p.x, p.y, p.x, p.y); @@ -6462,7 +6462,7 @@ simulateTouchEventChrome(type, point, pointMap) { let doc = this.frame.contentWindow.document; - let p = Points.fromPageToNode(this.frame, point); + let p = Points$1.fromPageToNode(this.frame, point); let touchTarget = doc.elementFromPoint(p.x, p.y); const touchObj = new Touch({ identifier: Date.now(), @@ -6490,7 +6490,7 @@ } simulateTouchEventSafari(type, point, pointMap, touchEventKey='targetTouches') { - let p = Points.fromPageToNode(this.frame, point); + let p = Points$1.fromPageToNode(this.frame, point); let data = { view: this.frame.contentWindow, bubbles: true, cancelable: true, @@ -7616,18 +7616,18 @@ let element = this.domNode; let activeNodes = this.activeNodes(); let globalClick = (event.center) ? event.center : { x: event.x, y: event.y }; - let localClick = Points.fromPageToNode(element, globalClick); + let localClick = Points$1.fromPageToNode(element, globalClick); let clickRects = activeNodes.map(link => { let rect = link.getBoundingClientRect(); - let topLeft = Points.fromPageToNode(element, rect); - let center = Points.fromPageToNode(element, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + let topLeft = Points$1.fromPageToNode(element, rect); + let center = Points$1.fromPageToNode(element, { 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 = []; clickRects.forEach(rect => { - let distance = Points.distanceToRect(localClick, rect); + let distance = Points$1.distanceToRect(localClick, rect); distances.push(parseInt(distance)); }); @@ -7735,7 +7735,7 @@ * @class Highlight * @extends {Object} */ - class Highlight extends Object { + class Highlight$1 extends Object { static disableAnimations() { _HighlightEnabled = false; @@ -7883,7 +7883,7 @@ this.shrink(m, { stroke }); } - Highlight._createSVGMask(svgRoot, image, id); + Highlight$1._createSVGMask(svgRoot, image, id); // TweenLite.set(maskImage, { transformOrigin: `${tx}% ${ty}%` }) @@ -7898,7 +7898,7 @@ scale = 2, onExpanded = null } = {}) { - if (Highlight._isExpanded(target)) { + if (Highlight$1._isExpanded(target)) { console.log('Target is already expanded!'); return } else { @@ -7920,8 +7920,8 @@ let image = svgRoot.querySelector('image'); // eslint-disable-next-line no-unused-vars - let [mask, maskImage] = Highlight._getSVGMask(target, { svgRoot, image }); - let center = Highlight._calculateCenterRelativeTo(target, image); + let [mask, maskImage] = Highlight$1._getSVGMask(target, { svgRoot, image }); + let center = Highlight$1._calculateCenterRelativeTo(target, image); TweenLite.set(maskImage, { transformOrigin: `${center.x}% ${center.y}%` }); TweenLite.set(target, { transformOrigin: '50% 50%' }); @@ -7936,11 +7936,11 @@ } static toggleHighlight(node) { - if (Highlight._isExpanded(node)) { - Highlight.closeHighlight(node); + if (Highlight$1._isExpanded(node)) { + Highlight$1.closeHighlight(node); } else { - Highlight.openHighlight(node); + Highlight$1.openHighlight(node); } } @@ -7963,7 +7963,7 @@ let maskImage = svgRoot.getElementById(maskImageId); if (!mask || !maskImage) - [mask, maskImage] = Highlight._createSVGMask(circle, { svgRoot, image, id }); + [mask, maskImage] = Highlight$1._createSVGMask(circle, { svgRoot, image, id }); return [mask, maskImage] } @@ -8050,23 +8050,23 @@ } static _isExpanded(target) { - return target.classList.contains(Highlight.expandedClass) + return target.classList.contains(Highlight$1.expandedClass) } static _setExpanded(target) { - target.classList.add(Highlight.expandedClass); + target.classList.add(Highlight$1.expandedClass); } static _notExpanded(target) { - target.classList.remove(Highlight.expandedClass); + target.classList.remove(Highlight$1.expandedClass); } static closeHighlight(target, { animation = 0.5 } = {}) { console.log('Close Highlight'); - Highlight._notExpanded(target); + Highlight$1._notExpanded(target); // eslint-disable-next-line no-unused-vars - let [mask, maskImage] = Highlight._getSVGMask(target); + let [mask, maskImage] = Highlight$1._getSVGMask(target); TweenLite.to([target, maskImage], animation, { scale: 1 @@ -8078,7 +8078,7 @@ return event.stopPropagation(); - Highlight.animateCircle(event.target); + Highlight$1.animateCircle(event.target); return false } @@ -8098,7 +8098,2046 @@ } } - Highlight.expandedClass = 'expanded'; + Highlight$1.expandedClass = 'expanded'; + + /** + * 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 = {}) { + 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: + *