export var CardPlugin = CardPlugin || {} export class CardPluginBase { apply(context) { this.context = 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 } /** * Called when the card is removed. * Can be used to cleanup the plugin. * * @memberof CardPluginBase */ remove() {} } 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 // We directly overwriting the function with a version that has a binded // reference to itself. Doing so provides an easy and reliable way to remove // the event listener using this function. - SO this._domWasChanged = this._domWasChanged.bind(this) /* Speech doesn't stop when page is navigated. Therefore we do it manually here. */ window.addEventListener('beforeunload', () => { window.speechSynthesis.cancel() }) // Binding the function beforehand ensures, that the end function is always the same. this._end = this._end.bind(this) this._setupUtterance() this.utterance.addEventListener('end', event => { this._end() }) } get require() { return [CardPlugin.Ui] } subcardChanged(closed) { if (this.cardActive) { this._updateText(closed) } } get cardActive() { return this.activeUtterance == this.utterance } _updateText(ignoreSubcard = false) { let node = this.context let subcard = node.querySelector('.mainview > .subcard') if (ignoreSubcard) { if (subcard != null) { let clone = node.cloneNode(true) let clonedSubcard = clone.querySelector('.mainview > .subcard') clonedSubcard.parentNode.removeChild(clonedSubcard) node = clone } } else { if (subcard) { let clone = subcard.cloneNode(true) clone.querySelectorAll('figure').forEach(figure => { figure.parentNode.removeChild(figure) }) node = clone } } let id = this.context.getAttribute('data-id') let src = this.context.getAttribute('data-source') let subcardSource = null if (subcard != null) { subcardSource = subcard.getAttribute('data-source') } if (!window.speechSynthesis.speaking) { this._start(node) Logging.log(`Started speech on card: id:${id} - source: ${src} - subcard: ${subcardSource}`) } else if (this.cardActive && this._sameText(node)) { Logging.log(`Stopped speech on card: id:${id} - source: ${src} - subcard: ${subcardSource}`) this._stop() } else { Logging.log(`Updated Text on card: id:${id} - source: ${src} - subcard: ${subcardSource}`) this._stop() .then(() => { this._start(node) }) .catch(console.error) } } _sameText(node) { return this.utterance.text == this._cleanupText(node) } _setupUtterance() { this.utterance = new SpeechSynthesisUtterance() this.utterance.lang = 'de-DE' } get require() { return [CardPlugin.Ui] } remove() { this.button = null this.context.removeEventListener('DOMNodeRemoved', this._domWasChanged) super.remove() } 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, () => { this.speak() }) context.addEventListener('DOMNodeRemoved', this._domWasChanged) } _domWasChanged(event) { if (this.context == null) this._stop() else if ( this.context['lastSpeechNode'] == window.speechSynthesis['speechPluginNode'] && event.target == this.context ) { this._stop() } } _isSameNode(node) { return this.currentText == node.textContent } speak() { /** * This is a little bit ugly, but imho the most elegant of all dirty solutions. * 5ht * Within the plugins we have no knowledge of other cards and such. But must differentiate the * clicks by their corresponding owner. The SpeechUtterance just takes a text and has no knowledge * about the node that is currently read to the user. * * This means, that we can identify same text, but not differentiate same text on different nodes. * To account for that, we add the node to the speechSynthesis object (#benefitsOfJavaScript) and * have access to the node, by - let's say - expanding the functionality of the SpeechSynthesis object. * * SO -17.07.19 */ let activeNode = window.speechSynthesis['speechPluginNode'] this._updateText() } async _stop() { return new Promise(resolve => { if (this.activeUtterance) { this.activeUtterance.addEventListener('end', resolve, { once: true }) } window.speechSynthesis.cancel() }) } get activeUtterance() { return window.speechSynthesis['speechPluginUtterance'] } _end() { window.speechSynthesis['speechPluginNode'] = null window.speechSynthesis['speechPluginUtterance'] = null this._deactivateButton() this.context.classList.remove('speech-plugin-is-reading') } _start(node) { window.speechSynthesis.cancel() window.speechSynthesis['speechPluginUtterance'] = this.utterance window.speechSynthesis['speechPluginNode'] = node this.context['lastSpeechNode'] = node let cleanText = this._cleanupText(node) this.utterance.text = cleanText window.speechSynthesis.speak(this.utterance) this._activateButton() this.context.classList.add('speech-plugin-is-reading') } _cleanupText(node) { let text = node.textContent text = this._removeShy(text) return text } _removeShy(text) { return text.replace(/\u00AD/g, '') } _activateButton() { if (this.button) this.button.classList.add('active') } _deactivateButton() { if (this.button) this.button.classList.remove('active') } }