2563 lines
84 KiB
JavaScript
2563 lines
84 KiB
JavaScript
|
|
|
|
/** To avoid problems with relative URL paths, we use inline data URI to load svg icons. */
|
|
const closeIconDataURI = `data:image/svg+xml;utf8,
|
|
<?xml version="1.0" encoding="utf-8" ?>
|
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
|
|
<line x1="20" y1="20" x2="80" y2="80" stroke="black" stroke-width="8" />
|
|
<line x1="80" y1="20" x2="20" y2="80" stroke="black" stroke-width="8" />
|
|
</svg>
|
|
`
|
|
|
|
const resizeIconDataURI = `data:image/svg+xml;utf8,
|
|
<?xml version="1.0" encoding="utf-8" ?>
|
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
|
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
|
|
<line x1="20" y1="80" x2="80" y2="20" stroke="lightgray" stroke-width="8" />
|
|
<line x1="40" y1="80" x2="80" y2="40" stroke="lightgray" stroke-width="8" />
|
|
</svg>
|
|
`
|
|
|
|
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 class Card {
|
|
|
|
static setup(context, modules = []) {
|
|
console.log("Setup Card...", modules)
|
|
context.modules = []
|
|
modules.forEach(module => {
|
|
if (module.apply(context))
|
|
context.modules.push(module.constructor.name)
|
|
})
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @static
|
|
* @param {*} event
|
|
* @memberof Card
|
|
*/
|
|
static close(event) {
|
|
let context = this.getContext(event.target)
|
|
if (context) {
|
|
if (context.onClose) {
|
|
context.onClose(event)
|
|
} else context.parentNode.removeChild(context)
|
|
} else console.error("Could not find context!", event.target)
|
|
}
|
|
|
|
/**
|
|
* Replaces a set of attributes using a provided function.
|
|
*
|
|
* @static
|
|
* @param {*} html
|
|
* @param {*} attribute
|
|
* @param {*} replaceFunc
|
|
* @memberof Card
|
|
*/
|
|
static _replaceAttributes(html, attribute, replaceFunc) {
|
|
let clickables = html.querySelectorAll(`[${attribute}]`)
|
|
clickables.forEach(element => {
|
|
|
|
let attributeVal = element.getAttribute(attribute)
|
|
element.removeAttribute(attribute)
|
|
replaceFunc.call(this, element, attributeVal)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Replaces the onClick callback of the element with an
|
|
* Interaction mapper event.
|
|
*
|
|
* @static
|
|
* @param {*} element
|
|
* @param {*} attributeVal
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static _replaceCallback(element, attributeVal) {
|
|
|
|
if (element.tagName == "A") {
|
|
element.addEventListener("click", event => { event.preventDefault() })
|
|
}
|
|
|
|
|
|
let callbackParts = attributeVal.split("(")
|
|
let funcPart = callbackParts[0].trim()
|
|
let trimmedArgs = callbackParts[1].trim()
|
|
|
|
//Remove the closing ')'
|
|
trimmedArgs = trimmedArgs.substring(0, trimmedArgs.length - 1)
|
|
|
|
|
|
let callParts = funcPart.split(".")
|
|
let argsStrings = trimmedArgs.split(",").filter(entry => { return entry.trim() != "" })
|
|
|
|
|
|
let callStack = window
|
|
do {
|
|
callStack = callStack[callParts.shift().trim()]
|
|
} while (callParts.length > 0)
|
|
|
|
let that = this
|
|
|
|
let interactionType = this.interactionType
|
|
|
|
//Remove the events on the circle.
|
|
// These are 'hardcoded' inside the convert.js.
|
|
if (element.tagName == "circle") return false
|
|
|
|
|
|
InteractionMapper.on(interactionType, element, (event) => {
|
|
|
|
/**
|
|
* Replaces the strings from the listener with the cooresponding variables.
|
|
*/
|
|
let args = []
|
|
argsStrings.forEach(arg => {
|
|
arg = arg.trim()
|
|
if (arg == "this")
|
|
args.push(event.target)
|
|
else if (arg == "event")
|
|
args.push(event)
|
|
else {
|
|
const firstCharacter = arg[0]
|
|
if (firstCharacter == "\"" || firstCharacter == "'") {
|
|
arg = arg.substring(1)
|
|
}
|
|
const lastCharacter = arg[arg.length - 1]
|
|
if (lastCharacter == "\"" || lastCharacter == "'") {
|
|
arg = arg.substring(0, arg.length - 1)
|
|
}
|
|
|
|
args.push(arg)
|
|
}
|
|
})
|
|
event.stopPropagation()
|
|
if (callStack)
|
|
callStack.call(that, ...args)
|
|
else {
|
|
console.error("Could not call callback function " + attributeVal, ...args)
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
/**
|
|
* Transform the relative links to absolute ones.
|
|
*
|
|
* Currently covers:
|
|
* Tags: a,img, image, circle
|
|
* Attributes: xlink:href,href,src (first occurence only)
|
|
*
|
|
* @static
|
|
* @param {DomElement} element - The children of the element are inspected, if there are relative paths, that has to be adjusted to absolute ones.
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static _adjustRelativeLinks(html) {
|
|
const that = this
|
|
/*
|
|
This RegEx finds all requested tags[1], and all requested attributes[3] and replaces the relative path [4] with the absolute one.
|
|
while all other attributes [2],[5] are preserved.
|
|
*/
|
|
return html.replace(/<\s*(a|video|img|image|circle)\s(.*?)(xlink:href|href|src)\s*=\s*["'](\..*?)["']\s*(.*?)>/g, function (data) {
|
|
let path = that._getRelativePath(arguments[4])
|
|
const tag = `<${arguments[1]} ${arguments[2]} ${arguments[3]}="${path}" ${arguments[5]}>`
|
|
/* if (that.debug) */ console.log("Adjusted: ", tag)
|
|
return tag
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Concats the given path with the relative path specified in the Card (as static variable).
|
|
*/
|
|
static _getRelativePath(src) {
|
|
let path = (this.relativePath != "") ? this.relativePath + "/" + src : src
|
|
return path
|
|
}
|
|
|
|
|
|
/**
|
|
* Loads the card text using an ajax request.
|
|
*
|
|
* @static
|
|
* @param {*} path
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static loadHTML(path) {
|
|
return new Promise((resolve, reject) => {
|
|
let request = new XMLHttpRequest()
|
|
|
|
request.onreadystatechange = function () {
|
|
if (this.readyState == 4) {
|
|
if (this.status == 200 || Card._isLocal()) {
|
|
try {
|
|
resolve(this.responseText)
|
|
} catch (e) {
|
|
reject(e)
|
|
}
|
|
} else reject(`Request failed '${path}'. Returned status ${this.status} and ready state ${this.readyState}.`)
|
|
}
|
|
}
|
|
|
|
request.open("GET", path, true)
|
|
request.send()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* TODO: Maybe put this in a utility script.
|
|
*/
|
|
static _isLocal() {
|
|
return (window.location.protocol == "file:")
|
|
}
|
|
|
|
/**
|
|
* Returns true if the card belongs to an editable page / context.
|
|
*
|
|
* @static
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static isEditable() {
|
|
return typeof mainController != 'undefined' && mainController.mode
|
|
}
|
|
|
|
/**
|
|
* Finds the closest element with the given css class name along the parent chain.
|
|
*
|
|
* @static
|
|
* @param {any} node - The node to start on
|
|
* @param {any} klass - The css class name
|
|
* @returns {element} - The DOM element with the given class name
|
|
* @memberof Card
|
|
*/
|
|
static closestWithClass(node, klass) {
|
|
if (node && node.classList) {
|
|
if (node.classList.contains(klass))
|
|
return node
|
|
return this.closestWithClass(node.parentNode, klass)
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Returns a computed style property. Similar to the jQuery.css method.
|
|
*
|
|
* @param {any} node
|
|
* @param {any} attr
|
|
* @returns {any}
|
|
* @memberof Card
|
|
*/
|
|
static css(node, attr) {
|
|
return window.getComputedStyle(node, null).getPropertyValue(attr)
|
|
}
|
|
|
|
/**
|
|
* Defines the standard drag behavior of cards. Copies the outer HTML of
|
|
* the card to the dataTransfer object of the drag event.
|
|
*
|
|
* Since dragover does not have the right to see the content we must specify
|
|
* additional type information to ensure that only accepted content
|
|
* can be dropped. Use the optional type parameter to specify custom types.
|
|
*
|
|
* The optional effectAllowed is set to 'all'. This is necessary to ensure
|
|
* that elements can be copied and moved. Unfortunately this
|
|
* adds a plus marker to the mouse that might be misleading. Use
|
|
* DataTransfer.dropEffect in dragover events to provide correct feedback instead.
|
|
*
|
|
* @static
|
|
* @param {any} event
|
|
* @param {string} [type="card"]
|
|
* @param {string} [effectAllowed="all"]
|
|
* @memberof Card
|
|
*/
|
|
static dragStart(event, type = "card", effectAllowed = 'all') {
|
|
event.dataTransfer.effectAllowed = effectAllowed
|
|
let html = event.target.outerHTML
|
|
event.dataTransfer.setData("text/html", html)
|
|
// https://stackoverflow.com/questions/11065803/determine-what-is-being-dragged-from-dragenter-dragover-events
|
|
event.dataTransfer.setData('iwmbrowser/' + type, '')
|
|
}
|
|
|
|
/**
|
|
* Opens a popup by calling the Popup.open method.
|
|
*
|
|
* @static
|
|
* @param {DOM element} parent - The DOM node to add the popup
|
|
* @param {any} position - The x,y position of the popup
|
|
* @param {any} content - The content to show within the popup
|
|
* @param {any} callback - A callback that is called on closing the popup
|
|
* @memberof Card
|
|
*/
|
|
static _openPopup(context, src, position, content, options = {}) {
|
|
|
|
let maxWidth = null
|
|
if (this.debug) console.log("Card._openPopup", position)
|
|
|
|
//logging
|
|
if (src) {
|
|
let strparts = src.split("/")
|
|
let cardID = strparts[strparts.length - 2]
|
|
let cardName = strparts[strparts.length - 1]
|
|
//console.log('open popup:',cardID,cardName,context,content)
|
|
let msg = 'ShowPopup: ' + cardID + '/' + cardName
|
|
console.log('Logging:', msg)
|
|
Logging.log(msg)
|
|
}
|
|
|
|
let highlight = null
|
|
if (options.highlight) {
|
|
highlight = options.highlight
|
|
delete options.highlight
|
|
}
|
|
|
|
this._createPopup(context, position, content, options).then((popup) => {
|
|
if (
|
|
//Test if meanwhile another popup was registered...
|
|
this._getPopup(context) ||
|
|
// Or if an highlight was loaded, if the highlight already was closed.
|
|
highlight !== null && !this._isHighlightActive(context, highlight)
|
|
) {
|
|
//.. if so remove the create popup instantly.
|
|
popup.remove()
|
|
} else {
|
|
// Otherwise set the popup regularly.
|
|
let popupParagraphs = popup.element.querySelectorAll(".popupContent > *")
|
|
|
|
// Remove a design error of naming two adjacent elements popup.
|
|
// Todo: fix this properly and remove this code.
|
|
// let unnecessaryPopupElement = popup.element.querySelector(".popupContent > .popup")
|
|
// unnecessaryPopupElement.classList.remove("popup")
|
|
|
|
popupParagraphs.forEach(popupParagraph => {
|
|
popupParagraph.setAttribute("draggable", false)
|
|
popupParagraph.addEventListener("mousedown", (event) => {
|
|
event.preventDefault()
|
|
})
|
|
})
|
|
|
|
|
|
this._setPopup(context, popup, src)
|
|
}
|
|
}).catch(e => console.error(e))
|
|
}
|
|
|
|
|
|
/**
|
|
* Closes a provided popup and unsets it on the context.
|
|
*
|
|
* @static
|
|
* @param {*} context
|
|
* @param {*} popup
|
|
* @memberof Card
|
|
*/
|
|
static closePopup(context, popup) {
|
|
if (popup) {
|
|
if (this.debug) console.log("Close Popup.", context, popup)
|
|
window.popup = popup
|
|
popup.close()
|
|
this._unsetPopup(context)
|
|
} else {
|
|
console.error("Requested to close popup, but popup was not found.")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a new Popup instance.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - The element on which the Popup should be created.
|
|
* @param {Point} position - The position, where the popup will be shown.
|
|
* @param {object} content - The content of the popup as required by the Popup class.
|
|
* @param {object} [options={}] - Additional options for the popup (optional).
|
|
* @returns {Promise} - Returns a Promise, which is resolved, when the Popup is fully loaded.
|
|
* @memberof Card
|
|
*/
|
|
static _createPopup(context, position, content, options = {}) {
|
|
|
|
if (this.debug) console.log("Create Popup.", context, position, content, options)
|
|
let popup = new Popup(Object.assign({
|
|
parent: context,
|
|
content
|
|
}, Object.assign({
|
|
noStyle: true,
|
|
// TODO: Remove offset when positioning according to element position
|
|
// is working.
|
|
posOffset: 10
|
|
}, options)))
|
|
|
|
// Placing the popup when it required loading,
|
|
// it resulted in flahing up at the default position.
|
|
// We manually prevent this here.
|
|
popup.element.style.display = "none"
|
|
|
|
|
|
let promise = new Promise((resolve) => {
|
|
if (popup.loaded)
|
|
resolve(popup)
|
|
else {
|
|
popup.onload = () => {
|
|
resolve(popup)
|
|
}
|
|
}
|
|
})
|
|
|
|
promise.then((popup) => {
|
|
|
|
popup.element.style.display = "block"
|
|
popup.element.style.visibility = "hidden"
|
|
popup.element.style.opacity = 0
|
|
|
|
popup.placeAt(position)
|
|
|
|
InteractionMapper.on(this.interactionType, popup.element, e => {
|
|
this._cleanup(context)
|
|
})
|
|
|
|
Object.assign(popup.element.style, {
|
|
zIndex: this.zIndices.popup
|
|
})
|
|
|
|
TweenMax.to(popup.element, this.animation.popup, {
|
|
autoAlpha: 1,
|
|
ease: Power2.easeIn
|
|
})
|
|
|
|
})
|
|
|
|
return promise
|
|
}
|
|
|
|
|
|
static _overlayCleanup(context, overlay) {
|
|
|
|
/**
|
|
* The cleanup functionality is now covered by the _cleanup function.
|
|
* It cleans up zoomables, popups and open image highlights.
|
|
*
|
|
* TEST if this intereferes with the editor.
|
|
*/
|
|
if (overlay) {
|
|
TweenMax.to(overlay, 0.2, {
|
|
autoAlpha: 0, onComplete: () => {
|
|
popup.remove()
|
|
//this._cleanup(context)
|
|
//overlay.parentNode.removeChild(overlay)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens a popup for a glossary. Typically used as a onlick handler of a link.
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @returns {bool} false - Returns false to prevent default click action
|
|
* @memberof Card
|
|
*/
|
|
static loadPopup(event, context = null, node = null, local = null) {
|
|
|
|
let editable = Card.isEditable()
|
|
if (context == null) {
|
|
context = this.getContext(event.target)
|
|
}
|
|
if (node == null) {
|
|
node = event.target
|
|
}
|
|
if (local == null) {
|
|
let globalClick = (event.center) ? event.center : { x: event.x, y: event.y }
|
|
local = Points.fromPageToNode(context, globalClick)
|
|
}
|
|
if (this.debug) console.log("loadPopup", event)
|
|
|
|
// Prevents loading the link in the current tab.
|
|
// Prevents loading the link in the current tab.
|
|
if (event.type != "Follow")
|
|
event.preventDefault()
|
|
|
|
if (editable && event.type == 'click') {
|
|
return false
|
|
}
|
|
|
|
let overlay = document.createElement('div')
|
|
let src = node.getAttribute("href")
|
|
let parentArticle = node.closest('article')
|
|
|
|
const that = this
|
|
let xhr = new XMLHttpRequest()
|
|
xhr.open('get', src, true)
|
|
xhr.onreadystatechange = () => {
|
|
|
|
if (this.debug) console.log("Popup Source: ", src)
|
|
if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 0)) {
|
|
if (editable) {
|
|
if (this.debug) console.log("Append overlay.", context)
|
|
overlay.classList.add('overlay')
|
|
TweenMax.set(overlay, { autoAlpha: 0 })
|
|
context.appendChild(overlay)
|
|
TweenMax.to(overlay, 0.5, { autoAlpha: 0.25 })
|
|
}
|
|
|
|
// Extract the body from the Popup site.
|
|
let parser = new DOMParser()
|
|
let popupPage = parser.parseFromString(xhr.responseText, "text/html")
|
|
|
|
//Fix the relative path of loaded images in the popup.
|
|
popupPage.querySelectorAll("img").forEach(node => {
|
|
node.setAttribute("src", that._getRelativePath(node.getAttribute("src")))
|
|
})
|
|
let html = popupPage.body.innerHTML
|
|
/**
|
|
* We do not want to use the popup class again.
|
|
* This results in problems when styling the popup.
|
|
* (You could style the .unselectable.popup, but that
|
|
* wouldn't be clean).
|
|
*
|
|
* Therefore the selector was changed:
|
|
* '.popup' => '.popupHtml'
|
|
*
|
|
* Which describes the construct even better than the
|
|
* '.popup' class.
|
|
*/
|
|
let selector = Card.popupHtmlSelector
|
|
let content = { html, selector }
|
|
|
|
|
|
let isSame = Card._checkForActiveSource(context, src)
|
|
Card._cleanup(context)
|
|
|
|
|
|
if (!isSame) {
|
|
Card._activateCorrespondingHighlights(context, node, parentArticle)
|
|
|
|
|
|
let callback = (popup, callback) => {
|
|
if (this.debug) console.log("Close popup (Editable = " + editable + ").", popup)
|
|
if (editable) {
|
|
let isDirty = mainController.askSaveNode()
|
|
if (isDirty)
|
|
mainController.saveNode(html.innerHTML, url => {
|
|
if (callback) {
|
|
callback()
|
|
}
|
|
saved(url)
|
|
this._overlayCleanup(context, overlay)
|
|
})
|
|
else {
|
|
if (callback) {
|
|
callback()
|
|
}
|
|
this._overlayCleanup(context, overlay)
|
|
}
|
|
mainController.popController()
|
|
}
|
|
/** This may be in conflice with the cleanup method. */
|
|
else {
|
|
//this._overlayCleanup(context, overlay)
|
|
popup.remove()
|
|
}
|
|
}
|
|
|
|
Card._openPopup(context, src, local, content, {
|
|
closeCommand: callback
|
|
})
|
|
}
|
|
}
|
|
|
|
overlay.onclick = e => {
|
|
if (editable)
|
|
e.preventDefault()
|
|
}
|
|
//console.log("onreadystatechange", editable)
|
|
if (editable) {
|
|
if (this.debug) console.log("pushController", src, popup.insertedNode)
|
|
mainController.pushController(popup.insertedNode, src)
|
|
}
|
|
}
|
|
xhr.send()
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* When an highlight link is clicked, this method activates all
|
|
* corresponding highlights.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - The context of the element.
|
|
* @param {DomElement} node - The node that (may) contain a highlightId.
|
|
* @param {DomElement} parent - The parent element that may contain more highlightIds.
|
|
* @memberof Card
|
|
*/
|
|
static _activateCorrespondingHighlights(context, node, parent) {
|
|
let highlightId = node.getAttribute('data-highlight-id')
|
|
// console.log("Request Highlight: " + highlightId)
|
|
|
|
let correspondingHighlights = []
|
|
if (highlightId) {
|
|
let queryString = 'circle[data-highlight-id="' + highlightId + '"]'
|
|
if (parent) {
|
|
correspondingHighlights = parent.querySelectorAll(queryString)
|
|
}
|
|
if (correspondingHighlights.length > 0) {
|
|
for (let highlight of correspondingHighlights) {
|
|
if (highlight.parentNode && highlight.parentNode.nodeName.toLowerCase() == 'g') {
|
|
Highlight.openHighlight(highlight, { animation: Card.highlightAnimation })
|
|
this._addHighlight(context, highlight)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests if any open item already contains the requested Source.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - Dom context we are in.
|
|
* @param {string} src - Source as dataUrl.
|
|
* @returns {boolean} - True if source is already active, false otherwise.
|
|
* @memberof Card
|
|
*/
|
|
static _checkForActiveSource(context, src) {
|
|
|
|
let requestedSame = false
|
|
let activePopup = Card._getPopup(context)
|
|
let activeHighlights = Card._getHighlights(context)
|
|
|
|
if (activePopup) {
|
|
let activeSrc = Card._getPopupSource(activePopup)
|
|
if (activeSrc && src == activeSrc) {
|
|
requestedSame = true
|
|
}
|
|
}
|
|
|
|
if (!requestedSame) {
|
|
for (let highlight of activeHighlights) {
|
|
let activeSrc = Card._getHighlightSource(highlight)
|
|
if (activeSrc == src) {
|
|
requestedSame = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return requestedSame
|
|
}
|
|
|
|
/**
|
|
* Opens a popup for a highlight. Typically used as a onlick handler of a link.
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @returns {bool} false - Returns false to prevent default click action
|
|
* @memberof Card
|
|
*/
|
|
static loadHighlightPopup(event) {
|
|
|
|
if (this.debug) console.log("Load Highlight Popup: ", event)
|
|
let node
|
|
if (event.firstTarget) {
|
|
node = event.firstTarget
|
|
} else {
|
|
node = event.target
|
|
}
|
|
let context = this.getContext(node)
|
|
event.stopPropagation()
|
|
|
|
/**
|
|
* This node is the documents body, as events wont work
|
|
* on svg elements properly. We need a workaround for that.
|
|
*/
|
|
let src = node.getAttribute("xlink:href")
|
|
let isSame = this._checkForActiveSource(context, src)
|
|
|
|
this._cleanup(context)
|
|
if (!isSame) {
|
|
this._addHighlight(context, node)
|
|
|
|
Highlight.openHighlight(node, {
|
|
animation: Card.highlightAnimation,
|
|
onExpanded: () => {
|
|
// We assume it's always a circle. This may break, when other svg shapes are used.
|
|
let x = node.getAttribute("cx")
|
|
let y = node.getAttribute("cy")
|
|
let position = { x, y }
|
|
|
|
let radius = parseFloat(node.getAttribute("r"))
|
|
|
|
/*
|
|
As the popup is appended directly to the card. We have to
|
|
transform the location from the subcard-/svg-space to the
|
|
card space.
|
|
*/
|
|
|
|
let svgRoot = node.closest("svg")
|
|
|
|
let svgPoint = svgRoot.createSVGPoint()
|
|
svgPoint.x = position.x
|
|
svgPoint.y = position.y - radius
|
|
let matrix = node.getCTM()
|
|
|
|
let point = svgPoint.matrixTransform(matrix)
|
|
let global = Points.fromNodeToPage(node.closest("div"), point)
|
|
let local = Points.fromPageToNode(context, global)
|
|
|
|
let overlay = document.createElement('div')
|
|
//TODO: Check if this is necessary for the editor.
|
|
// overlay.onclick = e =>{}
|
|
|
|
// This is not really efficient, as
|
|
// we could load the data while the circle is animating.
|
|
// but for simplicity it's just done here for now.
|
|
// TODO: Adjust to load while animating (Problem: Unload when cancelled).
|
|
console.log(src)
|
|
this._loadPopupContent(src)
|
|
.then(content => {
|
|
this._openPopup(context, src, local, content, {
|
|
highlight: node,
|
|
closeCommand: (popup, callback) => {
|
|
this._overlayCleanup(context, overlay)
|
|
popup.remove()
|
|
}
|
|
})
|
|
})
|
|
.catch(err => { console.error(err) })
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads the popup from a provided source.
|
|
*
|
|
* @static
|
|
* @private
|
|
* @param {string} source - Url to a popup file.
|
|
* @returns {Promise} - Returns a promise, that's resolved when the data is loaded.
|
|
* @memberof Card
|
|
*/
|
|
static _loadPopupContent(source) {
|
|
return new Promise((resolve, reject) => {
|
|
let xhr = new XMLHttpRequest()
|
|
xhr.open('get', source, true)
|
|
xhr.onreadystatechange = () => {
|
|
if (xhr.readyState == 4) {
|
|
|
|
if ((xhr.status == 200 || xhr.status == 0)) {
|
|
let html = this.postProcessResponseText(xhr.responseText)
|
|
let selector = Card.popupHtmlSelector
|
|
let content = { html: html.body.innerHTML, selector }
|
|
resolve(content)
|
|
} else {
|
|
reject(` Popup request failed (Code: ${xhr.status}): Could not load resource: ${src}`)
|
|
}
|
|
}
|
|
}
|
|
xhr.send()
|
|
})
|
|
}
|
|
|
|
static openZoomable(event) {
|
|
|
|
let node = event.target
|
|
|
|
//console.log("Open zoomable: ", node, node.classList)
|
|
|
|
let editable = Card.isEditable()
|
|
if (editable) {
|
|
if (node.classList.contains('zoomable-icon'))
|
|
this._openZoomable(event)
|
|
return
|
|
}
|
|
this._openZoomable(event)
|
|
}
|
|
|
|
|
|
/**
|
|
* Retrieve a Rectangle object from the a given zoomable.
|
|
*
|
|
* @static
|
|
* @param {DomElement} zoomable - The zoomable, the rectangle should be calculated for.
|
|
* @param {DomElement} wrapper - Specifies the coordinate system of the generated rectangle.
|
|
* @returns {object} - Returns an object of the rectangle in the wrapper's coordinate system in the form of {x ,y ,width , height}.
|
|
* @memberof Card
|
|
*/
|
|
static zoomableCurrentGeometry(zoomable, wrapper) {
|
|
|
|
|
|
/*
|
|
I don't think it's wise, that the zoomable calculation relies on
|
|
some icon that may or may not be present. When the same calculation can be
|
|
done using the bounding box of the desired element.
|
|
- SO
|
|
*/
|
|
|
|
//The div is cloned and animated, therefore we need it's style!
|
|
let actuallyZoomedItem = zoomable.querySelector("div")
|
|
|
|
let zoomableStyle = window.getComputedStyle(actuallyZoomedItem)
|
|
|
|
let svgElement = zoomable.querySelector("svg")
|
|
let videoElement = zoomable.querySelector("video")
|
|
let curStyle = null
|
|
if (svgElement) {
|
|
curStyle = window.getComputedStyle(svgElement)
|
|
}
|
|
if (videoElement) {
|
|
curStyle = window.getComputedStyle(videoElement)
|
|
}
|
|
|
|
let imageWidth = parseFloat(curStyle.width)
|
|
let imageHeight = parseFloat(curStyle.height)
|
|
|
|
let globalBottomRight = {
|
|
x: imageWidth,
|
|
y: imageHeight
|
|
}
|
|
|
|
globalBottomRight = Points.fromNodeToPage(zoomable, globalBottomRight)
|
|
let globalFigurePos = Points.fromNodeToPage(zoomable, { x: 0, y: 0 })
|
|
let localFigurePos = Points.fromPageToNode(wrapper, globalFigurePos)
|
|
let relativeBottomRight = Points.fromPageToNode(zoomable, globalBottomRight)
|
|
|
|
let width = relativeBottomRight.x + parseFloat(zoomableStyle.borderLeftWidth) + parseFloat(zoomableStyle.borderRightWidth)/*+ zoomIconRight*/
|
|
let height = relativeBottomRight.y + parseFloat(zoomableStyle.borderTopWidth) + parseFloat(zoomableStyle.borderBottomWidth) /*+ zoomIconRight*/
|
|
return { x: localFigurePos.x, y: localFigurePos.y, width, height }
|
|
}
|
|
|
|
|
|
/**
|
|
* Opens a zoomable object, which can be a figure containing an image or video or an image group
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @memberof Card
|
|
*/
|
|
static _openZoomable(event, editable = false) {
|
|
event.stopPropagation()
|
|
let node = event.target
|
|
let wrapper = this.getContext(node)
|
|
|
|
let zoomable = node.closest('figure')
|
|
|
|
// load mainimg - if none exists, there is nothing to open
|
|
let img = zoomable.querySelector('.mainimg')
|
|
if (img == null) {
|
|
return
|
|
}
|
|
|
|
//close other open elements before opening the zoomable:
|
|
//let removeDelay = this.removeActiveZoomable(wrapper, true) ? 0 : 0
|
|
this._cleanup(wrapper)
|
|
|
|
// Clone the zoomable and create a clone that is zoomed instead.
|
|
let current = this.zoomableCurrentGeometry(zoomable, wrapper)
|
|
let zoomedFig = zoomable.cloneNode(true)
|
|
let caption = zoomedFig.querySelector("figcaption.cap")
|
|
caption.parentNode.removeChild(caption)
|
|
this._toggleNextIcon(zoomedFig)
|
|
this._setZoomable(wrapper, zoomable, zoomedFig)
|
|
|
|
if (!editable) {
|
|
/**
|
|
* I don't remember why we need yet another wrapper for the zoomable.
|
|
* This should be investigated.
|
|
* -SO
|
|
*/
|
|
let zoomContainer = document.createElement('div')
|
|
zoomContainer.classList.add("zoomable-wrapper")
|
|
Object.assign(zoomContainer.style, {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
zIndex: 200
|
|
})
|
|
|
|
wrapper.appendChild(zoomContainer)
|
|
|
|
//Note: Remaned ZoomedFigure to zoomed-figure
|
|
zoomedFig.className = 'zoomed-figure'
|
|
zoomedFig.style.zIndex = this.zIndices.zoomedFigure
|
|
let zoomedG = zoomedFig.querySelector('g')
|
|
if (zoomedG && !editable)
|
|
zoomedG.parentNode.removeChild(zoomedG)
|
|
zoomContainer.appendChild(zoomedFig)
|
|
|
|
let zoomedImg = zoomedFig.querySelector('.mainimg')
|
|
zoomedImg.removeAttribute('onclick')
|
|
|
|
//set the values of corresponding zoomable element (needed when the zoomable is closed indirectly via removeActiveZoomable)
|
|
zoomedFig.zoomable = zoomable
|
|
zoomedFig.zoomableGeometry = current
|
|
|
|
//play video
|
|
let videoElement = zoomedFig.querySelector("video")
|
|
if (videoElement) {
|
|
videoElement.play()
|
|
}
|
|
|
|
//logging
|
|
let imgElt = zoomedFig.querySelector("image")
|
|
let videoElt = zoomedFig.querySelector("video")
|
|
//console.log('open zoomable:',imgElt,videoElt)
|
|
if (imgElt) {
|
|
let imgSrc = imgElt.getAttribute('xlink:href')
|
|
let strparts = imgSrc.split("/")
|
|
let cardID = strparts[strparts.length - 2]
|
|
let cardName = strparts[strparts.length - 1]
|
|
let msg = 'ShowImage: ' + cardID + '/' + cardName
|
|
console.log('Logging:', msg)
|
|
Logging.log(msg)
|
|
}
|
|
if (videoElt) {
|
|
let videoSrc = videoElt.getAttribute('src')
|
|
let strparts = videoSrc.split("/")
|
|
let cardID = strparts[strparts.length - 2]
|
|
let cardName = strparts[strparts.length - 1]
|
|
let msg = 'ShowVideo: ' + cardID + '/' + cardName
|
|
console.log('Logging:', msg)
|
|
Logging.log(msg)
|
|
}
|
|
|
|
// (At least in this style) We don't want the user to 'hit' the icon
|
|
// It's just an indicator that an action is possible. The click must be
|
|
// captured from the whole subcard.
|
|
|
|
InteractionMapper.on(this.interactionType, zoomedFig, (event) => {
|
|
this._cleanup(wrapper)
|
|
})
|
|
|
|
let zoomedFigStyle = window.getComputedStyle(zoomedFig)
|
|
let borderX = parseFloat(zoomedFigStyle.borderLeftWidth) + parseFloat(zoomedFigStyle.borderRightWidth)
|
|
let borderY = parseFloat(zoomedFigStyle.borderBottomWidth) + parseFloat(zoomedFigStyle.borderTopWidth)
|
|
|
|
|
|
const scaleFactor = 2
|
|
const transformOrigin = 'bottom right'
|
|
|
|
TweenMax.set(zoomedFig, {
|
|
x: current.x,
|
|
y: current.y,
|
|
width: current.width + borderX,
|
|
height: current.height + borderY,
|
|
transformOrigin
|
|
})
|
|
TweenMax.set(zoomable, { opacity: 0 })
|
|
|
|
let icon = zoomedFig.querySelector(".icon")
|
|
TweenMax.set(icon, {
|
|
transformOrigin
|
|
})
|
|
zoomedFig.style.transformOrigin = "calc(100% - " + parseFloat(zoomedFigStyle.borderRightWidth) + "px) calc(100% - " + parseFloat(zoomedFigStyle.borderBottomWidth) + "px)"
|
|
|
|
|
|
let tl = new TimelineLite()
|
|
|
|
let zoomCaption = zoomedFig.querySelector(".zoomcap")
|
|
|
|
tl.to(zoomedFig, Card.animation.zoomable, {
|
|
ease: Power2.easeIn,
|
|
css: {
|
|
scaleX: scaleFactor,
|
|
scaleY: scaleFactor
|
|
}
|
|
}, 0).set(zoomCaption, {
|
|
css: {
|
|
display: "block",
|
|
opacity: 0,
|
|
x: -parseFloat(zoomedFigStyle.borderLeftWidth),
|
|
width: current.width + borderX
|
|
}
|
|
}).to(zoomCaption, this.animation.fade, {
|
|
autoAlpha: 1
|
|
})
|
|
|
|
} else this._openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current)
|
|
}
|
|
|
|
/**
|
|
* Selects and transforms the zoomicon from a zoomicon to a closeicon
|
|
* or the other way around.
|
|
*
|
|
* @static
|
|
* @param {DomElement} parent - Parent to be searched for an zoomicon.
|
|
* @memberof Card
|
|
*/
|
|
static _toggleNextIcon(parent) {
|
|
let zoomIcon = Card._findNextIcon(parent)
|
|
const closeClass = "close"
|
|
const zoomClass = "zoom"
|
|
|
|
if (zoomIcon.classList.contains(closeClass)) {
|
|
zoomIcon.classList.remove(closeClass)
|
|
zoomIcon.classList.add(zoomClass)
|
|
} else if (zoomIcon.classList.contains(zoomClass)) {
|
|
zoomIcon.classList.remove(zoomClass)
|
|
zoomIcon.classList.add(closeClass)
|
|
} else {
|
|
console.error(`Error Toggleing Zoomicon: It did neither contain a class named ${closeClass} or ${zoomClass}.`)
|
|
}
|
|
}
|
|
|
|
static _findNextIcon(parent) {
|
|
return parent.querySelector('.icon')
|
|
}
|
|
|
|
|
|
static _openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current) {
|
|
|
|
let zoomContainer = document.createElement('div')
|
|
let zoomIcon = zoomable.querySelector('.zoom-icon')
|
|
zoomContainer.style.position = 'relative'
|
|
zoomContainer.style.zIndex = this.zIndices.zoomable
|
|
wrapper.appendChild(zoomContainer)
|
|
let zoomParent = zoomable.parentNode
|
|
zoomParent.appendChild(zoomedFig)
|
|
zoomedFig.style.opacity = 0.5
|
|
zoomContainer.appendChild(zoomable)
|
|
TweenMax.set(zoomable, { x: current.x, y: current.y, width: current.width, height: current.height })
|
|
let editor = mainController.topController().ensureEditor(img)
|
|
let savedDisplay = zoomIcon.style.display
|
|
let iconClone = zoomIcon.cloneNode(true)
|
|
zoomIcon.style.display = 'none'
|
|
let savedOnClick = zoomIcon.getAttribute('onclick')
|
|
zoomIcon.removeAttribute('onclick')
|
|
iconClone.src = iconClone.src.replace('info.svg', 'close.svg')
|
|
iconClone.style.zIndex = 1000
|
|
iconClone.classList.add('zoomed-icon')
|
|
iconClone.onclick = event => {
|
|
zoomIcon.style.display = savedDisplay
|
|
iconClone.remove()
|
|
zoomIcon.setAttribute('onclick', savedOnClick)
|
|
zoomedFig.remove()
|
|
zoomContainer.remove()
|
|
zoomParent.appendChild(zoomable)
|
|
TweenMax.set(zoomable, { x: 0, y: 0 })
|
|
zoomable.onmousedown = null
|
|
zoomable.onmousemove = null
|
|
zoomable.onmouseup = null
|
|
zoomable.onmousewheel = null
|
|
}
|
|
wrapper.appendChild(iconClone)
|
|
TweenMax.set(iconClone, { x: current.iconPos.x, y: current.iconPos.y })
|
|
|
|
zoomable.onmousedown = event => {
|
|
if (this.debug) console.log('mousedown', event.target)
|
|
event.preventDefault()
|
|
zoomable.dragging = true
|
|
zoomable.dragStartPos = { x: event.pageX, y: event.pageY }
|
|
}
|
|
zoomable.onmousemove = event => {
|
|
if (this.debug) console.log('onmousemove', event.target)
|
|
if (zoomable.dragging) {
|
|
event.preventDefault()
|
|
let dx = event.pageX - zoomable.dragStartPos.x
|
|
let dy = event.pageY - zoomable.dragStartPos.y
|
|
TweenMax.set([zoomable, iconClone], { x: '+=' + dx, y: '+=' + dy })
|
|
zoomable.dragStartPos = { x: event.pageX, y: event.pageY }
|
|
if (editor) {
|
|
editor.showControls()
|
|
}
|
|
}
|
|
}
|
|
zoomable.onmouseup = event => {
|
|
zoomable.dragging = false
|
|
}
|
|
|
|
let startZoom = 1
|
|
zoomable.onmousewheel = event => {
|
|
event.preventDefault()
|
|
let direction = event.detail < 0 || event.wheelDelta > 0
|
|
const zoomFactor = 1.1
|
|
let zoom = direction ? zoomFactor : 1 / zoomFactor
|
|
startZoom *= zoom
|
|
|
|
TweenMax.set(zoomable, { scale: startZoom })
|
|
if (editor) {
|
|
editor.showControls()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
|
|
/**
|
|
* Closes a zoomable object with animation
|
|
*
|
|
* @static
|
|
* @param {any} wrapper - the wrapper containing the index card
|
|
* @param {any} div - the figure containing the relevant elements
|
|
* @param {any} zoomable - the zoomable element, from which the zoomed figure originates
|
|
* @param {any} rect - the target rect for the tween (typically the top left width height of the zoomable)
|
|
* @memberof Card
|
|
*/
|
|
static closeZoomable(context, zoomable, zoomedFig) {
|
|
|
|
if (this.debug) console.log("Close Zoomable", context, zoomable, zoomedFig)
|
|
|
|
if (zoomable) {
|
|
this._unsetZoomable(context)
|
|
let caption = zoomable.querySelector("figcaption.cap")
|
|
zoomable.removeChild(caption)
|
|
zoomable.appendChild(caption)
|
|
let zoomedCaption = zoomedFig.querySelector("figcaption.zoomcap")
|
|
|
|
|
|
TweenMax.to(zoomedCaption, this.animation.fade, {
|
|
autoAlpha: 0,
|
|
})
|
|
|
|
TweenMax.to(zoomedFig, this.animation.zoomable, {
|
|
css: {
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
},
|
|
onComplete: () => {
|
|
TweenMax.set(zoomable, {
|
|
opacity: 1
|
|
})
|
|
let div = zoomedFig.parentNode
|
|
let videoElement = div.querySelector('video')
|
|
if (videoElement)
|
|
videoElement.pause()
|
|
div.parentNode.removeChild(div)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts and controls the expansion animation of an index card.
|
|
* The assumed card structure is as follows:
|
|
* <div class="card">
|
|
* <div class="card-content content">
|
|
* <h1 class="has-text-centered" contenteditable="true">...</h1>
|
|
* <div class="wrapper">
|
|
* <div data-innerhtml="preview" class="preview">...</div>
|
|
* <img class="zoom-icon cardicon" onclick="Card.openIndexCard(event, './article3.html')" src="../_theme/icons/info.svg">
|
|
* </div>
|
|
* </div>
|
|
* </div>
|
|
* The basic idea is that the card is cloned. In the animation the clone si expanded while the original is faded out.
|
|
*
|
|
* @param {*} card - The card to expand
|
|
* @param {string} html - The original HTML of the card
|
|
* @param {*} tagName - The tagname of the element that is used as exanded element
|
|
* @param {*} src - The src of the expanded element
|
|
* @param {*} callback - A callback that is called when the expanded element is closed
|
|
*/
|
|
static expandIndexCard(card, html, tagName = 'article', src = null, callback = null) {
|
|
let editable = Card.isEditable()
|
|
|
|
let context = this.getContext(card)
|
|
let indexbox = this.closestWithClass(card, 'mainview')
|
|
|
|
this._cleanup(context)
|
|
|
|
|
|
let angle = 0 // UO: out of context, to be computed from the scatter
|
|
let clone = card.cloneNode(true)
|
|
|
|
|
|
/**
|
|
* We have to reorder the clone, as it still contains the
|
|
* preview text image. And the new html is
|
|
* inserted before everything else.
|
|
*/
|
|
let cloneWrapper = clone.querySelector(".wrapper")
|
|
const article = html.body.querySelector("article")
|
|
|
|
let subcardContent = clone.querySelector(".subcard-content")
|
|
subcardContent.appendChild(article)
|
|
cloneWrapper.parentNode.removeChild(cloneWrapper)
|
|
|
|
/* Removes the 'default' cleanup on the card */
|
|
clone.removeAttribute("onclick")
|
|
InteractionMapper.on(this.interactionType, clone, (event) => {
|
|
this._cleanup(context)
|
|
})
|
|
|
|
let articleClone = clone.querySelector(tagName)
|
|
|
|
|
|
let globalPreviewRect = Card._getGlobalRect(card)
|
|
let globalIndexCardRect = Card._getGlobalRect(indexbox)
|
|
let localOrigin = Points.fromPageToNode(indexbox, Rect.getPosition(globalPreviewRect))
|
|
|
|
let scaleX = globalPreviewRect.width / globalIndexCardRect.width
|
|
let scaleY = globalPreviewRect.height / globalIndexCardRect.height
|
|
|
|
let padding = parseInt(this.css(indexbox, 'padding'))
|
|
let maxWidth = this.css(card, 'max-width')
|
|
|
|
TweenMax.set(clone, {
|
|
css: {
|
|
position: 'absolute',
|
|
width: globalIndexCardRect.width,
|
|
height: globalIndexCardRect.height,
|
|
maxWidth: '100%',
|
|
margin: 0,
|
|
zIndex: this.zIndices.article
|
|
}
|
|
})
|
|
|
|
TweenMax.set(articleClone, {
|
|
autoAlpha: 0
|
|
})
|
|
|
|
TweenMax.set(card, { css: { maxWidth: '100%' } })
|
|
TweenMax.set(clone, {
|
|
x: localOrigin.x - padding,
|
|
y: localOrigin.y - padding,
|
|
scaleX,
|
|
scaleY,
|
|
transformOrigin: '0% 0%',
|
|
rotation: angle,
|
|
})
|
|
indexbox.prepend(clone)
|
|
|
|
let titlebar = clone.querySelector(".titlebar")
|
|
let title = titlebar.querySelector("h2")
|
|
let titlebarStyle = window.getComputedStyle(titlebar)
|
|
let start = {
|
|
height: parseInt(titlebarStyle.height)
|
|
}
|
|
|
|
if (this.dynamicHeight) {
|
|
let targetHeight = subcardContent.offsetHeight;
|
|
console.log(targetHeight)
|
|
subcardContent.classList.add("dynamic-height")
|
|
/**
|
|
* Scale the content from 100% to it's target size.
|
|
*/
|
|
// TweenMax.set(subcardContent, {
|
|
// height: "100%"
|
|
// })
|
|
// TweenMax.to(subcardContent, Card.animation.articleTransition, {
|
|
// height: targetHeight + "px"
|
|
// })
|
|
}
|
|
|
|
//jquery hyphenate below
|
|
if (typeof($) != 'undefined') {
|
|
$('.column').not('.overview').children('p').hyphenate('de')
|
|
}
|
|
|
|
//logging
|
|
if (src) {
|
|
let strparts = src.split("/")
|
|
let cardID = strparts[strparts.length - 2]
|
|
let cardName = strparts[strparts.length - 1]
|
|
strparts = card.className.split(" ")
|
|
let cardType = strparts[1]
|
|
let msg = 'Card: ' + cardID + ': openTopic: ' + cardType + ', ' + cardName
|
|
console.log('Logging:', msg)
|
|
Logging.log(msg)
|
|
}
|
|
|
|
let desiredBorderBottomWidth = parseInt(window.getComputedStyle(titlebar).borderBottomWidth)
|
|
TweenMax.to(clone, Card.animation.articleTransition, {
|
|
x: -padding,
|
|
y: -padding,
|
|
ease: ExpoScaleEase.config(scaleX, 1),
|
|
scale: 1,
|
|
rotation: 0,
|
|
onComplete: () => {
|
|
card.classList.add("visited")
|
|
},
|
|
onUpdateParams: ['{self}'],
|
|
onUpdate: (self) => {
|
|
let transform = self.target._gsTransform
|
|
TweenMax.set(title, {
|
|
scale: 1 / transform.scaleX
|
|
})
|
|
TweenMax.set(titlebar, {
|
|
height: start.height * 1 / transform.scaleY
|
|
})
|
|
|
|
// Retain the border at same visual thickness.
|
|
titlebar.style.borderBottomWidth = desiredBorderBottomWidth / transform.scaleY + "px"
|
|
}
|
|
})
|
|
|
|
TweenMax.to([articleClone], this.animation.articleTransition / 2, {
|
|
delay: this.animation.articleTransition / 2,
|
|
autoAlpha: 1
|
|
})
|
|
|
|
if (editable) {
|
|
mainController.pushController(articleClone, src)
|
|
}
|
|
|
|
Card._disableCardCloseButton(context)
|
|
|
|
const closeAnimation = () => {
|
|
//logging
|
|
if (src) {
|
|
let strparts = src.split("/")
|
|
let cardID = strparts[strparts.length - 2]
|
|
let cardName = strparts[strparts.length - 1]
|
|
strparts = card.className.split(" ")
|
|
let cardType = strparts[1]
|
|
let msg = 'Card: ' + cardID + ': closeTopic: ' + cardType + ', ' + cardName
|
|
console.log('Logging:', msg)
|
|
Logging.log(msg)
|
|
}
|
|
|
|
Card._cleanup(context)
|
|
Card._unsetSubcard(context)
|
|
|
|
this._enableCardCloseButton(context)
|
|
|
|
let previewTitlebar = card.querySelector(".titlebar")
|
|
let titlebarStyle = window.getComputedStyle(previewTitlebar)
|
|
let titlebar = clone.querySelector(".titlebar")
|
|
|
|
TweenMax.to(titlebar, this.animation.articleTransition, {
|
|
height: parseInt(titlebarStyle.height)
|
|
})
|
|
|
|
TweenMax.to(articleClone, this.animation.articleTransition / 2, {
|
|
autoAlpha: 0
|
|
})
|
|
|
|
let title = titlebar.querySelector("h2")
|
|
let original = {
|
|
height: parseInt(titlebarStyle.height)
|
|
}
|
|
|
|
if (this.dynamicHeight) {
|
|
TweenMax.to(subcardContent, this.animation.articleTransition, {
|
|
height: "100%"
|
|
})
|
|
}
|
|
|
|
TweenMax.set(card, { autoAlpha: 1, css: { maxWidth } })
|
|
TweenMax.to(clone, this.animation.articleTransition, {
|
|
x: localOrigin.x - padding,
|
|
y: localOrigin.y - padding,
|
|
scaleX,
|
|
scaleY,
|
|
ease: ExpoScaleEase.config(1, scaleX),
|
|
rotation: angle,
|
|
onComplete: () => {
|
|
// article.remove()
|
|
TweenMax.to(clone, this.animation.fade,
|
|
{
|
|
//delay: 0.2,
|
|
autoAlpha: 0,
|
|
onComplete:
|
|
() => {
|
|
if (editable) {
|
|
mainController.popController()
|
|
}
|
|
clone.remove()
|
|
}
|
|
})
|
|
},
|
|
onUpdateParams: ["{self}"],
|
|
onUpdate: function (self) {
|
|
let transform = self.target._gsTransform
|
|
|
|
TweenMax.set(title, {
|
|
scale: 1 / transform.scaleX
|
|
})
|
|
|
|
TweenMax.set(titlebar, {
|
|
height: original.height * 1 / transform.scaleY
|
|
})
|
|
|
|
// Retain the border at same visual thickness.
|
|
titlebar.style.borderBottomWidth = desiredBorderBottomWidth / transform.scaleY + "px"
|
|
}
|
|
})
|
|
}
|
|
|
|
//TODO consider renaming it to something more intuitive.
|
|
let iconClone = clone.querySelector('.card-icon')
|
|
|
|
if (iconClone == null) {
|
|
iconClone = clone.querySelector(".cardicon")
|
|
console.warn("Legacy selector. Change it to 'card-icon' and find a more suitable name.")
|
|
}
|
|
|
|
if (iconClone.tagName == "img") {
|
|
iconClone.src = iconClone.src.replace('info.svg', 'close.svg')
|
|
}
|
|
//console.log("ICON: ", iconClone)
|
|
iconClone.classList.remove("info")
|
|
iconClone.classList.add("close", "view-button", "transparent-background")
|
|
|
|
// We append the icon clone to the subcard-content.
|
|
// Then it's always on the content and not on the background
|
|
// (If there is one).
|
|
if (this.dynamicHeight) {
|
|
article.appendChild(iconClone)
|
|
}
|
|
|
|
if (enableNearestNeighborTaps) {
|
|
//look for nearby popups on tap
|
|
InteractionMapper.on("tap", indexbox, () => {
|
|
console.log("Tap handler called", editable)
|
|
if (!editable) {
|
|
this.findNearbyPopups(event, card)
|
|
}
|
|
})
|
|
}
|
|
// Use the 'tap' event for closing.
|
|
// Otherwise the subcard cannot be closed,
|
|
// when another subcard is touched.
|
|
InteractionMapper.on("tap", iconClone, () => {
|
|
if (editable) {
|
|
let isDirty = mainController.askSaveNode()
|
|
if (isDirty) {
|
|
mainController.saveNode(html.innerHTML, url => {
|
|
callback(url)
|
|
closeAnimation()
|
|
})
|
|
}
|
|
else {
|
|
closeAnimation()
|
|
}
|
|
}
|
|
else {
|
|
closeAnimation()
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Opens the index card. Called by the zoom icon click handler.
|
|
* The assumed card structure is as follows:
|
|
* <div class="card">
|
|
* <div class="card-content content">
|
|
* <h1 class="has-text-centered" contenteditable="true">...</h1>
|
|
* <div class="wrapper">
|
|
* <div data-innerhtml="preview" class="preview">...</div>
|
|
* <img class="zoom-icon cardicon" onclick="Card.openIndexCard(event, './article3.html')" src="../_theme/icons/info.svg">
|
|
* </div>
|
|
* </div>
|
|
* </div>
|
|
* Loads the DOM element from a file source and adds it before the card-content to ensure that the article content
|
|
* can be positioned below the heading.
|
|
*
|
|
* <div class="card">
|
|
* <article> INSERTED CONTENT </article>
|
|
* <div class="card-content content">
|
|
* ...
|
|
* </div>
|
|
* </div>
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @param {string} src - The file source, typically a relative url
|
|
* @memberof Card
|
|
*/
|
|
static openIndexCard(event, src) {
|
|
//console.log("openIndexCard", src)
|
|
/*
|
|
* Called by the expandIndexCard(...)
|
|
*/
|
|
let target = event.target
|
|
const saveCallback = (url) => {
|
|
let handler = `Card.openIndexCard(event, '${url}')`
|
|
console.log("File has changed", target, handler)
|
|
|
|
//TODO If this is required, it should be accessing the interaction type.
|
|
target.setAttribute("onclick", handler)
|
|
}
|
|
|
|
let context = this.getContext(target)
|
|
let subcard = this._getSubcard(context)
|
|
//console.log("openIndexCard", { context, subcard })
|
|
//Dont proceeed if a subcard is active
|
|
if (subcard != null)
|
|
return
|
|
|
|
// In edit mode we only accept icon clicks
|
|
let editable = Card.isEditable()
|
|
if (editable && !target.classList.contains('icon')) {
|
|
return
|
|
}
|
|
|
|
let card = this.closestWithClass(target, 'subcard')
|
|
this._setSubcard(context, card)
|
|
const relativeSource = this._getRelativePath(src)
|
|
|
|
let xhr = new XMLHttpRequest()
|
|
xhr.open('get', relativeSource, true)
|
|
xhr.onreadystatechange = () => {
|
|
if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 0)) {
|
|
let html = xhr.responseText
|
|
let parsedHTML = this.postProcessResponseText(html)
|
|
|
|
// TODO: What is this good for?
|
|
// let article = parsedHTML.querySelector('article')
|
|
// card.insertAdjacentElement('afterbegin', article)
|
|
// TweenMax.set(article, { autoAlpha: 0 })
|
|
|
|
Card.expandIndexCard(card, parsedHTML, 'article', relativeSource, saveCallback)
|
|
}
|
|
}
|
|
xhr.onerror = () => {
|
|
alert('Unable to load ' + src)
|
|
}
|
|
xhr.send()
|
|
}
|
|
|
|
|
|
static _selectCardCloseButton(context) {
|
|
return context.querySelector(".mainview > .button.close")
|
|
}
|
|
|
|
static _enableCardCloseButton(context) {
|
|
//console.log("ENABLE")
|
|
let btn = this._selectCardCloseButton(context)
|
|
//console.log(btn)
|
|
btn.classList.remove("disabled")
|
|
}
|
|
|
|
static _disableCardCloseButton(context) {
|
|
let btn = this._selectCardCloseButton(context)
|
|
btn.classList.add("disabled")
|
|
}
|
|
|
|
|
|
/**
|
|
* Provides the nearest info-card of the provided item.
|
|
*
|
|
* @static
|
|
* @param {DomElement} child - A dom element for which the context shall be retrieved.
|
|
* @returns {DomElement} - The containing context / info-card.
|
|
* @memberof Card
|
|
*/
|
|
static getContext(child) {
|
|
let dom = child.closest(".info-card")
|
|
if (!dom) {
|
|
dom = child.querySelector(".wrapper")
|
|
console.warn("Change the 'wrapper' class to 'info-card' it's more suitable.")
|
|
}
|
|
return dom // (dom == null) ? document.body : dom
|
|
}
|
|
|
|
/**
|
|
* The cleanup method closes all active Popups, Zoomables and Highlights.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - The dom element to inspect.
|
|
* @memberof Card
|
|
*/
|
|
static _cleanup(context) {
|
|
|
|
let [zoomable, zoomedFig] = this._getZoomable(context)
|
|
if (zoomable && zoomedFig) {
|
|
this.closeZoomable(context, zoomable, zoomedFig)
|
|
}
|
|
|
|
let popup = this._getPopup(context)
|
|
if (popup) {
|
|
this.closePopup(context, popup)
|
|
}
|
|
|
|
let highlights = this._getHighlights(context)
|
|
highlights.forEach(highlight => {
|
|
Highlight.closeHighlight(highlight, { animation: Card.highlightAnimation })
|
|
})
|
|
|
|
this._unsetHighlights(context)
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Retrieves an Rectangle for an element in the local space of a provided context.
|
|
*
|
|
* Note: This works also for rotated DomElements unlike the Element.getBoundingClientRectangle method.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - The element that defines the space, we want the rectangle to be in.
|
|
* @param {DomElement} element - The element, we want to get the rectangle for.
|
|
* @returns {DomRect} - Returns a DomRect representing the element outline in the space of context.
|
|
* @memberof Card
|
|
*/
|
|
static _getContextRect(context, element) {
|
|
let global = this._getGlobalRect(element)
|
|
let localPosition = Points.fromPageToNode(context, { x: global.x, y: global.y })
|
|
return DOMRectReadOnly.fromRect({
|
|
x: localPosition.x,
|
|
y: localPosition.y,
|
|
width: global.width,
|
|
height: global.height
|
|
})
|
|
}
|
|
|
|
|
|
/**
|
|
* Gets a rectangle in global space for a provided element.
|
|
*
|
|
* Note: This works also for rotated DomElements unlike the Element.getBoundingClientRectangle method.
|
|
*
|
|
* @static
|
|
* @param {DomElement} element
|
|
* @returns {DomRect} - Returns a rectangle that specifies the location in global space.
|
|
* @memberof Card
|
|
*/
|
|
static _getGlobalRect(element) {
|
|
let cardStyle = window.getComputedStyle(element)
|
|
let cardWidth = parseInt(cardStyle.width)
|
|
let cardHeight = parseInt(cardStyle.height)
|
|
let globalPosition = Points.fromNodeToPage(element, { x: 0, y: 0 })
|
|
return DOMRectReadOnly.fromRect({
|
|
x: globalPosition.x,
|
|
y: globalPosition.y,
|
|
width: cardWidth,
|
|
height: cardHeight
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Adjusts all links
|
|
*
|
|
* @static
|
|
* @param {*} htmlString
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static postProcessResponseText(htmlString) {
|
|
let editable = this.isEditable()
|
|
|
|
htmlString = this._adjustRelativeLinks(htmlString)
|
|
//console.log(htmlString)
|
|
let parser = new DOMParser()
|
|
let html = parser.parseFromString(htmlString, "text/html")
|
|
if (!editable) {
|
|
this._replaceAttributes(html, "onclick", this._replaceCallback)
|
|
}
|
|
let zoomableWrappers = html.querySelectorAll(".svg-wrapper")
|
|
zoomableWrappers.forEach(wrapper => {
|
|
let svg = wrapper.querySelector("svg")
|
|
Object.assign(wrapper.style, {
|
|
width: svg.getAttribute("width") + "px",
|
|
height: svg.getAttribute("height") + "px"
|
|
})
|
|
})
|
|
let zoomableVideoWrappers = html.querySelectorAll(".video-wrapper")
|
|
zoomableVideoWrappers.forEach(wrapper => {
|
|
let video = wrapper.querySelector("video")
|
|
Object.assign(wrapper.style, {
|
|
width: video.getAttribute("width") + "px",
|
|
height: video.getAttribute("height") + "px"
|
|
})
|
|
})
|
|
|
|
return html
|
|
}
|
|
|
|
/**
|
|
* There occured a problem with the tap event, as it was canceled by the event on the svg element
|
|
* therefore we restructured the interaction, that only the svg listens for events and if the target is
|
|
* a circle (more are not supported yet) the target is adjusted using the .firstTarget of the event,
|
|
* that is provided by the Hammer.propergate plugin.
|
|
*/
|
|
static openPopupOrZoomable(event) {
|
|
let target = this._preferFirstTarget(event)
|
|
if (target.tagName == "circle") {
|
|
Card.loadHighlightPopup(event)
|
|
event.stopPropagation()
|
|
} else {
|
|
Card.openZoomable(event)
|
|
}
|
|
}
|
|
|
|
static pointRectDist(p, r) {
|
|
var cx = Math.max(Math.min(p.x, r.x + r.width), r.x)
|
|
var cy = Math.max(Math.min(p.y, r.y + r.height), r.y)
|
|
return Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
|
|
}
|
|
|
|
static findNearbyPopups(event, card) {
|
|
if (event.target) {
|
|
//let column = event.target.closest(".column")
|
|
let indexbox = this.closestWithClass(card, 'mainview')
|
|
if (indexbox != null) { // column != null ||
|
|
let links = Array.from(indexbox.getElementsByTagName("a"))
|
|
let globalClick = (event.center) ? event.center : { x: event.x, y: event.y }
|
|
let localClick = Points.fromPageToNode(indexbox, globalClick)
|
|
|
|
let linkRects = links.map(link => {
|
|
let rect = link.getBoundingClientRect()
|
|
let topLeft = Points.fromPageToNode(indexbox, rect)
|
|
let center = Points.fromPageToNode(indexbox, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 })
|
|
return { x: topLeft.x, y: topLeft.y, width: rect.width, height: rect.height, center, link }
|
|
})
|
|
|
|
let distances = []
|
|
linkRects.forEach(rect => {
|
|
let distance = Card.pointRectDist(localClick, rect)
|
|
if (distance == 0.0) {
|
|
// Distance == 0.0 indicates an inside relation. Since these
|
|
// are already handled by the links themselves we put these cases
|
|
// to the end of the sorted list below
|
|
distance = 100000.0
|
|
}
|
|
distances.push(parseInt(distance))
|
|
})
|
|
|
|
let closestLinkIndex = distances.indexOf(Math.min(...distances))
|
|
let closestLink = links[closestLinkIndex]
|
|
|
|
console.log("finding closest links", closestLink, closestLink.getClientRects())
|
|
|
|
if (distances[closestLinkIndex] < 44 && closestLink.getAttribute("href")) {
|
|
// Adapt context and local position
|
|
let context = this.getContext(closestLink)
|
|
let local = Points.fromPageToNode(context, globalClick)
|
|
let rect = linkRects[closestLinkIndex]
|
|
local.y = rect.center.y
|
|
|
|
Card.loadPopup(event, context, closestLink, local)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Evaluates an event, if it has a first target. If so it returns the first target,
|
|
* otherwise it returns the target.
|
|
*/
|
|
static _preferFirstTarget(event) {
|
|
return (event.firstTarget) ? event.firstTarget : event.target
|
|
}
|
|
|
|
|
|
/**
|
|
* Getter, Setter and 'Unsetter' for the properties hooked onto the context element.
|
|
*/
|
|
|
|
static _getPopup(context) {
|
|
return context.popup
|
|
}
|
|
|
|
static _setPopup(context, popup, source) {
|
|
this._setPopupSource(popup, source)
|
|
context.popup = popup
|
|
|
|
if (typeof($) != 'undefined') {
|
|
//jquery hyphenate below
|
|
console.log('hyphenated popup:', $('span').hyphenate('de'))
|
|
}
|
|
}
|
|
|
|
static _unsetPopup(context) {
|
|
context.popup = null
|
|
}
|
|
|
|
static _setPopupSource(popup, source) {
|
|
popup.element.setAttribute("data-src", source)
|
|
}
|
|
|
|
static _getPopupSource(popup) {
|
|
return popup.element.getAttribute("data-src")
|
|
}
|
|
|
|
static _unsetZoomable(context) {
|
|
context.zoomable = null
|
|
}
|
|
|
|
static _setZoomable(context, zoomable, zoomedFig) {
|
|
context.zoomable = [zoomable, zoomedFig]
|
|
}
|
|
|
|
static _getZoomable(context) {
|
|
return (context.zoomable) ? context.zoomable : []
|
|
}
|
|
|
|
/**
|
|
* Helper function to determine if a proided element is still
|
|
* an active highlight.
|
|
*/
|
|
static _isHighlightActive(context, element) {
|
|
let highlights = this._getHighlights(context)
|
|
return (highlights.indexOf(element) != -1)
|
|
}
|
|
|
|
static _getHighlights(context) {
|
|
return (context.highlights) ? context.highlights : []
|
|
}
|
|
|
|
static _addHighlight(context, highlight) {
|
|
if (context.highlights == null) this._unsetHighlight(context)
|
|
context.highlights.push(highlight)
|
|
}
|
|
|
|
static _unsetHighlights(context) {
|
|
context.highlights = []
|
|
}
|
|
|
|
static _getHighlightSource(highlight) {
|
|
return highlight.getAttribute("xlink:href")
|
|
}
|
|
|
|
static _getActiveSubcard(context) {
|
|
return context.querySelector(".mainview > .subcard")
|
|
}
|
|
|
|
static _setSubcard(context, subcard) {
|
|
//console.trace("_setSubcard", context)
|
|
context.subcard = subcard
|
|
}
|
|
|
|
static _getSubcard(context) {
|
|
return context.subcard
|
|
}
|
|
|
|
static _unsetSubcard(context) {
|
|
context.subcard = null
|
|
}
|
|
|
|
static incrementZIndex(context) {
|
|
if (!context.zIndex) context.zIndex = 0
|
|
context.zIndex++
|
|
}
|
|
|
|
static getZIndex(context) {
|
|
return (context.zIndex || 0)
|
|
}
|
|
|
|
/**
|
|
* This getter and setter pair solves the problem,
|
|
* when we assign 'relativePath' to a child class, the assign is routed
|
|
* to this base class. And we ensure, that we always set the same
|
|
* private variable.
|
|
*
|
|
* Not doing this lead to some cases, when Card-/ScatterCard.func
|
|
* was called and depending on context their were different values
|
|
* inside the relativePath variable.
|
|
*/
|
|
static set relativePath(value) {
|
|
Card._relativePath = value
|
|
}
|
|
|
|
static get relativePath() {
|
|
return Card._relativePath
|
|
}
|
|
|
|
}
|
|
|
|
Card.debug = true
|
|
Card._relativePath = ""
|
|
Card.scatterContainer = null
|
|
Card.interactionType = "tap"
|
|
Card.popupHtmlSelector = ".popupHtml"
|
|
Card.dynamicHeight = false
|
|
|
|
Card.popupYOffset = -15
|
|
|
|
Card.zIndices = {
|
|
article: 10,
|
|
popup: 100,
|
|
zoomable: 101,
|
|
zoomedFigure: 102
|
|
}
|
|
|
|
Card.animation = {
|
|
articleTransition: 0.6,
|
|
fade: 0.2,
|
|
popup: 0.1,
|
|
highlight: 0.4,
|
|
zoomable: 0.5
|
|
}
|
|
|
|
/**
|
|
* Extends the card with scatter functionality.
|
|
*
|
|
* @class ScatterCard
|
|
*/
|
|
export class ScatterCard extends Card {
|
|
|
|
|
|
/**
|
|
* TODO: Find a more suitable name.
|
|
* Adjusts the HTML to work in the new context.
|
|
*
|
|
* @static
|
|
* @param {*} domElement
|
|
* @param {*} htmlString
|
|
* @param {*} basePath
|
|
* @param {*} [opts={}]
|
|
* @memberof Card
|
|
*/
|
|
static setup(context, htmlString, {
|
|
basePath = "./",
|
|
modules = []
|
|
} = {}) {
|
|
context.classList.add("info-card")
|
|
|
|
this.relativePath = basePath
|
|
htmlString = this._adjustRelativeLinks(htmlString)
|
|
|
|
let parser = new DOMParser()
|
|
let html = parser.parseFromString(htmlString, "text/html")
|
|
|
|
/**
|
|
* Conflicts with the FindTarget method of the Abstract scatter.
|
|
*/
|
|
this._replaceAttributes(html, "onclick", this._replaceCallback)
|
|
|
|
|
|
let content = html.querySelector(".mainview")
|
|
context.appendChild(content)
|
|
|
|
super.setup(context, modules)
|
|
return context
|
|
}
|
|
|
|
|
|
/**
|
|
* Appends a close listener to the scatter element.
|
|
*
|
|
* @static
|
|
* @param {*} element
|
|
* @param {*} callback
|
|
* @memberof Card
|
|
*/
|
|
static addOnCloseListener(element, callback) {
|
|
if (callback) {
|
|
element.onClose = callback
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Creates a scatter for the card and applies the card to it,
|
|
*
|
|
* @static
|
|
* @param {*} html
|
|
* @param {*} scatterContainer
|
|
* @param {string} [basePath=""]
|
|
* @param {*} [opts={}]
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static createCardScatter(html, scatterContainer, {
|
|
basePath = "./",
|
|
modules = []
|
|
} = {}) {
|
|
let element = document.createElement("div")
|
|
|
|
scatterContainer.element.appendChild(element)
|
|
new DOMScatter(element, scatterContainer, {
|
|
width: 1400,
|
|
height: 1200
|
|
})
|
|
|
|
this.setup(element, html, {
|
|
basePath,
|
|
modules
|
|
})
|
|
return element
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
*Utility function to create a fully functional card scatter.
|
|
*
|
|
* @static
|
|
* @param {*} scatterContainer
|
|
* @param {*} path
|
|
* @param {string} [basePath="."]
|
|
* @param {*} opts
|
|
* @returns
|
|
* @memberof CardScatter
|
|
*/
|
|
static loadAndCreateScatterCard(scatterContainer, item, {
|
|
basePath = "../",
|
|
modules = [],
|
|
onClose = null
|
|
} = {}) {
|
|
console.log(basePath)
|
|
return new Promise((resolve, reject) => {
|
|
let url = basePath + "/" + item + "/index.html"
|
|
console.log("Loading", url)
|
|
this.loadHTML(url)
|
|
.then(html => {
|
|
console.log("Received", html)
|
|
let element = this.createCardScatter(html, scatterContainer, {
|
|
basePath,
|
|
modules
|
|
})
|
|
if (onClose)
|
|
this.addOnCloseListener(element, onClose)
|
|
resolve(element)
|
|
})
|
|
.catch(e => reject(e))
|
|
})
|
|
}
|
|
|
|
static _setLanguage(context, language) {
|
|
context.language = language
|
|
}
|
|
|
|
static _getLanguage(context) {
|
|
return context.language
|
|
}
|
|
|
|
}
|
|
|
|
window.ScatterCard = ScatterCard
|
|
|
|
ScatterCard.selectedLanguage = 0
|
|
ScatterCard.languages = ["Deutsch", "English"]
|
|
ScatterCard.languageTags = {
|
|
Deutsch: "de",
|
|
English: "en"
|
|
}
|
|
ScatterCard.scatterContainer = null
|
|
|
|
var CardPlugin = CardPlugin || {}
|
|
|
|
class CardPluginBase {
|
|
|
|
apply(context) {
|
|
if (this.verify(context)) {
|
|
this.append(context)
|
|
console.log("Plugin " + this.name + " was verified successfully.")
|
|
return true
|
|
} else console.error("Could not verify module " + this.name + ".")
|
|
return false
|
|
}
|
|
|
|
get name() {
|
|
return this.constructor.name
|
|
}
|
|
|
|
verify(context) {
|
|
let funcs = this._getVerificationFunctions(context)
|
|
for (let func of funcs) {
|
|
if (!func()) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
_verifyElementsExist(context, ...selectors) {
|
|
let missing = []
|
|
|
|
for (let selector of selectors) {
|
|
let requiredElement = context.querySelector(selector)
|
|
if (requiredElement == null) {
|
|
missing.push(selector)
|
|
}
|
|
}
|
|
const valid = (missing.length == 0)
|
|
if (!valid) console.error("Elements were missing: ", missing.join(", "))
|
|
return valid
|
|
}
|
|
|
|
|
|
/**
|
|
* Appends the Plugin to the context.
|
|
*
|
|
* @memberof CardPlugin
|
|
*/
|
|
append(context) {
|
|
console.error("Call of abstract method CardPlugin.prototype.append(context). Plugins need to overwrite the append method!")
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
return [
|
|
this._verifyContext.bind(this, context),
|
|
this._verifyRequirements.bind(this, context)
|
|
]
|
|
}
|
|
|
|
_verifyContext(context) {
|
|
if (!(context instanceof HTMLElement)) {
|
|
console.error("Context is not of type HTML Element.", context)
|
|
return false
|
|
} else return true
|
|
}
|
|
|
|
_verifyRequirements(context) {
|
|
let requirements = this._collectAllRequirements()
|
|
let missing = []
|
|
|
|
requirements.forEach(module => {
|
|
if (context.modules.indexOf(module.name) == -1) {
|
|
missing.push(module.name)
|
|
}
|
|
})
|
|
|
|
const valid = (missing.length == 0)
|
|
if (!valid) console.error("Could not apply module '" + this.name + "'. Following modules are required but were missing: " + missing.join(","))
|
|
else console.log("All requirements were met! Well done!")
|
|
return valid
|
|
}
|
|
|
|
|
|
_collectAllRequirements() {
|
|
let requirements = []
|
|
let klass = this.__proto__
|
|
while (klass) {
|
|
if (klass.require != null) {
|
|
requirements = requirements.concat(klass.require)
|
|
}
|
|
klass = klass.__proto__
|
|
}
|
|
return requirements
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
CardPlugin.LightBox = class LightBox extends CardPluginBase {
|
|
constructor(className, style = {}) {
|
|
super()
|
|
this.className = className
|
|
this.style = style
|
|
}
|
|
|
|
append(context) {
|
|
let wrapper = document.createElement("div")
|
|
wrapper.className = this.className
|
|
|
|
Object.assign(wrapper.style, {
|
|
zIndex: 1000,
|
|
// backgroundColor: "black",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
height: "100%"
|
|
}, this.style, {
|
|
display: "none",
|
|
position: "absolute",
|
|
})
|
|
|
|
context.appendChild(wrapper)
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* The Enlargeable Overlay module allows the user to click on the thumbnail image,
|
|
* and the images gets enlarged inside the card.
|
|
*
|
|
* @class EnlargeableThumbnail
|
|
* @extends {CardPlugin}
|
|
*/
|
|
CardPlugin.EnlargeableThumbnail = class EnlargeableThumbnail extends CardPluginBase {
|
|
|
|
constructor(wrapperSelector, overlaySelector = null, {
|
|
zoomAnimationDuration = 0.4,
|
|
fadeAnimationDuration = 0.4,
|
|
interactionType = "tap"
|
|
} = {}) {
|
|
super()
|
|
this.wrapperSelector = wrapperSelector
|
|
this.overlaySelector = overlaySelector
|
|
|
|
this.zoomAnimationDuration = zoomAnimationDuration
|
|
this.fadeAnimationDuration = fadeAnimationDuration
|
|
this.interactionType = interactionType
|
|
}
|
|
|
|
get require() {
|
|
return [
|
|
CardPlugin.LightBox
|
|
]
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
let arr = super._getVerificationFunctions(context)
|
|
let funcs = [
|
|
this._verifyElementsExist.bind(this, context, this.wrapperSelector, this.overlaySelector)
|
|
]
|
|
return arr.concat(funcs)
|
|
}
|
|
|
|
append(context) {
|
|
let source = this._retrieveSource(context)
|
|
this.setupEnlargeableThumbnail(context, source)
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the preview image.
|
|
*
|
|
* It depends on the fact, that the thumbnail image is in the same directory
|
|
*
|
|
*
|
|
* @param {*} context
|
|
* @returns
|
|
* @memberof EnlargeableThumbnail
|
|
*/
|
|
_retrieveSource(context) {
|
|
let img = context.querySelector(this.wrapperSelector + " img")
|
|
let src = img.getAttribute("src")
|
|
let parts = src.split("/")
|
|
parts.pop()
|
|
parts.push(parts[parts.length - 1])
|
|
let imagePath = parts.join("/") + ".jpg"
|
|
return imagePath
|
|
}
|
|
|
|
|
|
setupEnlargeableThumbnail(context, src) {
|
|
let wrapper = context.querySelector(this.wrapperSelector)
|
|
let overlay = context.querySelector(this.overlaySelector)
|
|
|
|
let icon = document.createElement("div")
|
|
icon.className = "button corner-button bottom-right icon zoom"
|
|
wrapper.appendChild(icon)
|
|
|
|
Object.assign(wrapper.style, {
|
|
cursor: "pointer"
|
|
})
|
|
|
|
InteractionMapper.on(this.interactionType, wrapper, () => {
|
|
this.openThumbnailDetail(context, src)
|
|
})
|
|
|
|
InteractionMapper.on(this.interactionType, overlay, () => {
|
|
this.closeThumnailDetail(context)
|
|
})
|
|
}
|
|
|
|
openThumbnailDetail(context, src) {
|
|
let overlay = context.querySelector(".img-overlay")
|
|
overlay.innerHTML = ""
|
|
let source = context.querySelector(this.wrapperSelector)
|
|
let sourceStyle = window.getComputedStyle(source)
|
|
let imageWrapper = source.cloneNode(true)
|
|
let image = imageWrapper.querySelector("img")
|
|
|
|
Object.assign(imageWrapper.style, {
|
|
maxWidth: "none",
|
|
maxHeight: "none"
|
|
})
|
|
|
|
Object.assign(image.style, {
|
|
width: "100%",
|
|
height: "100%",
|
|
objectFit: "cover"
|
|
})
|
|
|
|
this._replaceIcon(imageWrapper)
|
|
|
|
image.onload = () => {
|
|
let header = context.querySelector("header")
|
|
let headerStlye = window.getComputedStyle(header)
|
|
|
|
/**
|
|
* First the maxFillRatio is considered.
|
|
* It describes how much the image is allowed to exceed the context element.
|
|
*/
|
|
const maxFillRatio = 1.5
|
|
|
|
/**
|
|
* The minor side should not exceed the height of the context window.
|
|
*/
|
|
const maxMinorSize = context.offsetHeight - 2 * parseInt(headerStlye.paddingTop) - 2 * parseInt(headerStlye.marginTop)
|
|
|
|
|
|
|
|
const max = {
|
|
width: context.offsetWidth * maxFillRatio,
|
|
height: context.offsetHeight * maxFillRatio
|
|
}
|
|
|
|
|
|
let majorSide
|
|
let minorSide
|
|
const _width = { name: "width", axis: "x" }
|
|
const _height = { name: "height", axis: "y" }
|
|
if (image.naturalHeight > image.naturalWidth) {
|
|
majorSide = _height
|
|
minorSide = _width
|
|
} else {
|
|
majorSide = _width
|
|
minorSide = _height
|
|
}
|
|
|
|
function capitalize(string) {
|
|
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
}
|
|
function getImageSize(side) {
|
|
return image["natural" + capitalize(side.name)]
|
|
}
|
|
|
|
const majorImageSize = getImageSize(majorSide)
|
|
// const minorImageSize = getImageSize(minorSide)
|
|
|
|
let ratio = getImageSize(minorSide) / getImageSize(majorSide)
|
|
let size = (majorImageSize > max[majorSide.name]) ? max[majorSide.name] : majorImageSize
|
|
|
|
if (size * ratio > maxMinorSize) {
|
|
size = maxMinorSize / ratio
|
|
}
|
|
|
|
let targetDimensions = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
|
|
let position = Points.fromPageToNode(context, Points.fromNodeToPage(source, { x: 0, y: 0 }))
|
|
|
|
let targetOffset = {
|
|
x: 0,
|
|
y: 0
|
|
}
|
|
|
|
targetDimensions[majorSide.name] = size
|
|
targetDimensions[minorSide.name] = size * ratio
|
|
|
|
targetOffset[majorSide.axis] = (context["offset" + capitalize(majorSide.name)] - targetDimensions[majorSide.name]) / 2
|
|
targetOffset[minorSide.axis] = (context["offset" + capitalize(minorSide.name)] - targetDimensions[minorSide.name]) / 2
|
|
|
|
overlay.appendChild(imageWrapper)
|
|
|
|
TweenMax.set(imageWrapper, {
|
|
left: 0,
|
|
top: 0,
|
|
x: position.x,
|
|
y: position.y,
|
|
position: "absolute",
|
|
width: parseInt(sourceStyle.width),
|
|
height: parseInt(sourceStyle.height)
|
|
})
|
|
|
|
|
|
TweenMax.set(overlay, {
|
|
display: "flex",
|
|
autoAlpha: 0
|
|
})
|
|
|
|
TweenMax.to(imageWrapper, this.zoomAnimationDuration, {
|
|
x: targetOffset.x,
|
|
y: targetOffset.y,
|
|
width: targetDimensions.width,
|
|
height: targetDimensions.height,
|
|
})
|
|
TweenMax.to(overlay, this.fadeAnimationTime, {
|
|
autoAlpha: 1
|
|
})
|
|
}
|
|
|
|
image.src = src
|
|
}
|
|
|
|
_replaceIcon(clone) {
|
|
let zoomIcon = clone.querySelector(".icon.zoom")
|
|
zoomIcon.classList.remove("zoom")
|
|
zoomIcon.classList.add("close")
|
|
}
|
|
|
|
getBorderHeight(style) {
|
|
const borderWidth = parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth)
|
|
const padding = parseInt(style.paddingTop) + parseInt(style.paddingBottom)
|
|
return parseInt(style.width) + borderWidth + padding
|
|
}
|
|
|
|
getBorderWidth(style) {
|
|
const borderWidth = parseInt(style.borderLeftWidth) + parseInt(style.borderRightWidth)
|
|
const padding = parseInt(style.paddingLeft) + parseInt(style.paddingRight)
|
|
return parseInt(style.width) + borderWidth + padding
|
|
}
|
|
|
|
closeThumnailDetail(context) {
|
|
let overlay = context.querySelector(".img-overlay")
|
|
|
|
let timeline = new TimelineLite()
|
|
|
|
timeline.to(overlay, this.fadeAnimationDuration, {
|
|
autoAlpha: 0
|
|
}).set(overlay, {
|
|
display: "none"
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
CardPlugin.Ui = class UiPlugin extends CardPluginBase {
|
|
constructor(className, parent = null) {
|
|
super()
|
|
this.parent = parent
|
|
this.className = className
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
let arr = super._getVerificationFunctions(context)
|
|
let func = [
|
|
this._doesParentExist.bind(this, context, this.parent)
|
|
]
|
|
return arr.concat(func)
|
|
}
|
|
|
|
_doesParentExist(context, parent) {
|
|
if (parent == null) return true
|
|
let valid = (context.querySelector(parent) != null)
|
|
if (!valid) console.error("Could not find parent on context.", context, parent)
|
|
return valid
|
|
}
|
|
|
|
append(context) {
|
|
parent = (this.parent == null) ? context : context.querySelector(this.parent).appendChild(container)
|
|
let container = document.createElement("div")
|
|
container.className = this.className
|
|
parent.appendChild(container)
|
|
}
|
|
|
|
}
|
|
|
|
CardPlugin.Speech = class SpeechPlugin extends CardPluginBase {
|
|
|
|
constructor(parentSelector, className, interactionType = "tap") {
|
|
super()
|
|
this.className = className
|
|
this.parentSelector = parentSelector
|
|
this.interactionType = interactionType
|
|
|
|
}
|
|
|
|
get require() {
|
|
return [
|
|
CardPlugin.Ui
|
|
]
|
|
}
|
|
|
|
append(context) {
|
|
let container = context.querySelector(this.parentSelector)
|
|
this.button = document.createElement("div")
|
|
this.button.className = "icon button " + this.className
|
|
container.appendChild(this.button)
|
|
|
|
InteractionMapper.on(this.interactionType, this.button, () => {
|
|
let subcard = context.querySelector(".mainview > .subcard")
|
|
let target = (subcard) ? subcard : context
|
|
|
|
this.speak(target)
|
|
|
|
})
|
|
}
|
|
|
|
_activate() {
|
|
this._disableActive()
|
|
this.active = this
|
|
this._activateButton()
|
|
}
|
|
|
|
_activateButton() {
|
|
if (this.button)
|
|
this.button.classList.add("active")
|
|
}
|
|
|
|
_deactivate() {
|
|
this._deactivateButton()
|
|
}
|
|
|
|
_deactivateButton() {
|
|
if (this.button)
|
|
this.button.classList.remove("active")
|
|
}
|
|
|
|
_isSameNode(node) {
|
|
//console.log(this.currentText, node.innerText)
|
|
return (this.currentText == node.innerText)
|
|
}
|
|
|
|
speak(node) {
|
|
|
|
console.log(this._isSameNode(node))
|
|
|
|
if (!window.speechSynthesis.speaking) {
|
|
console.log("Noone talking!")
|
|
this._start(node)
|
|
} else if (this._isSameNode(node)) {
|
|
console.log("Requested same!")
|
|
this._stop()
|
|
|
|
} else {
|
|
console.log("Requested Different!")
|
|
this._stop()
|
|
this._start(node)
|
|
}
|
|
|
|
}
|
|
|
|
_disableActive() {
|
|
console.log("disableActive:", this.active)
|
|
if (this.active) {
|
|
this.active._deactivate()
|
|
}
|
|
}
|
|
|
|
_start(node) {
|
|
this.currentText = node.innerText
|
|
let utterance = new SpeechSynthesisUtterance(node.innerText)
|
|
|
|
let voices = window.speechSynthesis.getVoices()
|
|
console.log(voices)
|
|
let voice = voices.filter((val) => {
|
|
//console.log(val)
|
|
return val.name == "Microsoft Hedda Desktop - German"
|
|
})[0]
|
|
|
|
//console.log(voice)
|
|
|
|
utterance.voice = voice
|
|
console.log("TALK: ", utterance)
|
|
window.speechSynthesis.speak(utterance)
|
|
this._activate()
|
|
window.speechSynthesis.resume()
|
|
|
|
|
|
utterance.onboundary = () => { console.log("onboundary", node.innerText); if (this.currentText.substring(0, 5) != node.innerText.substring(0, 5)) { console.log("text for speech synth changed!", this.currentText, node.innerText); this._stop() } }
|
|
utterance.onend = () => console.log("onend", node.innerText)
|
|
utterance.onerror = () => console.log("onerror", node.innerText)
|
|
utterance.onmark = () => console.log("onmark", node.innerText)
|
|
utterance.onpause = () => console.log("onpause", node.innerText)
|
|
utterance.onresume = () => console.log("onresume", node.innerText)
|
|
utterance.onstart = () => console.log("onstart", node.innerText)
|
|
utterance.onerror = () => console.log("onerror", node.innerText)
|
|
}
|
|
|
|
_stop() {
|
|
window.speechSynthesis.cancel()
|
|
this.currentText = null
|
|
this._deactivate()
|
|
}
|
|
|
|
get active() {
|
|
return this.constructor.active
|
|
}
|
|
|
|
set active(val) { this.constructor.active = val }
|
|
|
|
get currentText() {
|
|
return this.constructor.text
|
|
}
|
|
|
|
set currentText(val) {
|
|
this.constructor.text = val
|
|
}
|
|
|
|
}
|