1905 lines
67 KiB
JavaScript
1905 lines
67 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 default 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
|
|
})
|
|
|
|
TweenLite.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) {
|
|
TweenLite.to(overlay, 0.2, {
|
|
autoAlpha: 0,
|
|
onComplete: () => {
|
|
popup.remove()
|
|
//this._cleanup(context)
|
|
//overlay.parentNode.removeChild(overlay)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens a popup for a glossary. Typically used as a onlick handler of a link.
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @returns {bool} false - Returns false to prevent default click action
|
|
* @memberof Card
|
|
*/
|
|
static loadPopup(event, context = null, node = null, local = null) {
|
|
let editable = Card.isEditable()
|
|
if (context == null) {
|
|
context = this.getContext(event.target)
|
|
}
|
|
if (node == null) {
|
|
node = event.target
|
|
}
|
|
if (local == null) {
|
|
let globalClick = event.center ? event.center : { x: event.x, y: event.y }
|
|
local = Points.fromPageToNode(context, globalClick)
|
|
}
|
|
if (this.debug) console.log('loadPopup', event)
|
|
|
|
// Prevents loading the link in the current tab.
|
|
// Prevents loading the link in the current tab.
|
|
if (event.type != 'Follow') event.preventDefault()
|
|
|
|
if (editable && event.type == 'click') {
|
|
return false
|
|
}
|
|
|
|
let overlay = document.createElement('div')
|
|
let src = node.getAttribute('href')
|
|
let parentArticle = node.closest('article')
|
|
|
|
const that = this
|
|
let xhr = new XMLHttpRequest()
|
|
xhr.open('get', src, true)
|
|
xhr.onreadystatechange = () => {
|
|
if (this.debug) console.log('Popup Source: ', src)
|
|
if (xhr.readyState == 4 && (xhr.status == 200 || xhr.status == 0)) {
|
|
if (editable) {
|
|
if (this.debug) console.log('Append overlay.', context)
|
|
overlay.classList.add('overlay')
|
|
TweenLite.set(overlay, { autoAlpha: 0 })
|
|
context.appendChild(overlay)
|
|
TweenLite.to(overlay, 0.5, { autoAlpha: 0.25 })
|
|
}
|
|
|
|
// Extract the body from the Popup site.
|
|
let parser = new DOMParser()
|
|
let popupPage = parser.parseFromString(xhr.responseText, 'text/html')
|
|
|
|
//Fix the relative path of loaded images in the popup.
|
|
popupPage.querySelectorAll('img').forEach(node => {
|
|
node.setAttribute('src', that._getRelativePath(node.getAttribute('src')))
|
|
})
|
|
let html = popupPage.body.innerHTML
|
|
/**
|
|
* We do not want to use the popup class again.
|
|
* This results in problems when styling the popup.
|
|
* (You could style the .unselectable.popup, but that
|
|
* wouldn't be clean).
|
|
*
|
|
* Therefore the selector was changed:
|
|
* '.popup' => '.popupHtml'
|
|
*
|
|
* Which describes the construct even better than the
|
|
* '.popup' class.
|
|
*/
|
|
let selector = Card.popupHtmlSelector
|
|
let content = { html, selector }
|
|
|
|
let isSame = Card._checkForActiveSource(context, src)
|
|
Card._cleanup(context)
|
|
|
|
if (!isSame) {
|
|
Card._activateCorrespondingHighlights(context, node, parentArticle)
|
|
|
|
let callback = (popup, callback) => {
|
|
if (this.debug) console.log('Close popup (Editable = ' + editable + ').', popup)
|
|
if (editable) {
|
|
let isDirty = mainController.askSaveNode()
|
|
if (isDirty)
|
|
mainController.saveNode(html.innerHTML, url => {
|
|
if (callback) {
|
|
callback()
|
|
}
|
|
saved(url)
|
|
this._overlayCleanup(context, overlay)
|
|
})
|
|
else {
|
|
if (callback) {
|
|
callback()
|
|
}
|
|
this._overlayCleanup(context, overlay)
|
|
}
|
|
mainController.popController()
|
|
} else {
|
|
/** This may be in conflice with the cleanup method. */
|
|
//this._overlayCleanup(context, overlay)
|
|
popup.remove()
|
|
}
|
|
}
|
|
|
|
Card._openPopup(context, src, local, content, {
|
|
closeCommand: callback
|
|
})
|
|
}
|
|
}
|
|
|
|
overlay.onclick = e => {
|
|
if (editable) e.preventDefault()
|
|
}
|
|
//console.log("onreadystatechange", editable)
|
|
if (editable) {
|
|
if (this.debug) console.log('pushController', src, popup.insertedNode)
|
|
mainController.pushController(popup.insertedNode, src)
|
|
}
|
|
}
|
|
xhr.send()
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* When an highlight link is clicked, this method activates all
|
|
* corresponding highlights.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - The context of the element.
|
|
* @param {DomElement} node - The node that (may) contain a highlightId.
|
|
* @param {DomElement} parent - The parent element that may contain more highlightIds.
|
|
* @memberof Card
|
|
*/
|
|
static _activateCorrespondingHighlights(context, node, parent) {
|
|
let highlightId = node.getAttribute('data-highlight-id')
|
|
// console.log("Request Highlight: " + highlightId)
|
|
|
|
let correspondingHighlights = []
|
|
if (highlightId) {
|
|
let queryString = 'circle[data-highlight-id="' + highlightId + '"]'
|
|
if (parent) {
|
|
correspondingHighlights = parent.querySelectorAll(queryString)
|
|
}
|
|
if (correspondingHighlights.length > 0) {
|
|
for (let highlight of correspondingHighlights) {
|
|
if (highlight.parentNode && highlight.parentNode.nodeName.toLowerCase() == 'g') {
|
|
Highlight.openHighlight(highlight, {
|
|
animation: Card.highlightAnimation
|
|
})
|
|
this._addHighlight(context, highlight)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests if any open item already contains the requested Source.
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - Dom context we are in.
|
|
* @param {string} src - Source as dataUrl.
|
|
* @returns {boolean} - True if source is already active, false otherwise.
|
|
* @memberof Card
|
|
*/
|
|
static _checkForActiveSource(context, src) {
|
|
let requestedSame = false
|
|
let activePopup = Card._getPopup(context)
|
|
let activeHighlights = Card._getHighlights(context)
|
|
|
|
if (activePopup) {
|
|
let activeSrc = Card._getPopupSource(activePopup)
|
|
if (activeSrc && src == activeSrc) {
|
|
requestedSame = true
|
|
}
|
|
}
|
|
|
|
if (!requestedSame) {
|
|
for (let highlight of activeHighlights) {
|
|
let activeSrc = Card._getHighlightSource(highlight)
|
|
if (activeSrc == src) {
|
|
requestedSame = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return requestedSame
|
|
}
|
|
|
|
static _calculateCenterRelativeTo(target, image) {
|
|
console.log('_calculateCenterRelativeTo', target, image)
|
|
let bbox = image.getBBox()
|
|
let width = bbox.width
|
|
let height = bbox.height
|
|
let cx = target.getAttribute('cx')
|
|
let cy = target.getAttribute('cy')
|
|
let r = target.getAttribute('r')
|
|
let radius = r.endsWith('%') ? (parseFloat(r) / 100) * width : parseFloat(r)
|
|
|
|
let x = cx.endsWith('%') ? (parseFloat(cx) / 100) * width : cx
|
|
let y = cy.endsWith('%') ? (parseFloat(cy) / 100) * height : cx
|
|
console.log({ x, y, width, height, radius })
|
|
return { x, y }
|
|
}
|
|
|
|
/**
|
|
* Opens a popup for a highlight. Typically used as a onlick handler of a link.
|
|
*
|
|
* @static
|
|
* @param {any} event - The trigger event, typically a click event
|
|
* @returns {bool} false - Returns false to prevent default click action
|
|
* @memberof Card
|
|
*/
|
|
static loadHighlightPopup(event, node = null) {
|
|
if (this.debug) console.log('Card.loadHighlightPopup', event, node)
|
|
if (node == null) {
|
|
if (event.firstTarget) {
|
|
node = event.firstTarget
|
|
} else {
|
|
node = event.target
|
|
}
|
|
}
|
|
|
|
let context = this.getContext(node)
|
|
event.stopPropagation()
|
|
|
|
/**
|
|
* This node is the documents body, as events wont work
|
|
* on svg elements properly. We need a workaround for that.
|
|
*/
|
|
let src = node.getAttribute('xlink:href')
|
|
let isSame = this._checkForActiveSource(context, src)
|
|
|
|
this._cleanup(context)
|
|
if (!isSame) {
|
|
this._addHighlight(context, node)
|
|
|
|
Highlight.openHighlight(node, {
|
|
animation: Card.highlightAnimation,
|
|
onExpanded: () => {
|
|
// We assume it's always a circle. This may break, when other svg shapes are used.
|
|
|
|
let svgRoot = node.closest('svg')
|
|
let image = svgRoot.querySelector('image')
|
|
let position = Card._calculateCenterRelativeTo(node, image)
|
|
|
|
/*
|
|
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
|
|
let matrix = node.getCTM()
|
|
|
|
let point = svgPoint.matrixTransform(matrix)
|
|
let closestDiv = node.closest('div')
|
|
console.log('closestDiv', closestDiv, point)
|
|
let global = Points.fromNodeToPage(closestDiv, point)
|
|
let local = Points.fromPageToNode(context, global)
|
|
|
|
let overlay = document.createElement('div')
|
|
//TODO: Check if this is necessary for the editor.
|
|
// overlay.onclick = e =>{}
|
|
|
|
// This is not really efficient, as
|
|
// we could load the data while the circle is animating.
|
|
// but for simplicity it's just done here for now.
|
|
// TODO: Adjust to load while animating (Problem: Unload when cancelled).
|
|
console.log('loadHighlightPopup', src, position, local)
|
|
this._loadPopupContent(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'
|
|
|
|
TweenLite.set(zoomedFig, {
|
|
x: current.x,
|
|
y: current.y,
|
|
width: current.width + borderX,
|
|
height: current.height + borderY,
|
|
transformOrigin
|
|
})
|
|
TweenLite.set(zoomable, { opacity: 0 })
|
|
|
|
let icon = zoomedFig.querySelector('.icon')
|
|
TweenLite.set(icon, {
|
|
transformOrigin
|
|
})
|
|
zoomedFig.style.transformOrigin =
|
|
'calc(100% - ' +
|
|
parseFloat(zoomedFigStyle.borderRightWidth) +
|
|
'px) calc(100% - ' +
|
|
parseFloat(zoomedFigStyle.borderBottomWidth) +
|
|
'px)'
|
|
|
|
let tl = new TimelineLite()
|
|
|
|
let zoomCaption = zoomedFig.querySelector('.zoomcap')
|
|
|
|
tl.to(
|
|
zoomedFig,
|
|
Card.animation.zoomable,
|
|
{
|
|
ease: Power2.easeIn,
|
|
css: {
|
|
scaleX: scaleFactor,
|
|
scaleY: scaleFactor
|
|
}
|
|
},
|
|
0
|
|
)
|
|
.set(zoomCaption, {
|
|
css: {
|
|
display: 'block',
|
|
opacity: 0,
|
|
x: -parseFloat(zoomedFigStyle.borderLeftWidth),
|
|
width: current.width + borderX
|
|
}
|
|
})
|
|
.to(zoomCaption, this.animation.fade, {
|
|
autoAlpha: 1
|
|
})
|
|
} else this._openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current)
|
|
}
|
|
|
|
/**
|
|
* Selects and transforms the zoomicon from a zoomicon to a closeicon
|
|
* or the other way around.
|
|
*
|
|
* @static
|
|
* @param {DomElement} parent - Parent to be searched for an zoomicon.
|
|
* @memberof Card
|
|
*/
|
|
static _toggleNextIcon(parent) {
|
|
let zoomIcon = Card._findNextIcon(parent)
|
|
const closeClass = 'close'
|
|
const zoomClass = 'zoom'
|
|
|
|
if (zoomIcon.classList.contains(closeClass)) {
|
|
zoomIcon.classList.remove(closeClass)
|
|
zoomIcon.classList.add(zoomClass)
|
|
} else if (zoomIcon.classList.contains(zoomClass)) {
|
|
zoomIcon.classList.remove(zoomClass)
|
|
zoomIcon.classList.add(closeClass)
|
|
} else {
|
|
console.error(
|
|
`Error Toggleing Zoomicon: It did neither contain a class named ${closeClass} or ${zoomClass}.`
|
|
)
|
|
}
|
|
}
|
|
|
|
static _findNextIcon(parent) {
|
|
return parent.querySelector('.icon')
|
|
}
|
|
|
|
static _openZoomableEditorBehaviour(wrapper, img, zoomable, zoomedFig, current) {
|
|
let zoomContainer = document.createElement('div')
|
|
let zoomIcon = zoomable.querySelector('.zoom-icon')
|
|
zoomContainer.style.position = 'relative'
|
|
zoomContainer.style.zIndex = this.zIndices.zoomable
|
|
wrapper.appendChild(zoomContainer)
|
|
let zoomParent = zoomable.parentNode
|
|
zoomParent.appendChild(zoomedFig)
|
|
zoomedFig.style.opacity = 0.5
|
|
zoomContainer.appendChild(zoomable)
|
|
TweenLite.set(zoomable, {
|
|
x: current.x,
|
|
y: current.y,
|
|
width: current.width,
|
|
height: current.height
|
|
})
|
|
let editor = mainController.topController().ensureEditor(img)
|
|
let savedDisplay = zoomIcon.style.display
|
|
let iconClone = zoomIcon.cloneNode(true)
|
|
zoomIcon.style.display = 'none'
|
|
let savedOnClick = zoomIcon.getAttribute('onclick')
|
|
zoomIcon.removeAttribute('onclick')
|
|
iconClone.src = iconClone.src.replace('info.svg', 'close.svg')
|
|
iconClone.style.zIndex = 1000
|
|
iconClone.classList.add('zoomed-icon')
|
|
iconClone.onclick = event => {
|
|
zoomIcon.style.display = savedDisplay
|
|
iconClone.remove()
|
|
zoomIcon.setAttribute('onclick', savedOnClick)
|
|
zoomedFig.remove()
|
|
zoomContainer.remove()
|
|
zoomParent.appendChild(zoomable)
|
|
TweenLite.set(zoomable, { x: 0, y: 0 })
|
|
zoomable.onmousedown = null
|
|
zoomable.onmousemove = null
|
|
zoomable.onmouseup = null
|
|
zoomable.onmousewheel = null
|
|
}
|
|
wrapper.appendChild(iconClone)
|
|
TweenLite.set(iconClone, { x: current.iconPos.x, y: current.iconPos.y })
|
|
|
|
zoomable.onmousedown = event => {
|
|
if (this.debug) console.log('mousedown', event.target)
|
|
event.preventDefault()
|
|
zoomable.dragging = true
|
|
zoomable.dragStartPos = { x: event.pageX, y: event.pageY }
|
|
}
|
|
zoomable.onmousemove = event => {
|
|
if (this.debug) console.log('onmousemove', event.target)
|
|
if (zoomable.dragging) {
|
|
event.preventDefault()
|
|
let dx = event.pageX - zoomable.dragStartPos.x
|
|
let dy = event.pageY - zoomable.dragStartPos.y
|
|
TweenLite.set([zoomable, iconClone], {
|
|
x: '+=' + dx,
|
|
y: '+=' + dy
|
|
})
|
|
zoomable.dragStartPos = { x: event.pageX, y: event.pageY }
|
|
if (editor) {
|
|
editor.showControls()
|
|
}
|
|
}
|
|
}
|
|
zoomable.onmouseup = 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
|
|
|
|
TweenLite.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')
|
|
|
|
TweenLite.to(zoomedCaption, this.animation.fade, {
|
|
autoAlpha: 0
|
|
})
|
|
|
|
TweenLite.to(zoomedFig, this.animation.zoomable, {
|
|
css: {
|
|
scaleX: 1,
|
|
scaleY: 1
|
|
},
|
|
onComplete: () => {
|
|
TweenLite.set(zoomable, {
|
|
opacity: 1
|
|
})
|
|
let div = zoomedFig.parentNode
|
|
let videoElement = div.querySelector('video')
|
|
if (videoElement) videoElement.pause()
|
|
div.parentNode.removeChild(div)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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')
|
|
|
|
TweenLite.set(clone, {
|
|
css: {
|
|
position: 'absolute',
|
|
width: globalIndexCardRect.width,
|
|
height: globalIndexCardRect.height,
|
|
maxWidth: '100%',
|
|
margin: 0,
|
|
zIndex: this.zIndices.article
|
|
}
|
|
})
|
|
|
|
TweenLite.set(articleClone, {
|
|
autoAlpha: 0
|
|
})
|
|
|
|
TweenLite.set(card, { css: { maxWidth: '100%' } })
|
|
TweenLite.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.
|
|
*/
|
|
// TweenLite.set(subcardContent, {
|
|
// height: "100%"
|
|
// })
|
|
// TweenLite.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)
|
|
TweenLite.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
|
|
TweenLite.set(title, {
|
|
scale: 1 / transform.scaleX
|
|
})
|
|
TweenLite.set(titlebar, {
|
|
height: (start.height * 1) / transform.scaleY
|
|
})
|
|
|
|
// Retain the border at same visual thickness.
|
|
titlebar.style.borderBottomWidth = desiredBorderBottomWidth / transform.scaleY + 'px'
|
|
}
|
|
})
|
|
|
|
TweenLite.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')
|
|
|
|
TweenLite.to(titlebar, this.animation.articleTransition, {
|
|
height: parseInt(titlebarStyle.height)
|
|
})
|
|
|
|
TweenLite.to(articleClone, this.animation.articleTransition / 2, {
|
|
autoAlpha: 0
|
|
})
|
|
|
|
let title = titlebar.querySelector('h2')
|
|
let original = {
|
|
height: parseInt(titlebarStyle.height)
|
|
}
|
|
|
|
if (this.dynamicHeight) {
|
|
TweenLite.to(subcardContent, this.animation.articleTransition, {
|
|
height: '100%'
|
|
})
|
|
}
|
|
|
|
TweenLite.set(card, { autoAlpha: 1, css: { maxWidth } })
|
|
TweenLite.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()
|
|
TweenLite.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
|
|
|
|
TweenLite.set(title, {
|
|
scale: 1 / transform.scaleX
|
|
})
|
|
|
|
TweenLite.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)
|
|
// TweenLite.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
|
|
}
|