613 lines
18 KiB
JavaScript
613 lines
18 KiB
JavaScript
|
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._stopThisSpeechIfPlaying()
|
||
|
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)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Don't remember why this was required - SO 20-11-2019
|
||
|
*/
|
||
|
_domWasChanged(event) {
|
||
|
if (event.target == this.context) this._stopThisSpeechIfPlaying()
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Stops the module if it is set in the context.
|
||
|
*/
|
||
|
_stopThisSpeechIfPlaying() {
|
||
|
if (this.context == null || this.context['lastSpeechNode'] == window.speechSynthesis['speechPluginNode']) {
|
||
|
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
|
||
|
*/
|
||
|
|
||
|
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')
|
||
|
}
|
||
|
|
||
|
closed() {}
|
||
|
|
||
|
_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')
|
||
|
}
|
||
|
}
|