iwmlib/lib/card/card.js

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
}
}