'use strict' /* globals Logging Power2 Points InteractionMapper mainController Popup saved popup Rect ExpoScaleEase*/ import Highlight from './highlight.js' /* * TODO: @ue are these 2 values still necessary? */ /** To avoid problems with relative URL paths, we use inline data URI to load svg icons. */ // eslint-disable-next-line no-unused-vars const closeIconDataURI = `data:image/svg+xml;utf8, ` // eslint-disable-next-line no-unused-vars const resizeIconDataURI = `data:image/svg+xml;utf8, ` // TODO: @ue Is this constant necessary? 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. */ export default class Card { static setup(context, modules = []) { if (this.debug) console.log('Setup Card...', modules) /** * This is required for the callback functions to work properly. */ window.Card = Card context.modules = [] context.module = {} context.onClose = null context.classList.add('info-card') context.setAttribute('data-id', Card.id++) modules.forEach(module => { if (module.apply(context)) { const moduleName = module.constructor.name context.modules.push(moduleName) context.module[moduleName] = module } }) } /** * Removes the context and all registered components of the card. * * @static * @param {DOMElement} context - The DOMElement representing the card. * @memberof Card */ static remove(context) { //Remove all modules that are registered to the context. for (let module of Object.values(context.module)) { const moduleHasRemoveFunction = typeof module.remove === 'function' if (moduleHasRemoveFunction) { module.remove() } } // Remove the context itself. if (context.parentNode != null) { context.parentNode.removeChild(context) } else { console.warn('Tried removing card but it has already been removed.') } } /** * Closes the card when an event happens on the card. * Either the card is removed or the onClose callback is called. * * NOTE: Make sure to remove the card manually, when using the onClose callback! * * INFO: I just revisited this function and was wondering, why it takes the event as * parameter instead of the context. The problem is that the card template defines * the functions inside the HTML. When loaded, the HTML attributes need to be adjusted * and the functions are overwritten. Using during the post processing step withing the postProcessResponseText function. * * @static * @param {Event} event - event triggered on the car * @memberof Card */ static close(event) { let context = this.getContext(event.target) if (context) { if (context.onClose != null) { context.onClose() } else { this.remove(context) } } } /** * Adds an on close method to the provided context. * This will overwrite the default closing behaviour. * Removing the * * @static * @param {DOMElement} context - Context on which the onClose will be set. * @param {Function} callback - Callback function of the onClose. * @memberof Card */ static setOnClose(context, callback) { if (context.onClose != null) console.error('OnClose was already set. It was overwritten by the new method.') context.onClose = callback } /** * Unsets the onClose. * * Note: This may be used in conjunction with the setOnClose method. * Using the setOnClose method to adjust behaviour before closing the card. * Then unsetting the onClose to close the Card appropriately by calling the * Card.Close again. * * @static * @param {DOMElement} context - Context on which the remove will be executed. * @memberof Card */ static removeOnClose(context) { context.onClose = null } /** * Replaces a set of attributes using a provided function. * * @static * @param {*} html * @param {*} attribute * @param {*} replaceFunc * @memberof Card */ static _replaceAttributes(context, html, attribute, replaceFunc) { let attributeCarrier = html.querySelectorAll(`[${attribute}]`) attributeCarrier.forEach(element => { let attributeVal = element.getAttribute(attribute) element.removeAttribute(attribute) replaceFunc.call(this, context, element, attributeVal) }) } /** * Replaces the onClick callback of the element with an * Interaction mapper event. * * @static * @param {*} element * @param {*} attributeVal * @returns * @memberof Card */ static _replaceCallback(context, element, attributeVal) { if (element.tagName == 'A') { element.addEventListener('pointerdown', 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() != '' }) /** * As we determine a function by a string we must traverse from the window object to * get the associated javascript function. */ let callStack = window let last = 'window' do { let func = callParts.shift().trim() if (callStack[func] == null) { callStack = null console.error( `Could not access callback function: ${attributeVal}. Member ${func} of ${last} could not be found.` ) break } else callStack = callStack[func] } while (callParts.length > 0) let targetFunc = callStack 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 this.registerEvent(context, interactionType, element, event => { /** * Replaces the strings from the listener with the corresponding 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 (targetFunc) { targetFunc.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 occurrence 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 () { 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 } ) } /** * Concatenates 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() InteractionMapper.off(popup.element) 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 flashing 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, () => { this._cleanup(context) }) Object.assign(popup.element.style, { zIndex: this.zIndices.popup }) TweenLite.to(popup.element, this.animation.popup, { autoAlpha: 1, ease: Power2.easeIn }) }) return promise } /** * LEGACY but this may be required still for the editor */ 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 interferes with the editor. */ if (overlay) { TweenLite.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') TweenLite.set(overlay, { autoAlpha: 0 }) context.appendChild(overlay) TweenLite.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() } else { /** This may be in conflict with the cleanup method. */ //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') if (this.debug) 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 } static _calculateCenterRelativeTo(target, image) { // console.log('_calculateCenterRelativeTo', target, image) let bbox = image.getBBox() let width = bbox.width let height = bbox.height let cx = target.getAttribute('cx') let cy = target.getAttribute('cy') let r = target.getAttribute('r') let radius = r.endsWith('%') ? (parseFloat(r) / 100) * width : parseFloat(r) let x = cx.endsWith('%') ? (parseFloat(cx) / 100) * width : cx let y = cy.endsWith('%') ? (parseFloat(cy) / 100) * height : cx // console.log({ x, y, width, height, radius }) return { x, y } } /** * 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, node = null) { if (this.debug) console.log('Card.loadHighlightPopup', event, node) if (node == null) { 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')) let svgRoot = node.closest('svg') /* 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 svgPoint = svgRoot.createSVGPoint() svgPoint.x = position.x svgPoint.y = position.y - radius let matrix = node.getCTM() let point = svgPoint.matrixTransform(matrix) let closestDiv = node.closest('div') // console.log('closestDiv', closestDiv, point) let global = Points.fromNodeToPage(closestDiv, 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('loadHighlightPopup', src, position, local) this._loadPopupContent(context, src) .then(content => { this._openPopup(context, src, local, content, { highlight: node, closeCommand: popup => { 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(context, 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(context, 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: ${source}`) } } } 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') if (zoomable == null) { return } // 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: Renamed 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, () => { 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' TweenLite.set(zoomedFig, { x: current.x, y: current.y, width: current.width + borderX, height: current.height + borderY, transformOrigin }) TweenLite.set(zoomable, { opacity: 0 }) let icon = zoomedFig.querySelector('.icon') TweenLite.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) TweenLite.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 = () => { zoomIcon.style.display = savedDisplay iconClone.remove() zoomIcon.setAttribute('onclick', savedOnClick) zoomedFig.remove() zoomContainer.remove() zoomParent.appendChild(zoomable) TweenLite.set(zoomable, { x: 0, y: 0 }) zoomable.onmousedown = null zoomable.onmousemove = null zoomable.onmouseup = null zoomable.onmousewheel = null } wrapper.appendChild(iconClone) TweenLite.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 TweenLite.set([zoomable, iconClone], { x: '+=' + dx, y: '+=' + dy }) zoomable.dragStartPos = { x: event.pageX, y: event.pageY } if (editor) { editor.showControls() } } } zoomable.onmouseup = () => { 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 TweenLite.set(zoomable, { scale: startZoom }) if (editor) { editor.showControls() } } return } /** * Closes an zoomable object with an animation. * * @static * @param {DOMElement} context - Context of the zoomable. * @param {*} zoomable * @param {*} zoomedFig * @memberof Card */ static closeZoomable(context, zoomable, zoomedFig) { if (this.debug) console.log('Close Zoomable', context, zoomable, zoomedFig) //TODO: Why do I need this check. Shouldn't it be always present?! - SO if (zoomable) { this._unsetZoomable(context) let caption = zoomable.querySelector('figcaption.cap') zoomable.removeChild(caption) zoomable.appendChild(caption) let zoomedCaption = zoomedFig.querySelector('figcaption.zoomcap') TweenLite.to(zoomedCaption, this.animation.fade, { autoAlpha: 0 }) TweenLite.to(zoomedFig, this.animation.zoomable, { css: { scaleX: 1, scaleY: 1 }, onComplete: () => { TweenLite.set(zoomable, { opacity: 1 }) let div = zoomedFig.parentNode let videoElement = div.querySelector('video') if (videoElement) videoElement.pause() div.parentNode.removeChild(div) } }) InteractionMapper.off(zoomedFig) } } /** * Starts and controls the expansion animation of an index card. * The assumed card structure is as follows: *