11307 lines
406 KiB
JavaScript
11307 lines
406 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
// In order to test this interface implementation run jsc interface.js
|
|
|
|
class Interface {
|
|
// Abstract interface that should be extended in interface subclasses.
|
|
// By convention all interfaces should start with an upper 'I'
|
|
|
|
static implementationError(klass) {
|
|
let interfaceKeys = Reflect.ownKeys(this.prototype);
|
|
let classKeys = Reflect.ownKeys(klass.prototype);
|
|
for (let key of interfaceKeys) {
|
|
let interfaceDesc = this.prototype[key];
|
|
let classDesc = klass.prototype[key];
|
|
if (typeof classDesc == 'undefined') return 'Missing ' + key
|
|
}
|
|
return null
|
|
}
|
|
|
|
static implementedBy(klass) {
|
|
// In the first step only checks whether the methods of this
|
|
// interface are all implemented by the given class
|
|
let error = this.implementationError(klass);
|
|
return error == null
|
|
}
|
|
|
|
// TODO: Specify optional methods
|
|
// static optionalMethods() {
|
|
// return [this.onMouseWheel]
|
|
// }
|
|
}
|
|
|
|
/** Basic Application object to be used as a singleton.
|
|
Provides an interface for automatic testing and common device specific
|
|
feature detection.
|
|
*/
|
|
|
|
class IApp extends Interface {
|
|
/** Build the app by registering event handlers,
|
|
* adding DOM elements, instanciating templates, etc...
|
|
*/
|
|
setup() {
|
|
return this
|
|
}
|
|
|
|
/** Run the application by starting a main loop, ...
|
|
*/
|
|
run() {
|
|
return this
|
|
}
|
|
}
|
|
|
|
class App extends Object {
|
|
/** Override this method to build your app.
|
|
*/
|
|
setup() {
|
|
return this
|
|
}
|
|
|
|
/** Start and run the application. Override this method with everything
|
|
that is needed to maintain your App, main loops, etc.
|
|
*/
|
|
run() {
|
|
return this
|
|
}
|
|
|
|
/** Defines all test suites. Overwrite this method to ensure that
|
|
all testable aspects of your App are evaluated.
|
|
*/
|
|
allTests() {
|
|
console.log('Overwrite App.allTests()');
|
|
}
|
|
|
|
/** Run all tests. Should return 'ok' and the amount of time needed to
|
|
run App.allTests() or a failure message with diagnostic error decription.
|
|
@return {array} - array with 'ok' as first element and needed time as
|
|
second element or "Tests failed" and an error string
|
|
*/
|
|
runTests() {
|
|
var start = performance.now();
|
|
try {
|
|
this.allTests();
|
|
var end = performance.now();
|
|
return ['ok', end - start]
|
|
} catch (e) {
|
|
console.trace();
|
|
return ['Tests failed', e.message]
|
|
}
|
|
}
|
|
}
|
|
|
|
IApp.implementedBy(App);
|
|
|
|
// Allows browsers to perform doctests.
|
|
// Uses the code highlight package from http://highlightjs.readthedocs.io
|
|
// if available
|
|
|
|
var docTestLogMessages = [];
|
|
|
|
Array.prototype.equals = function(array) {
|
|
return (
|
|
this.length == array.length &&
|
|
this.every(function(this_i, i) {
|
|
return this_i == array[i]
|
|
})
|
|
)
|
|
};
|
|
|
|
class Doctest {
|
|
static assert(value) {
|
|
if (!value) {
|
|
throw new Error('Assertion violated')
|
|
}
|
|
}
|
|
|
|
static pprint(obj) {
|
|
if (obj === null) return 'null'
|
|
let stringified = obj.toString();
|
|
if (stringified == '[object Object]') return JSON.stringify(obj)
|
|
return stringified
|
|
}
|
|
|
|
/**
|
|
* When dealing with floating point numbers,
|
|
* floating point imprecision may occur. Therefore a simple
|
|
* equality check is not sufficient.
|
|
*
|
|
* The expectPointPrecision method compares two points by calculating their difference
|
|
* and accepting the result as far as it is lower than the defined acceptable error.
|
|
*
|
|
* @static
|
|
* @param {number} a - Number a to test.
|
|
* @param {number} b - Number b to test against.
|
|
* @param {number} accetableError - Defines the value of how much the two numbers may differ, that the test still passes.
|
|
* @memberof Doctest
|
|
*/
|
|
static expectPointPrecision(pointA, pointB, accetableError = 0.000001) {
|
|
if (Math.abs(pointA.x - pointB.x) > accetableError || Math.abs(pointA.y - pointB.y) > accetableError) {
|
|
throw new Error(
|
|
`Testing difference of Points ${this.pprint(pointA)} and ${this.pprint(
|
|
pointB
|
|
)} exceeded the acceptable error of ${accetableError} (x:${Math.abs(
|
|
pointB.x - pointA.x
|
|
)}, y: ${Math.abs(pointB.y - pointA.y)}).`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When dealing with floating point numbers,
|
|
* floating point imprecision may occur. Therefore a simple
|
|
* equality check is not sufficient.
|
|
*
|
|
* The expectPrecise method compares two numbers by calculating their difference
|
|
* and accepting the result as far as it is lower than the defined acceptable error.
|
|
*
|
|
* @static
|
|
* @param {number} a - Number a to test.
|
|
* @param {number} b - Number b to test against.
|
|
* @param {number} accetableError - Defines the value of how much the two numbers may differ, that the test still passes.
|
|
* @memberof Doctest
|
|
*/
|
|
static expectPrecision(a, b, accetableError = 0.000001) {
|
|
let aFloat = parseFloat(a);
|
|
let bFloat = parseFloat(b);
|
|
|
|
console.log(aFloat);
|
|
if (isNaN(aFloat) || isNaN(bFloat) || Math.abs(bFloat - aFloat) > accetableError) {
|
|
throw new Error(
|
|
`Testing difference of ${this.pprint(a)} and ${this.pprint(
|
|
b
|
|
)} exceeded the acceptable error of ${accetableError} (${Math.abs(bFloat - aFloat)}).`
|
|
)
|
|
}
|
|
}
|
|
|
|
static expect(expr, value) {
|
|
if (this.pprint(expr) != this.pprint(value)) {
|
|
//throw new Error("got `" + expr + "` but expected `" + value + "`.")
|
|
throw new Error('got `' + this.pprint(expr) + '` but expected `' + this.pprint(value) + '`.')
|
|
}
|
|
}
|
|
|
|
static expectError(error, message) {
|
|
let index = error.toString().indexOf(message);
|
|
if (index < 0) {
|
|
throw new Error('got `' + message + '` but expected `' + error + '`.')
|
|
}
|
|
}
|
|
|
|
static expectLog(...messages) {
|
|
// if (!docTestLogMessages.equals(messages)) {
|
|
docTestLogMessages.forEach((msg, i) => {
|
|
if (msg != messages[i]) throw new Error('Unexpected log message: `' + messages[i] + '`.')
|
|
});
|
|
// throw new Error('Uups')
|
|
//}
|
|
}
|
|
|
|
static log(message) {
|
|
docTestLogMessages.push(message);
|
|
}
|
|
|
|
static highlight(code) {
|
|
if (typeof hljs == 'undefined') return code
|
|
return hljs.highlight('javascript', code)
|
|
}
|
|
|
|
static stripLeadingLines(code) {
|
|
let result = [];
|
|
let informative = false;
|
|
for (let line of code.split('\n')) {
|
|
if (line.trim().length > 0) {
|
|
informative = true;
|
|
}
|
|
if (informative) result.push(line);
|
|
}
|
|
return result.join('\n')
|
|
}
|
|
|
|
static event(type = 'mouse', { clientX = 0, clientY = 0 } = {}) {
|
|
if (type.startsWith('mouse')) {
|
|
return new MouseEvent(type, { clientX, clientY })
|
|
}
|
|
return { type, clientX, clientY }
|
|
}
|
|
|
|
static run(replaceExpect = false) {
|
|
if (typeof hljs != 'undefined') {
|
|
hljs.initHighlighting();
|
|
}
|
|
let doctests = document.querySelectorAll('.doctest');
|
|
for (let i = 0; i < doctests.length; i++) {
|
|
let doctest = doctests[i];
|
|
let code = this.stripLeadingLines(doctest.innerHTML);
|
|
let text = this.highlight(code);
|
|
|
|
let container = document.createElement('div');
|
|
container.className = 'doctest-wrapper';
|
|
|
|
if (doctest.hasAttribute('data-title') || doctest.hasAttribute('data-collapsible')) {
|
|
let titlebar = document.createElement('div');
|
|
titlebar.className = 'doctest-titlebar';
|
|
titlebar.style = 'min-height: 10px;';
|
|
container.appendChild(titlebar);
|
|
|
|
if (doctest.hasAttribute('data-title')) {
|
|
let title = document.createElement('h6');
|
|
title.innerText = doctest.getAttribute('data-title');
|
|
title.className = 'doctest-section-title';
|
|
titlebar.appendChild(title);
|
|
}
|
|
|
|
if (doctest.hasAttribute('data-collapsible')) {
|
|
let icon = document.createElement('i');
|
|
icon.className = 'material-icons';
|
|
titlebar.classList.add('doctest-collapsible-toggle');
|
|
|
|
if (titlebar.childNodes.length > 0) {
|
|
titlebar.insertBefore(icon, titlebar.childNodes[0]);
|
|
} else {
|
|
titlebar.appendChild(icon);
|
|
}
|
|
|
|
const collapsedClass = 'collapsed';
|
|
|
|
function setToggleMode(collapse) {
|
|
if (collapse) {
|
|
container.classList.add(collapsedClass);
|
|
icon.innerText = 'arrow_drop_down';
|
|
} else {
|
|
container.classList.remove(collapsedClass);
|
|
icon.innerText = 'arrow_drop_up';
|
|
}
|
|
}
|
|
|
|
function determineToggleMode() {
|
|
setToggleMode(!container.classList.contains(collapsedClass));
|
|
}
|
|
|
|
setToggleMode(doctest.hasAttribute('data-collapsed'));
|
|
titlebar.addEventListener('click', determineToggleMode);
|
|
}
|
|
}
|
|
|
|
let pre = document.createElement('pre');
|
|
pre.className = 'hljs doctest';
|
|
// See http://stackoverflow.com/questions/1068280/javascript-regex-multiline-flag-doesnt-work
|
|
// let re = /Doctest\.expect\(([\s\S]*)[\,\s\S]*([\s\S]*)\)/g
|
|
let lines = text.value ? text.value.split('\n') : text.split('\n');
|
|
let better = [];
|
|
for (let line of lines) {
|
|
if (replaceExpect && line.trim().startsWith('Doctest.expect(')) {
|
|
line = line.replace(/Doctest\.expect\(/, '>>> ').trim();
|
|
if (line.endsWith(')') || line.endsWith(',')) {
|
|
line = line.slice(0, -1);
|
|
}
|
|
}
|
|
better.push(line);
|
|
}
|
|
pre.innerHTML = better.join('\n'); // text.value.replace(re, ">>> $1\n$2")
|
|
container.appendChild(pre);
|
|
|
|
doctest.parentNode.replaceChild(container, doctest);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Needed to make Doctest visible in modules
|
|
//window.Doctest = Doctest
|
|
|
|
var recordedErrors = new Map();
|
|
|
|
class Errors {
|
|
static countErrors() {
|
|
let total = 0;
|
|
for (let error of recordedErrors.keys()) {
|
|
total += recordedErrors.get(error).size;
|
|
}
|
|
return total
|
|
}
|
|
|
|
static setStyle(element, styles) {
|
|
for (let key in styles) {
|
|
element.style[key] = styles[key];
|
|
}
|
|
}
|
|
|
|
static appendError(error, source) {
|
|
if (recordedErrors.has(error)) {
|
|
let sources = recordedErrors.get(error);
|
|
sources.add(source);
|
|
} else {
|
|
recordedErrors.set(error, new Set([source]));
|
|
}
|
|
}
|
|
|
|
static showErrors() {
|
|
if (this.countErrors() == 0) {
|
|
return
|
|
}
|
|
let errors = document.getElementById('runtime-errors');
|
|
if (errors == null) {
|
|
errors = document.createElement('div');
|
|
errors.setAttribute('id', 'runtime-errors');
|
|
this.setStyle(document.body, {
|
|
border: '2px solid red'
|
|
});
|
|
this.setStyle(errors, {
|
|
position: 'absolute',
|
|
top: '0px',
|
|
padding: '8px',
|
|
width: '100%',
|
|
background: 'red',
|
|
color: 'white'
|
|
});
|
|
document.body.appendChild(errors);
|
|
let counter = document.createElement('div');
|
|
counter.setAttribute('id', 'runtime-errors-counter');
|
|
this.setStyle(counter, {
|
|
borderRadius: '50%',
|
|
width: '32px',
|
|
height: '32px',
|
|
background: 'white',
|
|
color: 'red',
|
|
fontSize: '18px',
|
|
textAlign: 'center',
|
|
lineHeight: '32px',
|
|
verticalAlign: 'middle'
|
|
});
|
|
counter.innerHTML = '1';
|
|
errors.appendChild(counter);
|
|
|
|
let header = document.createElement('div');
|
|
this.setStyle(header, {
|
|
position: 'absolute',
|
|
top: '6px',
|
|
left: '48px',
|
|
height: '44px',
|
|
fontSize: '32px'
|
|
});
|
|
header.innerHTML = 'Runtime Errors';
|
|
errors.appendChild(header);
|
|
errors.addEventListener('click', this.toggleErrors.bind(this));
|
|
}
|
|
let counter = document.getElementById('runtime-errors-counter');
|
|
counter.innerHTML = this.countErrors();
|
|
}
|
|
|
|
static expandErrors() {
|
|
let errors = document.getElementById('runtime-errors');
|
|
for (let error of recordedErrors.keys()) {
|
|
for (var source of recordedErrors.get(error)) {
|
|
if (typeof source == 'undefined') {
|
|
source = 'See console for details';
|
|
return
|
|
}
|
|
let info = document.createElement('div');
|
|
info.className = 'info';
|
|
info.style.wordWrap = 'break-word';
|
|
info.innerHTML = error + `<br/><small>${source}</small>`;
|
|
errors.appendChild(info);
|
|
}
|
|
}
|
|
}
|
|
|
|
static toggleErrors() {
|
|
let errors = document.getElementById('runtime-errors');
|
|
let infos = errors.querySelectorAll('.info');
|
|
if (infos.length > 0) {
|
|
infos.forEach(info => errors.removeChild(info));
|
|
} else {
|
|
this.expandErrors();
|
|
}
|
|
}
|
|
|
|
static removeError(event) {
|
|
console.log('removeError', event);
|
|
if (recordedErrors.has(event.error)) {
|
|
let sources = recordedErrors.get(event.error);
|
|
sources.delete(event.source);
|
|
console.log('sources', sources);
|
|
}
|
|
}
|
|
|
|
static registerGlobalErrorHandler() {
|
|
// Register more informative error handler
|
|
window.addEventListener(
|
|
'error',
|
|
event => {
|
|
if (typeof event.error == 'undefined') {
|
|
// This sometimes happens in Edge. Since we have no error
|
|
// position, we cannot do much beside an info log.
|
|
console.info('Catched undefined error', event);
|
|
return
|
|
}
|
|
this.appendError(event.error, event.filename);
|
|
},
|
|
true
|
|
);
|
|
|
|
document.addEventListener('DOMContentLoaded', event => {
|
|
this.showErrors();
|
|
});
|
|
}
|
|
|
|
static registerFrameAwaitErrors() {
|
|
let iframes = document.getElementsByTagName('iframe');
|
|
for (let i = 0; i < iframes.length; i++) {
|
|
let target = iframes[i];
|
|
target.iframeTimeout = setTimeout(() => {
|
|
this.appendError('Cannot load iframe', target.src);
|
|
}, frameErrorTimeout);
|
|
target.onload = () => {
|
|
clearTimeout(target.iframeTimeout);
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
Errors.registerGlobalErrorHandler();
|
|
|
|
class Events {
|
|
static stop(event) {
|
|
event.preventDefault();
|
|
// I removed this, as it destroys all the Hammer.js events.
|
|
// And also the click event.
|
|
// It seems to have no (apparent) negative impact. -SO
|
|
// event.stopPropagation()
|
|
}
|
|
|
|
static extractPoint(event) {
|
|
switch (event.constructor.name) {
|
|
case 'TouchEvent':
|
|
for (let i = 0; i < event.targetTouches.length; i++) {
|
|
let t = event.targetTouches[i];
|
|
return { x: t.clientX, y: t.clientY }
|
|
}
|
|
break
|
|
default:
|
|
return { x: event.clientX, y: event.clientY }
|
|
}
|
|
}
|
|
|
|
static isCaptured(event) {
|
|
if (event.__capturedBy) return true
|
|
return false
|
|
}
|
|
|
|
static capturedBy(event, obj) {
|
|
event.__capturedBy = obj;
|
|
}
|
|
|
|
static isPointerDown(event) {
|
|
// According to
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events
|
|
// pointer events use the buttons feature to represent pressed buttons
|
|
return event.buttons
|
|
}
|
|
|
|
static isMouseDown(event) {
|
|
// Attempts to clone the which attribute of events failed in WebKit. May
|
|
// be this is a bug or a security feature. Workaround: we introduce
|
|
// a mouseDownSubstitute attribute that can be assigned to cloned
|
|
// events after instantiation.
|
|
if (Reflect.has(event, 'mouseDownSubstitute')) return event.mouseDownSubstitute
|
|
return event.buttons || event.which
|
|
}
|
|
|
|
static isSimulatedEvent(event) {
|
|
return Reflect.has(event, 'mouseDownSubstitute')
|
|
}
|
|
|
|
static isMouseRightClick(event) {
|
|
return event.buttons || event.which
|
|
}
|
|
|
|
static extractTouches(targets) {
|
|
let touches = [];
|
|
for (let i = 0; i < targets.length; i++) {
|
|
let t = targets[i];
|
|
touches.push({
|
|
targetSelector: this.selector(t.target),
|
|
identifier: t.identifier,
|
|
screenX: t.screenX,
|
|
screenY: t.screenY,
|
|
clientX: t.clientX,
|
|
clientY: t.clientY,
|
|
pageX: t.pageX,
|
|
pageY: t.pageY
|
|
});
|
|
}
|
|
return touches
|
|
}
|
|
|
|
static createTouchList(targets) {
|
|
let touches = [];
|
|
for (let i = 0; i < targets.length; i++) {
|
|
let t = targets[i];
|
|
let touchTarget = document.elementFromPoint(t.pageX, t.pageY);
|
|
let touch = new Touch(undefined, touchTarget, t.identifier, t.pageX, t.pageY, t.screenX, t.screenY);
|
|
touches.push(touch);
|
|
}
|
|
return new TouchList(...touches)
|
|
}
|
|
|
|
static extractEvent(timestamp, event) {
|
|
let targetSelector = this.selector(event.target);
|
|
let infos = {
|
|
type: event.type,
|
|
time: timestamp,
|
|
constructor: event.constructor,
|
|
data: {
|
|
targetSelector: targetSelector,
|
|
view: event.view,
|
|
mouseDownSubstitute: event.buttons || event.which, // which cannot be cloned directly
|
|
bubbles: event.bubbles,
|
|
cancelable: event.cancelable,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
layerX: event.layerX,
|
|
layerY: event.layerY,
|
|
pageX: event.pageX,
|
|
pageY: event.pageY,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: event.altKey,
|
|
shiftKey: event.shiftKey,
|
|
metaKey: event.metaKey
|
|
}
|
|
};
|
|
if (event.type.startsWith('touch')) {
|
|
// On Safari-WebKit the TouchEvent has layerX, layerY coordinates
|
|
let data = infos.data;
|
|
data.targetTouches = this.extractTouches(event.targetTouches);
|
|
data.changedTouches = this.extractTouches(event.changedTouches);
|
|
data.touches = this.extractTouches(event.touches);
|
|
}
|
|
if (event.type.startsWith('pointer')) {
|
|
let data = infos.data;
|
|
data.pointerId = event.pointerId;
|
|
data.pointerType = event.pointerType;
|
|
}
|
|
if (Events.debug) {
|
|
Events.extracted.push(this.toLine(event));
|
|
}
|
|
return infos
|
|
}
|
|
|
|
static cloneEvent(type, constructor, data) {
|
|
if (type.startsWith('touch')) {
|
|
// We need to find target from layerX, layerY
|
|
//var target = document.querySelector(data.targetSelector)
|
|
// elementFromPoint(data.layerX, data.layerY)
|
|
//data.target = target
|
|
data.targetTouches = this.createTouchList(data.targetTouches);
|
|
data.changedTouches = this.createTouchList(data.changedTouches);
|
|
data.touches = this.createTouchList(data.touches);
|
|
}
|
|
// We need to find target from pageX, pageY which are only
|
|
// available after construction. They seem to getter items.
|
|
|
|
let clone = Reflect.construct(constructor, [type, data]);
|
|
clone.mouseDownSubstitute = data.mouseDownSubstitute;
|
|
return clone
|
|
}
|
|
|
|
static simulateEvent(type, constructor, data) {
|
|
data.target = document.querySelector(data.targetSelector);
|
|
let clone = this.cloneEvent(type, constructor, data);
|
|
if (data.target != null) {
|
|
data.target.dispatchEvent(clone);
|
|
}
|
|
if (Events.debug) {
|
|
Events.simulated.push(this.toLine(clone));
|
|
}
|
|
}
|
|
|
|
static toLine(event) {
|
|
return `${event.type} #${event.target.id} ${event.clientX} ${event.clientY}`
|
|
}
|
|
|
|
static compareExtractedWithSimulated() {
|
|
if (this.extracted.length != this.simulated.length) {
|
|
alert(
|
|
'Unequal length of extracted [' +
|
|
this.extracted.length +
|
|
'] and simulated events [' +
|
|
this.simulated.length +
|
|
'].'
|
|
);
|
|
} else {
|
|
for (let i = 0; i < this.extracted.length; i++) {
|
|
var extracted = this.extracted[i];
|
|
var simulated = this.simulated[i];
|
|
if (extracted != simulated) {
|
|
console.log('Events differ:' + extracted + '|' + simulated);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static selector(context) {
|
|
return OptimalSelect.select(context)
|
|
}
|
|
|
|
static reset() {
|
|
this.extracted = [];
|
|
this.simulated = [];
|
|
}
|
|
|
|
static resetSimulated() {
|
|
this.simulated = [];
|
|
}
|
|
|
|
static showExtractedEvents(event) {
|
|
if (!event.shiftKey) {
|
|
return
|
|
}
|
|
if (this.popup == null) {
|
|
let element = document.createElement('div');
|
|
Elements.setStyle(element, {
|
|
position: 'absolute',
|
|
width: '480px',
|
|
height: '640px',
|
|
overflow: 'auto',
|
|
backgroundColor: 'lightgray'
|
|
});
|
|
document.body.appendChild(element);
|
|
this.popup = element;
|
|
}
|
|
this.popup.innerHTML = '';
|
|
for (let line of this.extracted) {
|
|
let div = document.createElement('div');
|
|
div.innerHTML = line;
|
|
this.popup.appendChild(div);
|
|
}
|
|
let div = document.createElement('div');
|
|
div.innerHTML = '------------ Simulated -----------';
|
|
this.popup.appendChild(div);
|
|
for (let line of this.simulated) {
|
|
let div = document.createElement('div');
|
|
div.innerHTML = line;
|
|
this.popup.appendChild(div);
|
|
}
|
|
Elements.setStyle(this.popup, {
|
|
left: event.clientX + 'px',
|
|
top: event.clientY + 'px'
|
|
});
|
|
}
|
|
}
|
|
|
|
Events.popup = null;
|
|
Events.debug = true;
|
|
Events.extracted = [];
|
|
Events.simulated = [];
|
|
Events.simulationRunning = false;
|
|
|
|
class EventRecorder {
|
|
constructor() {
|
|
this.recording = [];
|
|
this.recorded = [];
|
|
this.step = 0;
|
|
}
|
|
|
|
record(event) {
|
|
let length = this.recording.length;
|
|
if (length == 0) {
|
|
this.startTime = event.timeStamp;
|
|
Events.reset();
|
|
} else {
|
|
let last = this.recording[length - 1];
|
|
if (event.timeStamp < last.time) {
|
|
console.log('warning: wrong temporal order');
|
|
}
|
|
}
|
|
let t = event.timeStamp - this.startTime;
|
|
this.recording.push(Events.extractEvent(t, event));
|
|
}
|
|
|
|
stopRecording() {
|
|
this.recorded = this.recording;
|
|
this.recording = [];
|
|
console.log('Recorded ' + this.recorded.length + ' events');
|
|
}
|
|
|
|
startReplay(whileCondition = null, onComplete = null) {
|
|
this.step = 0;
|
|
Events.resetSimulated();
|
|
console.log('Start replay');
|
|
Events.simulationRunning = true;
|
|
this.replay(whileCondition, onComplete);
|
|
}
|
|
|
|
replay(whileCondition = null, onComplete = null) {
|
|
if (this.step < this.recorded.length) {
|
|
let { type, time, constructor, data } = this.recorded[this.step];
|
|
Events.simulateEvent(type, constructor, data);
|
|
|
|
this.step += 1;
|
|
let dt = 0;
|
|
if (this.step < this.recorded.length) {
|
|
var next = this.recorded[this.step];
|
|
dt = next.time - time;
|
|
if (dt < 0) {
|
|
console.log('warning: wrong temporal order');
|
|
}
|
|
}
|
|
if (whileCondition == null || whileCondition()) {
|
|
let delta = Math.round(dt);
|
|
setTimeout(() => this.replay(whileCondition, onComplete), delta);
|
|
}
|
|
} else {
|
|
console.log('Played ' + this.step + ' events' + onComplete);
|
|
Events.simulationRunning = false;
|
|
if (onComplete != null) {
|
|
onComplete();
|
|
}
|
|
//Events.compareExtractedWithSimulated()
|
|
}
|
|
}
|
|
}
|
|
|
|
/* globals */
|
|
|
|
/** Tests whether an object is empty
|
|
* @param {Object} obj - the object to be tested
|
|
* @return {boolean}
|
|
*/
|
|
function isEmpty(obj) {
|
|
// > isEmpty({})
|
|
// true
|
|
for (let i in obj) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function lerp(start, stop, amt) {
|
|
return amt * (stop - start) + start
|
|
}
|
|
|
|
// Returns a function, that, as long as it continues to be invoked, will not
|
|
// be triggered. The function will be called after it stops being called for
|
|
// N milliseconds. If `immediate` is passed, trigger the function on the
|
|
// leading edge, instead of the trailing.
|
|
// Taken from: https://davidwalsh.name/essential-javascript-functions
|
|
function debounce(func, wait, immediate) {
|
|
let timeout;
|
|
return function() {
|
|
let context = this,
|
|
args = arguments;
|
|
let later = function() {
|
|
timeout = null;
|
|
if (!immediate) func.apply(context, args);
|
|
};
|
|
let callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
if (callNow) func.apply(context, args);
|
|
}
|
|
}
|
|
|
|
/** Returns an id that is guaranteed to be unique within the livetime of the
|
|
* application
|
|
* @return {string}
|
|
*/
|
|
let _idGenerator = 0;
|
|
function getId$1() {
|
|
return 'id' + _idGenerator++
|
|
}
|
|
|
|
function randomInt(min = 0, max = 100) {
|
|
return Math.floor(Math.random() * (max - min + 1) + min)
|
|
}
|
|
|
|
function randomFloat(min = 0.0, max = 1.0) {
|
|
return Math.random() * (max - min) + min
|
|
}
|
|
|
|
class Dates {
|
|
static create(fullYear, month, day) {
|
|
return new Date(Date.UTC(fullYear, month, day))
|
|
}
|
|
|
|
static daysInMonth(date) {
|
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
|
}
|
|
|
|
static startYearRange(date) {
|
|
return new Date(Date.UTC(date.getFullYear() - 1, 11, 31, 23, 59, 59, 999))
|
|
}
|
|
|
|
static endYearRange(date) {
|
|
return new Date(Date.UTC(date.getFullYear() + 1, 0, 1))
|
|
}
|
|
|
|
static prevYear(date, offset = 1) {
|
|
return this.create(date.getFullYear() - offset, 0, 1)
|
|
}
|
|
|
|
static nextYear(date, offset = 1) {
|
|
return this.create(date.getFullYear() + offset, 0, 1)
|
|
}
|
|
|
|
static nextMonth(date) {
|
|
return this.create(date.getFullYear(), date.getMonth() + 1, 1)
|
|
}
|
|
|
|
static nextDay(date) {
|
|
return this.create(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
|
}
|
|
|
|
static nextHour(date) {
|
|
// See http://stackoverflow.com/questions/1050720/adding-hours-to-javascript-date-object
|
|
return new Date(date.getTime() + 60 * 60 * 1000)
|
|
}
|
|
|
|
static nextMinute(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 60 * 1000)
|
|
}
|
|
|
|
static nextSecond(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 1000)
|
|
}
|
|
|
|
static nextMillisecond(date) {
|
|
// See above
|
|
return new Date(date.getTime() + 1)
|
|
}
|
|
|
|
static *iterYears(start, end) {
|
|
let date = this.create(start.getFullYear(), 0, 1);
|
|
while (date <= end) {
|
|
yield date;
|
|
date = this.nextYear(date);
|
|
}
|
|
yield date;
|
|
}
|
|
|
|
static *iterMonths(year, limit = 12) {
|
|
let month = 0;
|
|
while (month < limit) {
|
|
let date = this.create(year.getFullYear(), month, 1);
|
|
yield date;
|
|
month += 1;
|
|
}
|
|
}
|
|
|
|
static *iterMonthsOfYears(years) {
|
|
for (let year of years) {
|
|
for (let month of this.iterMonths(year)) {
|
|
yield month;
|
|
}
|
|
}
|
|
}
|
|
|
|
static *iterDays(month) {
|
|
let day = 1;
|
|
let limit = Dates.daysInMonth(month);
|
|
while (day <= limit) {
|
|
let date = this.create(month.getFullYear(), month.getMonth(), day);
|
|
yield date;
|
|
day += 1;
|
|
}
|
|
}
|
|
|
|
static *iterDaysOfMonths(months) {
|
|
for (let month of months) {
|
|
for (let day of this.iterDays(month)) {
|
|
yield day;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/* Color conversion functions */
|
|
|
|
class Colors {
|
|
// http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
|
|
|
|
static rgb2num(red, green, blue) {
|
|
let rgb = blue | (green << 8) | (red << 16);
|
|
return 0x000000 + rgb
|
|
}
|
|
|
|
static rgb2hex(red, green, blue) {
|
|
let rgb = blue | (green << 8) | (red << 16);
|
|
return '#' + (0x1000000 + rgb).toString(16).slice(1)
|
|
}
|
|
|
|
static hex2rgb(hex) {
|
|
// long version
|
|
let r = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
if (r) {
|
|
return r.slice(1, 4).map(x => {
|
|
return parseInt(x, 16)
|
|
})
|
|
}
|
|
// short version
|
|
r = hex.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
|
|
if (r) {
|
|
return r.slice(1, 4).map(x => {
|
|
return 0x11 * parseInt(x, 16)
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
|
|
static rgb(r, g, b) {
|
|
return { r, g, b }
|
|
}
|
|
|
|
static string2hex(str) {
|
|
return parseInt('0x' + str.slice(1))
|
|
}
|
|
|
|
static lerp(rgb1, rgb2, amount) {
|
|
return {
|
|
r: Math.round(lerp(rgb1.r, rgb2.r, amount)),
|
|
g: Math.round(lerp(rgb1.g, rgb2.g, amount)),
|
|
b: Math.round(lerp(rgb1.b, rgb2.b, amount))
|
|
}
|
|
}
|
|
|
|
static get violet() {
|
|
return Colors.rgb2num(89, 34, 131)
|
|
}
|
|
|
|
static get steelblue() {
|
|
return Colors.rgb2num(0, 130, 164)
|
|
}
|
|
|
|
static get ochre() {
|
|
return Colors.rgb2num(181, 157, 0)
|
|
}
|
|
|
|
static get turquoise() {
|
|
return Colors.rgb2num(34, 164, 131)
|
|
}
|
|
|
|
static get eminence() {
|
|
return Colors.rgb2num(150, 60, 134)
|
|
}
|
|
|
|
static random() {
|
|
let r = Math.round(Math.random() * 255);
|
|
let g = Math.round(Math.random() * 255);
|
|
let b = Math.round(Math.random() * 255);
|
|
return Colors.rgb2num(r, g, b)
|
|
}
|
|
}
|
|
|
|
class Cycle extends Array {
|
|
constructor(...items) {
|
|
super();
|
|
for (let item of items) {
|
|
this.push(item);
|
|
}
|
|
this.index = 0;
|
|
}
|
|
|
|
next() {
|
|
if (this.index == this.length) {
|
|
this.index = 0;
|
|
}
|
|
return this[this.index++]
|
|
}
|
|
|
|
current() {
|
|
if (this.index === this.length) {
|
|
this.index = 0;
|
|
}
|
|
return this[this.index]
|
|
}
|
|
}
|
|
|
|
/** Static methods to compute 2D points with x and y coordinates.
|
|
*/
|
|
class Points$1 {
|
|
static length(a) {
|
|
return Math.sqrt(a.x * a.x + a.y * a.y)
|
|
}
|
|
|
|
static normalize(p) {
|
|
let len = this.length(p);
|
|
return this.multiplyScalar(p, 1 / len)
|
|
}
|
|
|
|
static mean(a, b) {
|
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }
|
|
}
|
|
|
|
static subtract(a, b) {
|
|
return { x: a.x - b.x, y: a.y - b.y }
|
|
}
|
|
|
|
static multiply(a, b) {
|
|
return { x: a.x * b.x, y: a.y * b.y }
|
|
}
|
|
|
|
static divide(a, b) {
|
|
return { x: a.x / b.x, y: a.y / b.y }
|
|
}
|
|
|
|
static multiplyScalar(a, b) {
|
|
return { x: a.x * b, y: a.y * b }
|
|
}
|
|
|
|
static add(a, b) {
|
|
return { x: a.x + b.x, y: a.y + b.y }
|
|
}
|
|
|
|
static negate(p) {
|
|
return { x: -p.x, y: -p.y }
|
|
}
|
|
|
|
static angle(p1, p2) {
|
|
return Math.atan2(p1.y - p2.y, p1.x - p2.x)
|
|
}
|
|
|
|
static normalizedAngle(p1, p2) {
|
|
return Angle.normalize(this.angle(p1, p2))
|
|
}
|
|
|
|
static normalized2Angle(p1, p2) {
|
|
return Angle.normalize2(this.angle(p1, p2))
|
|
}
|
|
|
|
static arc(p, alpha, radius) {
|
|
return {
|
|
x: p.x + radius * Math.cos(alpha),
|
|
y: p.y + radius * Math.sin(alpha)
|
|
}
|
|
}
|
|
|
|
static distance(a, b) {
|
|
let dx = a.x - b.x;
|
|
let dy = a.y - b.y;
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
}
|
|
|
|
// Distance == 0.0 indicates an inside relation.
|
|
static distanceToRect(p, r) {
|
|
let dx = 0;
|
|
let dy = 0;
|
|
if (p.x < r.x) dx = p.x - r.x;
|
|
else if (p.x > r.x + r.width) dx = p.x - (r.x + r.width);
|
|
if (p.y < r.y) dy = p.y - r.y;
|
|
else if (p.y > r.y + r.height) dy = p.y - (r.y + r.height);
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
/* let cx = Math.max(Math.min(p.x, r.x + r.width), r.x)
|
|
let cy = Math.max(Math.min(p.y, r.y + r.height), r.y)
|
|
let result = Math.sqrt((p.x - cx) * (p.x - cx) + (p.y - cy) * (p.y - cy))
|
|
console.log("distanceToRect", p, r, result)
|
|
return result */
|
|
}
|
|
|
|
static fromPageToNode(element, p) {
|
|
// if (window.webkitConvertPointFromPageToNode) {
|
|
// return window.webkitConvertPointFromPageToNode(element,
|
|
// new WebKitPoint(p.x, p.y))
|
|
// }
|
|
return window.convertPointFromPageToNode(element, p.x, p.y)
|
|
}
|
|
|
|
static fromNodeToPage(element, p) {
|
|
// if (window.webkitConvertPointFromNodeToPage) {
|
|
// return window.webkitConvertPointFromNodeToPage(element,
|
|
// new WebKitPoint(p.x, p.y))
|
|
// }
|
|
return window.convertPointFromNodeToPage(element, p.x, p.y)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A helper class for common set operations.
|
|
*
|
|
* @export
|
|
* @class Sets
|
|
*/
|
|
class Sets {
|
|
/**
|
|
* Returns the intersection of all sets
|
|
* https://stackoverflow.com/questions/31930894/javascript-set-data-structure-intersect
|
|
* @static
|
|
* @param {*} sets
|
|
* @returns
|
|
* @memberof Sets
|
|
*/
|
|
static intersect(...sets) {
|
|
if (!sets.length) return new Set()
|
|
const i = sets.reduce((m, s, i) => (s.size < sets[m].size ? i : m), 0);
|
|
const [smallest] = sets.splice(i, 1);
|
|
const res = new Set();
|
|
for (let val of smallest) if (sets.every(s => s.has(val))) res.add(val);
|
|
return res
|
|
}
|
|
|
|
/**
|
|
* Returns the union of all sets
|
|
*
|
|
* @static
|
|
* @param {*} sets
|
|
* @returns
|
|
* @memberof Sets
|
|
*/
|
|
static union(...sets) {
|
|
let result = new Set();
|
|
for (let set of sets) {
|
|
for (let m of set) {
|
|
result.add(m);
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns the difference of the given sets. Starts with the first set and removing all elements of the following sets.
|
|
*
|
|
* @static
|
|
* @param {*} set
|
|
* @param {*} sets
|
|
* @returns
|
|
* @memberof Sets
|
|
*/
|
|
static difference(set, ...sets) {
|
|
let result = new Set();
|
|
for (let m of set) {
|
|
result.add(m);
|
|
}
|
|
for (let s of sets) {
|
|
for (let m of s) {
|
|
result.delete(m);
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
/** Static methods to compute angles.
|
|
*/
|
|
class Angle {
|
|
static normalize(angle) {
|
|
let TAU = Math.PI * 2.0;
|
|
while (angle > Math.PI) {
|
|
angle -= TAU;
|
|
}
|
|
while (angle < -Math.PI) {
|
|
angle += TAU;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalize2(angle) {
|
|
let TAU = Math.PI * 2.0;
|
|
while (angle > TAU) {
|
|
angle -= TAU;
|
|
}
|
|
while (angle < 0) {
|
|
angle += TAU;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalizeDegree(angle) {
|
|
let full = 360.0;
|
|
while (angle > 180.0) {
|
|
angle -= full;
|
|
}
|
|
while (angle < -180.0) {
|
|
angle += full;
|
|
}
|
|
return angle
|
|
}
|
|
|
|
static normalizedDiff(a, b) {
|
|
return this.normalize(this.diff(a, b))
|
|
}
|
|
|
|
static normalized2Diff(a, b) {
|
|
return this.normalize2(this.diff(a, b))
|
|
}
|
|
|
|
static diff(a, b) {
|
|
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
|
|
}
|
|
|
|
static degree2radian(degree) {
|
|
return (Math.PI * degree) / 180.0
|
|
}
|
|
|
|
static radian2degree(rad) {
|
|
return (180.0 / Math.PI) * rad
|
|
}
|
|
}
|
|
|
|
class Elements$1 {
|
|
static setStyle(element, styles) {
|
|
for (let key in styles) {
|
|
element.style[key] = styles[key];
|
|
}
|
|
}
|
|
|
|
static addClass(element, cssClass) {
|
|
element.classList.add(cssClass);
|
|
}
|
|
|
|
static removeClass(element, cssClass) {
|
|
element.classList.remove(cssClass);
|
|
}
|
|
|
|
static toggleClass(element, cssClass) {
|
|
element.classList.toggle(cssClass);
|
|
}
|
|
|
|
static hasClass(element, cssClass) {
|
|
return element.classList.contains(cssClass)
|
|
}
|
|
}
|
|
|
|
class MapProxy {
|
|
/* This class is needed if we want to use the interaction classes
|
|
in Firefox 45.8 and modern Browsers.
|
|
|
|
A workaround for https://github.com/babel/babel/issues/2334
|
|
*/
|
|
constructor() {
|
|
this.map = new Map();
|
|
}
|
|
|
|
get size() {
|
|
return this.map.size
|
|
}
|
|
|
|
get(key) {
|
|
return this.map.get(key)
|
|
}
|
|
|
|
set(key, value) {
|
|
return this.map.set(key, value)
|
|
}
|
|
|
|
delete(key) {
|
|
return this.map.delete(key)
|
|
}
|
|
|
|
clear() {
|
|
return this.map.clear()
|
|
}
|
|
|
|
has(key) {
|
|
return this.map.has(key)
|
|
}
|
|
|
|
keys() {
|
|
return this.map.keys()
|
|
}
|
|
|
|
values() {
|
|
return this.map.values()
|
|
}
|
|
|
|
entries() {
|
|
return this.map.entries()
|
|
}
|
|
|
|
forEach(func) {
|
|
this.map.forEach(func);
|
|
}
|
|
}
|
|
|
|
/* Based om https://gist.github.com/cwleonard/e124d63238bda7a3cbfa */
|
|
class Polygon {
|
|
/*
|
|
* This is the Polygon constructor. All points are center-relative.
|
|
*/
|
|
constructor(center) {
|
|
this.points = new Array();
|
|
this.center = center;
|
|
}
|
|
|
|
/*
|
|
* Point x and y values should be relative to the center.
|
|
*/
|
|
addPoint(p) {
|
|
this.points.push(p);
|
|
}
|
|
|
|
/*
|
|
* Point x and y values should be absolute coordinates.
|
|
*/
|
|
addAbsolutePoint(p) {
|
|
this.points.push({ x: p.x - this.center.x, y: p.y - this.center.y });
|
|
}
|
|
|
|
/*
|
|
* Returns the number of sides. Equal to the number of vertices.
|
|
*/
|
|
getNumberOfSides() {
|
|
return this.points.length
|
|
}
|
|
|
|
/*
|
|
* rotate the polygon by a number of radians
|
|
*/
|
|
rotate(rads) {
|
|
for (let i = 0; i < this.points.length; i++) {
|
|
let x = this.points[i].x;
|
|
let y = this.points[i].y;
|
|
this.points[i].x = Math.cos(rads) * x - Math.sin(rads) * y;
|
|
this.points[i].y = Math.sin(rads) * x + Math.cos(rads) * y;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The draw function takes as a parameter a Context object from
|
|
* a Canvas element and draws the polygon on it.
|
|
*/
|
|
draw(context, { lineWidth = 2, stroke = '#000000', fill = null } = {}) {
|
|
context.beginPath();
|
|
context.moveTo(this.points[0].x + this.center.x, this.points[0].y + this.center.y);
|
|
for (let i = 1; i < this.points.length; i++) {
|
|
context.lineTo(this.points[i].x + this.center.x, this.points[i].y + this.center.y);
|
|
}
|
|
context.closePath();
|
|
context.lineWidth = lineWidth;
|
|
if (stroke) {
|
|
context.strokeStyle = stroke;
|
|
context.stroke();
|
|
}
|
|
if (fill) {
|
|
context.fillStyle = fill;
|
|
context.fill();
|
|
}
|
|
}
|
|
|
|
absolutePoints() {
|
|
let result = new Array();
|
|
for (let p of this.points) {
|
|
result.push(Points$1.add(p, this.center));
|
|
}
|
|
return result
|
|
}
|
|
|
|
flatAbsolutePoints() {
|
|
let result = new Array();
|
|
for (let p of this.points) {
|
|
let a = Points$1.add(p, this.center);
|
|
result.push(a.x);
|
|
result.push(a.y);
|
|
}
|
|
return result
|
|
}
|
|
|
|
/*
|
|
* This function returns true if the given point is inside the polygon,
|
|
* and false otherwise.
|
|
*/
|
|
containsPoint(pnt) {
|
|
let nvert = this.points.length;
|
|
let testx = pnt.x;
|
|
let testy = pnt.y;
|
|
|
|
let vertx = new Array();
|
|
for (let q = 0; q < this.points.length; q++) {
|
|
vertx.push(this.points[q].x + this.center.x);
|
|
}
|
|
|
|
let verty = new Array();
|
|
for (let w = 0; w < this.points.length; w++) {
|
|
verty.push(this.points[w].y + this.center.y);
|
|
}
|
|
|
|
let i,
|
|
j = 0;
|
|
let c = false;
|
|
for (i = 0, j = nvert - 1; i < nvert; j = i++) {
|
|
if (
|
|
verty[i] > testy != verty[j] > testy &&
|
|
testx < ((vertx[j] - vertx[i]) * (testy - verty[i])) / (verty[j] - verty[i]) + vertx[i]
|
|
)
|
|
c = !c;
|
|
}
|
|
return c
|
|
}
|
|
|
|
multiplyScalar(scale) {
|
|
let center = Points$1.multiplyScalar(this.center, scale);
|
|
let clone = new Polygon(center);
|
|
for (let p of this.points) {
|
|
clone.addPoint(Points$1.multiplyScalar(p, scale));
|
|
}
|
|
return clone
|
|
}
|
|
|
|
/*
|
|
* To detect intersection with another Polygon object, this
|
|
* function uses the Separating Axis Theorem. It returns false
|
|
* if there is no intersection, or an object if there is. The object
|
|
* contains 2 fields, overlap and axis. Moving the polygon by overlap
|
|
* on axis will get the polygons out of intersection.
|
|
*/
|
|
intersectsWith(other) {
|
|
let axis = { x: 0, y: 0 };
|
|
let tmp, minA, maxA, minB, maxB;
|
|
let side, i;
|
|
let smallest = null;
|
|
let overlap = 99999999;
|
|
|
|
/* test polygon A's sides */
|
|
for (side = 0; side < this.getNumberOfSides(); side++) {
|
|
/* get the axis that we will project onto */
|
|
if (side == 0) {
|
|
axis.x = this.points[this.getNumberOfSides() - 1].y - this.points[0].y;
|
|
axis.y = this.points[0].x - this.points[this.getNumberOfSides() - 1].x;
|
|
} else {
|
|
axis.x = this.points[side - 1].y - this.points[side].y;
|
|
axis.y = this.points[side].x - this.points[side - 1].x;
|
|
}
|
|
|
|
/* normalize the axis */
|
|
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
|
|
axis.x /= tmp;
|
|
axis.y /= tmp;
|
|
|
|
/* project polygon A onto axis to determine the min/max */
|
|
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
|
|
for (i = 1; i < this.getNumberOfSides(); i++) {
|
|
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
|
|
if (tmp > maxA) maxA = tmp;
|
|
else if (tmp < minA) minA = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = this.center.x * axis.x + this.center.y * axis.y;
|
|
minA += tmp;
|
|
maxA += tmp;
|
|
|
|
/* project polygon B onto axis to determine the min/max */
|
|
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
|
|
for (i = 1; i < other.getNumberOfSides(); i++) {
|
|
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
|
|
if (tmp > maxB) maxB = tmp;
|
|
else if (tmp < minB) minB = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = other.center.x * axis.x + other.center.y * axis.y;
|
|
minB += tmp;
|
|
maxB += tmp;
|
|
|
|
/* test if lines intersect, if not, return false */
|
|
if (maxA < minB || minA > maxB) {
|
|
return false
|
|
} else {
|
|
let o = maxA > maxB ? maxB - minA : maxA - minB;
|
|
if (o < overlap) {
|
|
overlap = o;
|
|
smallest = { x: axis.x, y: axis.y };
|
|
}
|
|
}
|
|
}
|
|
|
|
/* test polygon B's sides */
|
|
for (side = 0; side < other.getNumberOfSides(); side++) {
|
|
/* get the axis that we will project onto */
|
|
if (side == 0) {
|
|
axis.x = other.points[other.getNumberOfSides() - 1].y - other.points[0].y;
|
|
axis.y = other.points[0].x - other.points[other.getNumberOfSides() - 1].x;
|
|
} else {
|
|
axis.x = other.points[side - 1].y - other.points[side].y;
|
|
axis.y = other.points[side].x - other.points[side - 1].x;
|
|
}
|
|
|
|
/* normalize the axis */
|
|
tmp = Math.sqrt(axis.x * axis.x + axis.y * axis.y);
|
|
axis.x /= tmp;
|
|
axis.y /= tmp;
|
|
|
|
/* project polygon A onto axis to determine the min/max */
|
|
minA = maxA = this.points[0].x * axis.x + this.points[0].y * axis.y;
|
|
for (i = 1; i < this.getNumberOfSides(); i++) {
|
|
tmp = this.points[i].x * axis.x + this.points[i].y * axis.y;
|
|
if (tmp > maxA) maxA = tmp;
|
|
else if (tmp < minA) minA = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = this.center.x * axis.x + this.center.y * axis.y;
|
|
minA += tmp;
|
|
maxA += tmp;
|
|
|
|
/* project polygon B onto axis to determine the min/max */
|
|
minB = maxB = other.points[0].x * axis.x + other.points[0].y * axis.y;
|
|
for (i = 1; i < other.getNumberOfSides(); i++) {
|
|
tmp = other.points[i].x * axis.x + other.points[i].y * axis.y;
|
|
if (tmp > maxB) maxB = tmp;
|
|
else if (tmp < minB) minB = tmp;
|
|
}
|
|
/* correct for offset */
|
|
tmp = other.center.x * axis.x + other.center.y * axis.y;
|
|
minB += tmp;
|
|
maxB += tmp;
|
|
|
|
/* test if lines intersect, if not, return false */
|
|
if (maxA < minB || minA > maxB) {
|
|
return false
|
|
} else {
|
|
let o = maxA > maxB ? maxB - minA : maxA - minB;
|
|
if (o < overlap) {
|
|
overlap = o;
|
|
smallest = { x: axis.x, y: axis.y };
|
|
}
|
|
}
|
|
}
|
|
return { overlap: overlap + 0.001, axis: smallest }
|
|
}
|
|
|
|
static fromPoints(points) {
|
|
let min = { x: Number.MAX_VALUE, y: Number.MAX_VALUE };
|
|
let max = { x: Number.MIN_VALUE, y: Number.MIN_VALUE };
|
|
for (let p of points) {
|
|
min.x = Math.min(p.x, min.x);
|
|
max.x = Math.max(p.x, max.x);
|
|
min.y = Math.min(p.y, min.y);
|
|
max.y = Math.max(p.y, max.y);
|
|
}
|
|
let center = Points$1.mean(min, max);
|
|
let polygon = new Polygon(center);
|
|
for (let p of points) {
|
|
polygon.addAbsolutePoint(p);
|
|
}
|
|
return polygon
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Util functions to deal with DOMRects.
|
|
*/
|
|
class Rect$1 {
|
|
/**
|
|
* Test if a given point is contained by the provided Rect.
|
|
*
|
|
* @static
|
|
* @param {DOMRect} rect - Rectangle to check the collision with.
|
|
* @param {Point} point - Point that should be tested.
|
|
* @returns {boolean} - True if point is inside of rect, otherwise false.
|
|
* @memberof Rect
|
|
*/
|
|
static contains(rect, point) {
|
|
return point.x > rect.left && point.x < rect.x + rect.right && point.y > rect.top && point.y < rect.bottom
|
|
}
|
|
|
|
/**
|
|
*Returns the position of an rect as point object.
|
|
*
|
|
* @static
|
|
* @param {Rect} rect - The rectangle we want to get the position from.
|
|
* @returns {Point} - Returns the position as Point.
|
|
* @memberof Rect
|
|
*/
|
|
static getPosition(rect) {
|
|
return { x: rect.x, y: rect.y }
|
|
}
|
|
}
|
|
|
|
/** String utility functions */
|
|
|
|
class Strings {
|
|
static toUpperCaseFirstChar(str) {
|
|
return str.substr(0, 1).toUpperCase() + str.substr(1)
|
|
}
|
|
|
|
static toLowerCaseFirstChar(str) {
|
|
return str.substr(0, 1).toLowerCase() + str.substr(1)
|
|
}
|
|
|
|
static toUpperCaseEachWord(str, delim = ' ') {
|
|
return str
|
|
.split(delim)
|
|
.map(v => v.toUpperCaseFirstChar())
|
|
.join(delim)
|
|
}
|
|
|
|
static toLowerCaseEachWord(str, delim = ' ') {
|
|
return str
|
|
.split(delim)
|
|
.map(v => v.toLowerCaseFirstChar())
|
|
.join(delim)
|
|
}
|
|
}
|
|
|
|
class LowPassFilter {
|
|
constructor(smoothing = 0.5, bufferMaxSize = 10) {
|
|
this.smoothing = smoothing; // must be smaller than 1
|
|
this.buffer = []; // FIFO queue
|
|
this.bufferMaxSize = bufferMaxSize;
|
|
}
|
|
|
|
/**
|
|
* Setup buffer with array of values
|
|
*
|
|
* @param {array} values
|
|
* @returns {array}
|
|
* @access public
|
|
*/
|
|
setup(values) {
|
|
for (let i = 0; i < values.length; i++) {
|
|
this.__push(values[i]);
|
|
}
|
|
return this.buffer
|
|
}
|
|
|
|
/**
|
|
* Clear buffer to prepare for new values.
|
|
*
|
|
* @access public
|
|
*/
|
|
clear() {
|
|
this.buffer = [];
|
|
}
|
|
|
|
/**
|
|
* Add new value to buffer (FIFO queue)
|
|
*
|
|
* @param {integer|float} value
|
|
* @returns {integer|float}
|
|
* @access private
|
|
*/
|
|
__push(value) {
|
|
let removed = this.buffer.length === this.bufferMaxSize ? this.buffer.shift() : 0;
|
|
|
|
this.buffer.push(value);
|
|
return removed
|
|
}
|
|
|
|
/**
|
|
* Smooth value from stream
|
|
*
|
|
* @param {integer|float} nextValue
|
|
* @returns {integer|float}
|
|
* @access public
|
|
*/
|
|
next(nextValue) {
|
|
// push new value to the end, and remove oldest one
|
|
let removed = this.__push(nextValue);
|
|
// smooth value using all values from buffer
|
|
let result = this.buffer.reduce((last, current) => {
|
|
return this.smoothing * current + (1 - this.smoothing) * last
|
|
}, removed);
|
|
// replace smoothed value
|
|
this.buffer[this.buffer.length - 1] = result;
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Smooth array of values
|
|
*
|
|
* @param {array} values
|
|
* @returns {undefined}
|
|
* @access public
|
|
*/
|
|
smoothArray(values) {
|
|
let value = values[0];
|
|
for (let i = 1; i < values.length; i++) {
|
|
let currentValue = values[i];
|
|
value += (currentValue - value) * this.smoothing;
|
|
values[i] = Math.round(value);
|
|
}
|
|
return values
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-undef */
|
|
/* eslint-disable no-console */
|
|
/* eslint-disable no-unused-vars */
|
|
let ipc = null;
|
|
let logMessages = new Set();
|
|
let logHandlers = {
|
|
log: console.log,
|
|
warn: console.warn,
|
|
error: console.error
|
|
};
|
|
|
|
try {
|
|
ipc = window.ipcRenderer || require('electron').ipcRenderer;
|
|
logHandlers.log = message => ipc.send('log', message);
|
|
logHandlers.warn = message => ipc.send('warn', message);
|
|
logHandlers.error = message => ipc.send('error', message);
|
|
} catch (e) {
|
|
console.log('Cannot use electron logging.');
|
|
}
|
|
|
|
/** Basic class for app specific logging requirements.
|
|
* Can be used to implement persistent logging in electron apps.
|
|
* Uses a logMessage cache to prevent error overflows. This is
|
|
* needed since errors may occur very frequently
|
|
* (e.g. display update loops at 60fps, programmatic loops, ...).
|
|
*
|
|
* The logging handlers can be overwritten by calling the static
|
|
* setup method.
|
|
*/
|
|
class Logging$1 {
|
|
/** Static log function.
|
|
* @param {*} message
|
|
*/
|
|
static log(message) {
|
|
logHandlers.log(message);
|
|
}
|
|
|
|
/**
|
|
* Static warn function.
|
|
* Emits each warning only once per session.
|
|
* @param {*} message
|
|
*/
|
|
static warn(message) {
|
|
if (!logMessages.has(message)) {
|
|
logMessages.add(message);
|
|
logHandlers.warn(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static error function.
|
|
* Emits each error message only once per session.
|
|
* @param {*} message
|
|
*/
|
|
static error(message) {
|
|
if (!logMessages.has(message)) {
|
|
logMessages.add(message);
|
|
logHandlers.error(message);
|
|
}
|
|
}
|
|
|
|
static setup({ log = console.log, warn = console.warn, error = console.error } = {}) {
|
|
logHandlers.log = log;
|
|
logHandlers.warn = warn;
|
|
logHandlers.error = error;
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/** Interaction patterns
|
|
|
|
See interaction.html for explanation
|
|
*/
|
|
|
|
class IInteractionTarget extends Interface {
|
|
capture(event) {
|
|
return typeof true
|
|
}
|
|
|
|
onStart(event, interaction) {}
|
|
onMove(event, interaction) {}
|
|
onEnd(event, interaction) {}
|
|
|
|
onMouseWheel(event) {}
|
|
}
|
|
|
|
class IInteractionMapperTarget extends Interface {
|
|
capture(event) {
|
|
return typeof true
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
return IInteractionTarget
|
|
}
|
|
}
|
|
|
|
class PointMap extends MapProxy {
|
|
// Collects touch points, mouse coordinates, etc. as key value pairs.
|
|
// Keys are pointer and touch ids, the special "mouse" key.
|
|
// Values are points, i.e. all objects with numeric x and y properties.
|
|
constructor(points = {}) {
|
|
super();
|
|
for (let key in points) {
|
|
this.set(key, points[key]);
|
|
}
|
|
}
|
|
|
|
toString() {
|
|
let points = [];
|
|
for (let key of this.keys()) {
|
|
let value = this.get(key);
|
|
points.push(`${key}:{x:${value.x}, y:${value.y}}`);
|
|
}
|
|
let attrs = points.join(', ');
|
|
return `[PointMap ${attrs}]`
|
|
}
|
|
|
|
clone() {
|
|
let result = new PointMap();
|
|
for (let key of this.keys()) {
|
|
let value = this.get(key);
|
|
result.set(key, { x: value.x, y: value.y });
|
|
}
|
|
return result
|
|
}
|
|
|
|
keyOf(value) {
|
|
for (let key of this.keys()) {
|
|
let p = this.get(key);
|
|
if (p.x == value.x && p.y == value.y) {
|
|
return key
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
firstKey() {
|
|
for (let key of this.keys()) {
|
|
return key
|
|
}
|
|
return null
|
|
}
|
|
|
|
first() {
|
|
for (let key of this.keys()) {
|
|
return this.get(key)
|
|
}
|
|
return null
|
|
}
|
|
|
|
farthests() {
|
|
if (this.size == 0) {
|
|
return null
|
|
}
|
|
let pairs = [];
|
|
for (let key of this.keys()) {
|
|
let p = this.get(key);
|
|
p.key = key;
|
|
for (let k of this.keys()) {
|
|
let q = this.get(k);
|
|
q.key = k;
|
|
pairs.push([p, q]);
|
|
}
|
|
}
|
|
let sorted = pairs.sort((a, b) => {
|
|
return Points$1.distance(b[0], b[1]) - Points$1.distance(a[0], a[1])
|
|
});
|
|
return sorted[0]
|
|
}
|
|
|
|
mean() {
|
|
if (this.size == 0) {
|
|
return null
|
|
}
|
|
let x = 0.0,
|
|
y = 0.0;
|
|
for (let p of this.values()) {
|
|
x += p.x;
|
|
y += p.y;
|
|
}
|
|
return { x: x / this.size, y: y / this.size }
|
|
}
|
|
}
|
|
|
|
class InteractionDelta {
|
|
/**
|
|
*Creates an instance of InteractionDelta.
|
|
* @param {*} x
|
|
* @param {*} y
|
|
* @param {*} zoom
|
|
* @param {*} rotate
|
|
* @param {*} about
|
|
* @param {*} number - number of involved pointer
|
|
* @param {*} distance - distance of farthests touch points
|
|
* @memberof InteractionDelta
|
|
*/
|
|
constructor(x, y, zoom, rotate, about, number, distance) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.zoom = zoom;
|
|
this.rotate = rotate;
|
|
this.about = about;
|
|
this.number = number;
|
|
this.distance = distance;
|
|
}
|
|
|
|
toString() {
|
|
let values = [];
|
|
for (let key of Object.keys(this)) {
|
|
let value = this[key];
|
|
if (key == 'about') {
|
|
values.push(`${key}:{x:${value.x}, y:${value.y}}`);
|
|
} else {
|
|
values.push(`${key}:${value}`);
|
|
}
|
|
}
|
|
let attrs = values.join(', ');
|
|
return `[InteractionDelta ${attrs}]`
|
|
}
|
|
}
|
|
|
|
class InteractionPoints {
|
|
constructor(parent = null) {
|
|
this.parent = parent;
|
|
this.current = new PointMap();
|
|
this.previous = new PointMap();
|
|
this.start = new PointMap();
|
|
this.ended = new PointMap();
|
|
this.timestamps = new Map();
|
|
}
|
|
|
|
moved(key) {
|
|
let current = this.current.get(key);
|
|
let previous = this.previous.get(key);
|
|
return Points$1.subtract(current, previous)
|
|
}
|
|
|
|
move() {
|
|
let current = this.current.mean();
|
|
let previous = this.previous.mean();
|
|
return Points$1.subtract(current, previous)
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between previous and current angles. Corrects
|
|
* value that are larger than 45°
|
|
* @param {*} a
|
|
* @param {*} b
|
|
* @returns delta
|
|
*/
|
|
diffAngle(a, b) {
|
|
let alpha = Math.atan2(Math.sin(a - b), Math.cos(a - b));
|
|
if (Math.abs(alpha) > Math.PI / 4) {
|
|
alpha -= Math.PI;
|
|
}
|
|
return alpha
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between interaction points at t and t+1.
|
|
*
|
|
* @returns InteractionDelta
|
|
* @memberof InteractionPoints
|
|
*/
|
|
delta() {
|
|
let prev = [];
|
|
let curr = [];
|
|
let cm = { x: 0, y: 0 };
|
|
let pm = { x: 0, y: 0 };
|
|
let count = 0;
|
|
for (let key of this.current.keys()) {
|
|
if (this.previous.has(key)) {
|
|
let p = this.previous.get(key);
|
|
let c = this.current.get(key);
|
|
pm = Points$1.add(pm, p);
|
|
cm = Points$1.add(cm, c);
|
|
prev.push(p);
|
|
curr.push(c);
|
|
count += 1;
|
|
}
|
|
}
|
|
if (count > 0) {
|
|
pm = Points$1.multiplyScalar(pm, 1 / count);
|
|
cm = Points$1.multiplyScalar(cm, 1 / count);
|
|
let delta = Points$1.subtract(cm, pm);
|
|
let scale = 0;
|
|
let scaled = 0;
|
|
let alpha = 0;
|
|
let zoom = 1;
|
|
for (let i = 0; i < count; i++) {
|
|
let p = prev[i];
|
|
let c = curr[i];
|
|
let previousAngle = Points$1.angle(p, pm);
|
|
let currentAngle = Points$1.angle(c, cm);
|
|
let diff = this.diffAngle(currentAngle, previousAngle);
|
|
alpha += diff;
|
|
|
|
let distance1 = Points$1.distance(p, pm);
|
|
let distance2 = Points$1.distance(c, cm);
|
|
if (distance1 != 0 && distance2 != 0) {
|
|
scale += distance2 / distance1;
|
|
scaled += 1;
|
|
}
|
|
}
|
|
if (scaled > 0) {
|
|
zoom = scale / scaled;
|
|
}
|
|
alpha /= count;
|
|
|
|
let current = this.current.farthests();
|
|
|
|
let c1 = current[0];
|
|
let c2 = current[1];
|
|
let distance2 = Points$1.distance(c1, c2);
|
|
|
|
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, count, distance2)
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes the delta between interaction points at t and t+1.
|
|
*
|
|
* @returns InteractionDelta
|
|
* @memberof InteractionPoints
|
|
*/
|
|
deltaByTwoFarthestsPoints() {
|
|
let csize = this.current.size;
|
|
let psize = this.previous.size;
|
|
if (csize >= 2 && csize == psize) {
|
|
// Reduce to the two farthests points
|
|
let current = this.current.farthests();
|
|
|
|
let c1 = current[0];
|
|
let c2 = current[1];
|
|
|
|
let p1 = this.previous.get(c1.key);
|
|
let p2 = this.previous.get(c2.key);
|
|
|
|
let d1 = Points$1.subtract(c1, p1);
|
|
let d2 = Points$1.subtract(c2, p2);
|
|
let cm = Points$1.mean(c1, c2);
|
|
|
|
// Using the mean leads to jumps between time slices with 3 and 2 fingers
|
|
// We use the mean of deltas instead
|
|
let delta = Points$1.mean(d1, d2);
|
|
let zoom = 1.0;
|
|
let distance1 = Points$1.distance(p1, p2);
|
|
let distance2 = Points$1.distance(c1, c2);
|
|
if (distance1 != 0 && distance2 != 0) {
|
|
zoom = distance2 / distance1;
|
|
}
|
|
let currentAngle = Points$1.angle(c1, c2);
|
|
let previousAngle = Points$1.angle(p1, p2);
|
|
let alpha = this.diffAngle(currentAngle, previousAngle);
|
|
return new InteractionDelta(delta.x, delta.y, zoom, alpha, cm, csize, distance2)
|
|
} else if (csize == 1 && psize == 1 && this.current.firstKey() == this.previous.firstKey()) {
|
|
// We need to ensure that the keys are the same, since single points with different keys
|
|
// can jump
|
|
let current = this.current.first();
|
|
let previous = this.previous.first();
|
|
let delta = Points$1.subtract(current, previous);
|
|
return new InteractionDelta(delta.x, delta.y, 1.0, 0.0, current, csize)
|
|
}
|
|
return null
|
|
}
|
|
|
|
started(key, point) {
|
|
this.current.set(key, point);
|
|
this.start.set(key, point);
|
|
this.previous.set(key, point);
|
|
this.timestamps.set(key, performance.now());
|
|
}
|
|
|
|
update(key, point) {
|
|
// Returns true iff the key is new
|
|
this.current.set(key, point);
|
|
if (!this.start.has(key)) {
|
|
this.start.set(key, point);
|
|
this.previous.set(key, point);
|
|
this.timestamps.set(key, performance.now());
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
updatePrevious() {
|
|
for (let key of this.current.keys()) {
|
|
this.previous.set(key, this.current.get(key));
|
|
}
|
|
}
|
|
|
|
stop(key, point) {
|
|
if (this.current.has(key)) {
|
|
this.current.delete(key);
|
|
this.previous.delete(key);
|
|
this.ended.set(key, point);
|
|
}
|
|
}
|
|
|
|
finish(key, point) {
|
|
this.current.delete(key);
|
|
this.previous.delete(key);
|
|
this.start.delete(key);
|
|
this.timestamps.delete(key);
|
|
this.ended.delete(key);
|
|
}
|
|
|
|
isFinished() {
|
|
return this.current.size == 0
|
|
}
|
|
|
|
isNoLongerTwoFinger() {
|
|
return this.previous.size > 1 && this.current.size < 2
|
|
}
|
|
|
|
isTap(key) {
|
|
return this.parent.isTap(key)
|
|
}
|
|
|
|
isDoubleTap(key) {
|
|
return this.parent.isDoubleTap(key)
|
|
}
|
|
|
|
isLongPress(key) {
|
|
return this.parent.isLongPress(key)
|
|
}
|
|
}
|
|
|
|
class Interaction extends InteractionPoints {
|
|
constructor(tapDistance = 10, tapDuration = 250.0, longPressTime = 500.0) {
|
|
super();
|
|
this.tapDistance = tapDistance;
|
|
this.tapCounts = new Map();
|
|
this.tapPositions = new Map();
|
|
this.tapTimestamps = new Map();
|
|
this.tapDuration = tapDuration;
|
|
this.longPressTime = longPressTime;
|
|
this.targets = new Map();
|
|
this.subInteractions = new Map(); // target:Object : InteractionPoints
|
|
}
|
|
|
|
stop(key, point) {
|
|
super.stop(key, point);
|
|
for (let points of this.subInteractions.values()) {
|
|
points.stop(key, point);
|
|
}
|
|
}
|
|
|
|
addTarget(key, target) {
|
|
this.targets.set(key, target);
|
|
this.subInteractions.set(target, new InteractionPoints(this));
|
|
}
|
|
|
|
removeTarget(key) {
|
|
let target = this.targets.get(key);
|
|
this.targets.delete(key);
|
|
// Only remove target if no keys are refering to the target
|
|
let remove = true;
|
|
for (let t of this.targets.values()) {
|
|
if (target === t) {
|
|
remove = false;
|
|
}
|
|
}
|
|
if (remove) {
|
|
this.subInteractions.delete(target);
|
|
}
|
|
}
|
|
|
|
finish(key, point) {
|
|
super.finish(key, point);
|
|
this.removeTarget(key);
|
|
}
|
|
|
|
mapInteraction(points, aspects, mappingFunc) {
|
|
// Map centrally registered points to target interactions
|
|
// Returns an array of [target, updated subInteraction] pairs
|
|
let result = new Map();
|
|
for (let key in points) {
|
|
if (this.targets.has(key)) {
|
|
let target = this.targets.get(key);
|
|
if (this.subInteractions.has(target)) {
|
|
let interaction = this.subInteractions.get(target);
|
|
for (let aspect of aspects) {
|
|
let pointMap = this[aspect];
|
|
let point = pointMap.get(key);
|
|
let mapped = mappingFunc(point, target);
|
|
interaction[aspect].set(key, mapped);
|
|
}
|
|
result.set(target, interaction);
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
registerTap(key, point) {
|
|
if (this.tapCounts.has(key)) {
|
|
let count = this.tapCounts.get(key);
|
|
this.tapCounts.set(key, count + 1);
|
|
} else {
|
|
this.tapCounts.set(key, 1);
|
|
}
|
|
this.tapPositions.set(key, point);
|
|
this.tapTimestamps.set(key, performance.now());
|
|
}
|
|
|
|
unregisterTap(key) {
|
|
this.tapCounts.delete(key);
|
|
this.tapPositions.delete(key);
|
|
this.tapTimestamps.delete(key);
|
|
}
|
|
|
|
isTap(key) {
|
|
let ended = this.ended.get(key);
|
|
let start = this.start.get(key);
|
|
if (start && ended && Points$1.distance(ended, start) < this.tapDistance) {
|
|
let t1 = this.timestamps.get(key);
|
|
let tookLong = performance.now() > t1 + this.longPressTime;
|
|
if (tookLong) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isDoubleTap(key) {
|
|
let ended = this.ended.get(key);
|
|
if (this.tapCounts.has(key) && this.tapCounts.get(key) > 2) {
|
|
this.unregisterTap(key);
|
|
}
|
|
if (this.tapPositions.has(key)) {
|
|
let pos = this.tapPositions.get(key);
|
|
if (Points$1.distance(ended, pos) > this.tapDistance) {
|
|
this.unregisterTap(key);
|
|
}
|
|
}
|
|
if (this.tapTimestamps.has(key) && performance.now() > this.tapTimestamps.get(key) + this.tapDuration) {
|
|
//console.log("tap too long")
|
|
this.unregisterTap(key);
|
|
}
|
|
let result = false;
|
|
if (this.isTap(key)) {
|
|
this.registerTap(key, ended);
|
|
result = this.tapCounts.get(key) == 2;
|
|
} else {
|
|
this.unregisterTap(key);
|
|
}
|
|
//console.log("isDoubleTap", this.tapCounts.get(key), result)
|
|
return result
|
|
}
|
|
|
|
isAnyTap() {
|
|
for (let key of this.ended.keys()) {
|
|
if (this.isTap(key)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isLongPress(key) {
|
|
let ended = this.ended.get(key);
|
|
let start = this.start.get(key);
|
|
if (start && ended && Points$1.distance(ended, start) < this.tapDistance) {
|
|
let t1 = this.timestamps.get(key);
|
|
let tookLong = performance.now() > t1 + this.longPressTime;
|
|
if (tookLong) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
return false
|
|
}
|
|
|
|
isAnyLongPress() {
|
|
for (let key of this.ended.keys()) {
|
|
if (this.isLongPress(key)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
isStylus(key) {
|
|
return key === 'stylus'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This class implements the main delegate functionality: All necessary event handlers are registered for the
|
|
* given element. Uses PointerEvents if available or TouchEvents on iOS. The fallback is on mouse events.
|
|
* Collects the events if the interaction target captures the start event (i.e. declares that
|
|
* the target wants the start event as well as all following move and end evcents.)
|
|
*
|
|
* @export
|
|
* @class InteractionDelegate
|
|
*/
|
|
class InteractionDelegate {
|
|
// Long press: http://stackoverflow.com/questions/1930895/how-long-is-the-event-onlongpress-in-the-android
|
|
// Stylus support: https://w3c.github.io/touch-events/
|
|
|
|
/**
|
|
* Creates an instance of InteractionDelegate.
|
|
* @param {any} element
|
|
* @param {any} target
|
|
* @param {any} [{ mouseWheelElement = null, useCapture = true, capturePointerEvents = true, debug = false }={}]
|
|
* @memberof InteractionDelegate
|
|
*/
|
|
constructor(
|
|
element,
|
|
target,
|
|
{
|
|
mouseWheelElement = null,
|
|
useCapture = true,
|
|
capturePointerEvents = true,
|
|
cancelOnWindowOut = true,
|
|
debug = false
|
|
} = {}
|
|
) {
|
|
this.debug = debug;
|
|
this.interaction = new Interaction();
|
|
this.element = element;
|
|
this.mouseWheelElement = mouseWheelElement || element;
|
|
this.target = target;
|
|
this.useCapture = useCapture;
|
|
this.capturePointerEvents = capturePointerEvents;
|
|
this.cancelOnWindowOut = cancelOnWindowOut;
|
|
this.setupInteraction();
|
|
}
|
|
|
|
setupInteraction() {
|
|
if (this.debug) {
|
|
let error = this.targetInterface.implementationError(this.target.constructor);
|
|
if (error != null) {
|
|
throw new Error('Expected IInteractionTarget: ' + error)
|
|
}
|
|
}
|
|
this.setupTouchInteraction();
|
|
this.setupMouseWheelInteraction();
|
|
}
|
|
|
|
get targetInterface() {
|
|
return IInteractionTarget
|
|
}
|
|
|
|
setupTouchInteraction() {
|
|
let element = this.element;
|
|
let useCapture = this.useCapture;
|
|
if (window.PointerEvent) {
|
|
if (this.debug) console.log('Pointer API' + window.PointerEvent);
|
|
element.addEventListener(
|
|
'pointerdown',
|
|
e => {
|
|
if (this.debug) console.log('pointerdown', e.pointerId);
|
|
if (this.capture(e)) {
|
|
if (this.capturePointerEvents) {
|
|
try {
|
|
element.setPointerCapture(e.pointerId);
|
|
} catch (e) {
|
|
console.warn('Cannot setPointerCapture');
|
|
}
|
|
}
|
|
this.onStart(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointermove',
|
|
e => {
|
|
if (this.debug) console.log('pointermove', e.pointerId, e.pointerType);
|
|
|
|
if (e.pointerType == 'touch' || (e.pointerType == 'mouse' && Events.isPointerDown(e))) {
|
|
// this.capture(e) &&
|
|
if (this.debug) console.log('pointermove captured', e.pointerId);
|
|
this.onMove(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointerup',
|
|
e => {
|
|
if (this.debug) console.log('pointerup', e.pointerId, e.pointerType);
|
|
this.onEnd(e);
|
|
if (this.capturePointerEvents) {
|
|
try {
|
|
element.releasePointerCapture(e.pointerId);
|
|
} catch (e) {
|
|
console.warn('Cannot release pointer');
|
|
}
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'pointercancel',
|
|
e => {
|
|
if (this.debug) console.log('pointercancel', e.pointerId, e.pointerType);
|
|
this.onEnd(e);
|
|
if (this.capturePointerEvents) element.releasePointerCapture(e.pointerId);
|
|
},
|
|
useCapture
|
|
);
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'pointerleave',
|
|
e => {
|
|
if (this.debug) console.log('pointerleave', e.pointerId, e.pointerType);
|
|
if (e.target == element) this.onEnd(e);
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'pointerout',
|
|
e => {
|
|
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType);
|
|
if (e.target == element) this.onEnd(e);
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
|
|
if (this.cancelOnWindowOut) {
|
|
window.addEventListener(
|
|
'pointerout',
|
|
e => {
|
|
if (this.debug) console.log('pointerout', e.pointerId, e.pointerType, e.target);
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
} else if (window.TouchEvent) {
|
|
if (this.debug) console.log('Touch API');
|
|
element.addEventListener(
|
|
'touchstart',
|
|
e => {
|
|
if (this.debug) console.log('touchstart', this.touchPoints(e));
|
|
if (this.capture(e)) {
|
|
for (let touch of e.changedTouches) {
|
|
this.onStart(touch);
|
|
}
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchmove',
|
|
e => {
|
|
if (this.debug) console.log('touchmove', this.touchPoints(e), e);
|
|
for (let touch of e.changedTouches) {
|
|
this.onMove(touch);
|
|
}
|
|
for (let touch of e.targetTouches) {
|
|
this.onMove(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchend',
|
|
e => {
|
|
if (this.debug) console.log('touchend', this.touchPoints(e));
|
|
for (let touch of e.changedTouches) {
|
|
this.onEnd(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'touchcancel',
|
|
e => {
|
|
if (this.debug) console.log('touchcancel', e.targetTouches.length, e.changedTouches.length);
|
|
for (let touch of e.changedTouches) {
|
|
this.onEnd(touch);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
} else {
|
|
if (this.debug) console.log('Mouse API');
|
|
|
|
element.addEventListener(
|
|
'mousedown',
|
|
e => {
|
|
if (this.debug) console.log('mousedown', e);
|
|
if (this.capture(e)) {
|
|
this.onStart(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'mousemove',
|
|
e => {
|
|
// Dow we only use move events if the mouse is down?
|
|
// HOver effects have to be implemented by other means
|
|
// && Events.isMouseDown(e))
|
|
|
|
if (Events.isMouseDown(e)) {
|
|
if (this.debug) console.log('mousemove', e);
|
|
this.onMove(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
element.addEventListener(
|
|
'mouseup',
|
|
e => {
|
|
if (this.debug) console.log('mouseup', e);
|
|
this.onEnd(e);
|
|
},
|
|
true
|
|
);
|
|
|
|
if (!this.capturePointerEvents) {
|
|
element.addEventListener(
|
|
'mouseout',
|
|
e => {
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
console.warn("Shouldn't happen: mouseout ends interaction");
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
if (this.cancelOnWindowOut) {
|
|
window.addEventListener(
|
|
'mouseout',
|
|
e => {
|
|
if (e.target == element) {
|
|
this.onEnd(e);
|
|
}
|
|
},
|
|
useCapture
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
isDescendant(parent, child) {
|
|
if (parent == child) return true
|
|
let node = child.parentNode;
|
|
while (node != null) {
|
|
if (node == parent) {
|
|
return true
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false
|
|
}
|
|
|
|
touchPoints(event) {
|
|
let result = [];
|
|
for (let touch of event.changedTouches) {
|
|
result.push(this.extractPoint(touch));
|
|
}
|
|
return result
|
|
}
|
|
|
|
setupMouseWheelInteraction() {
|
|
this.mouseWheelElement.addEventListener('mousewheel', this.onMouseWheel.bind(this), true);
|
|
this.mouseWheelElement.addEventListener('DOMMouseScroll', this.onMouseWheel.bind(this), true);
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (this.capture(event) && this.target.onMouseWheel) {
|
|
this.target.onMouseWheel(event);
|
|
}
|
|
}
|
|
|
|
onStart(event) {
|
|
let extracted = this.extractPoint(event);
|
|
this.startInteraction(event, extracted);
|
|
this.target.onStart(event, this.interaction);
|
|
}
|
|
|
|
onMove(event) {
|
|
let extracted = this.extractPoint(event, 'all');
|
|
this.updateInteraction(event, extracted);
|
|
this.target.onMove(event, this.interaction);
|
|
this.interaction.updatePrevious();
|
|
}
|
|
|
|
onEnd(event) {
|
|
let extracted = this.extractPoint(event, 'changedTouches');
|
|
this.endInteraction(event, extracted);
|
|
this.target.onEnd(event, this.interaction);
|
|
this.finishInteraction(event, extracted);
|
|
}
|
|
|
|
/**
|
|
* Asks the target whether the event should be captured
|
|
*
|
|
* @param {any} event
|
|
* @returns {bool}
|
|
* @memberof InteractionDelegate
|
|
*/
|
|
capture(event) {
|
|
if (Events.isCaptured(event)) {
|
|
return false
|
|
}
|
|
|
|
return this.target.capture ? this.target.capture(event) : false
|
|
}
|
|
|
|
getPosition(event) {
|
|
return { x: event.clientX, y: event.clientY }
|
|
}
|
|
|
|
extractPoint(event, touchEventKey = 'all') {
|
|
// 'targetTouches'
|
|
let result = {};
|
|
switch (event.constructor.name) {
|
|
case 'MouseEvent': {
|
|
let buttons = event.buttons || event.which;
|
|
if (buttons) result['mouse'] = this.getPosition(event);
|
|
break
|
|
}
|
|
case 'PointerEvent': {
|
|
result[event.pointerId.toString()] = this.getPosition(event);
|
|
break
|
|
}
|
|
case 'Touch': {
|
|
let id = event.touchType === 'stylus' ? 'stylus' : event.identifier.toString();
|
|
result[id] = this.getPosition(event);
|
|
break
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
interactionStarted(event, key, point) {
|
|
// Callback: can be overwritten
|
|
}
|
|
|
|
interactionEnded(event, key, point) {
|
|
// Callback: can be overwritten
|
|
}
|
|
|
|
interactionFinished(event, key, point) {}
|
|
|
|
startInteraction(event, extracted) {
|
|
for (let key in extracted) {
|
|
let point = extracted[key];
|
|
this.interaction.started(key, point);
|
|
this.interactionStarted(event, key, point);
|
|
}
|
|
}
|
|
|
|
updateInteraction(event, extracted) {
|
|
for (let key in extracted) {
|
|
let point = extracted[key];
|
|
let updated = this.interaction.update(key, point);
|
|
if (updated) {
|
|
console.warn("new pointer in updateInteraction shouldn't happen", key);
|
|
this.interactionStarted(event, key, point);
|
|
}
|
|
}
|
|
}
|
|
|
|
endInteraction(event, ended) {
|
|
for (let key in ended) {
|
|
let point = ended[key];
|
|
this.interaction.stop(key, point);
|
|
this.interactionEnded(event, key, point);
|
|
}
|
|
}
|
|
|
|
finishInteraction(event, ended) {
|
|
for (let key in ended) {
|
|
let point = ended[key];
|
|
this.interaction.finish(key, point);
|
|
this.interactionFinished(event, key, point);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* A special InteractionDelegate that maps events to specific parts of
|
|
* the interaction target. The InteractionTarget must implement a findTarget
|
|
* method that returns an object implementing the IInteractionTarget interface.
|
|
*
|
|
* If the InteractionTarget also implements a mapPositionToPoint method this
|
|
* is used to map the points to the local coordinate space of the the target.
|
|
*
|
|
* This makes it easier to lookup elements and relate events to local
|
|
* positions.
|
|
*
|
|
* @export
|
|
* @class InteractionMapper
|
|
* @extends {InteractionDelegate}
|
|
*/
|
|
class InteractionMapper$1 extends InteractionDelegate {
|
|
constructor(
|
|
element,
|
|
target,
|
|
{
|
|
tapDistance = 10,
|
|
longPressTime = 500.0,
|
|
useCapture = true,
|
|
capturePointerEvents = true,
|
|
mouseWheelElement = null,
|
|
logInteractionsAbove = 12
|
|
} = {}
|
|
) {
|
|
super(element, target, {
|
|
tapDistance,
|
|
useCapture,
|
|
capturePointerEvents,
|
|
longPressTime,
|
|
mouseWheelElement
|
|
});
|
|
this.logInteractionsAbove = logInteractionsAbove;
|
|
}
|
|
|
|
get targetInterface() {
|
|
return IInteractionMapperTarget
|
|
}
|
|
|
|
mapPositionToPoint(point, element = null) {
|
|
if (this.target.mapPositionToPoint) {
|
|
return this.target.mapPositionToPoint(point, element)
|
|
}
|
|
return point
|
|
}
|
|
|
|
interactionStarted(event, key, point) {
|
|
if (this.target.findTarget) {
|
|
let local = this.mapPositionToPoint(point);
|
|
let found = this.target.findTarget(event, local, point);
|
|
if (found != null) {
|
|
this.interaction.addTarget(key, found);
|
|
}
|
|
}
|
|
let size = this.interaction.current.size;
|
|
let limit = this.logInteractionsAbove;
|
|
if (size > limit) {
|
|
Logging$1.log(`Number of interactions ${size} exceeds ${limit}`);
|
|
}
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (this.capture(event)) {
|
|
if (this.target.findTarget) {
|
|
let point = this.getPosition(event);
|
|
let local = this.mapPositionToPoint(point);
|
|
let found = this.target.findTarget(event, local, point);
|
|
if (found != null && found.onMouseWheel) {
|
|
found.onMouseWheel(event);
|
|
return
|
|
}
|
|
}
|
|
if (this.target.onMouseWheel) {
|
|
this.target.onMouseWheel(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
onStart(event) {
|
|
let extracted = this.extractPoint(event);
|
|
this.startInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(
|
|
extracted,
|
|
['current', 'start'],
|
|
this.mapPositionToPoint.bind(this)
|
|
);
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onStart(event, interaction);
|
|
}
|
|
}
|
|
|
|
onMove(event) {
|
|
let extracted = this.extractPoint(event, 'all');
|
|
this.updateInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(
|
|
extracted,
|
|
['current', 'previous'],
|
|
this.mapPositionToPoint.bind(this)
|
|
);
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onMove(event, interaction);
|
|
interaction.updatePrevious();
|
|
}
|
|
this.interaction.updatePrevious();
|
|
}
|
|
|
|
onEnd(event) {
|
|
let extracted = this.extractPoint(event, 'changedTouches');
|
|
this.endInteraction(event, extracted);
|
|
let mapped = this.interaction.mapInteraction(extracted, ['ended'], this.mapPositionToPoint.bind(this));
|
|
for (let [target, interaction] of mapped.entries()) {
|
|
target.onEnd(event, interaction);
|
|
}
|
|
this.finishInteraction(event, extracted);
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @static
|
|
* @param {string|array} types - An event type, an array of event types or event types seperated by a space sign. The following
|
|
* events are possible:
|
|
* pan, panstart, panmove, panend, pancancel, panleft, panright, panup, pandown
|
|
* pinch, pinchstart, pinchmove, pinchend, pinchcancel, pinchin, pinchout
|
|
* press, pressup
|
|
* rotate, rotatestart, rotatemove, rotateend, rotatecancel
|
|
* swipe, swipeleft, swiperight, swipeup, swipedown
|
|
* tap
|
|
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
|
|
* @param {function} [cb] - The callback. A function which is executed after the event occurs. Receives the event object as the
|
|
* first paramter
|
|
* @param {object} [opts] - An options object. See the hammer documentation for more details.
|
|
*/
|
|
static on(types, elements, cb, opts = {}) {
|
|
opts = Object.assign({}, {}, opts);
|
|
|
|
if (typeof Hammer === 'undefined') {
|
|
console.error('Hammer.js not found!');
|
|
return this
|
|
}
|
|
|
|
if (typeof Hammer.__hammers === 'undefined') {
|
|
Hammer.__hammers = new Map();
|
|
}
|
|
|
|
// convert to array
|
|
types = Array.isArray(types) ? types : types.split(/\s/);
|
|
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
|
|
elements = Array.from(elements);
|
|
}
|
|
elements = Array.isArray(elements) ? elements : [elements];
|
|
|
|
for (let i = 0; i < types.length; i++) {
|
|
const type = types[i].toLowerCase();
|
|
|
|
// list of hammer events
|
|
const useHammer = /^(tap|doubletap|press|pan|swipe|pinch|rotate).*$/.test(type);
|
|
|
|
// if it is a hammer event
|
|
if (useHammer) {
|
|
for (let j = 0; j < elements.length; j++) {
|
|
// if(elements[j].tagName == "svg") return false;
|
|
|
|
let hammer = new Hammer(elements[j], opts);
|
|
|
|
if (window.propagating !== 'undefined') {
|
|
hammer = propagating(hammer);
|
|
}
|
|
|
|
// recognizers
|
|
if (type.startsWith('pan')) {
|
|
hammer.get('pan').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
|
|
} else if (type.startsWith('pinch')) {
|
|
hammer.get('pinch').set(Object.assign({ enable: true }, opts));
|
|
} else if (type.startsWith('press')) {
|
|
hammer.get('press').set(opts);
|
|
} else if (type.startsWith('rotate')) {
|
|
hammer.get('rotate').set(Object.assign({ enable: true }, opts));
|
|
} else if (type.startsWith('swipe')) {
|
|
hammer.get('swipe').set(Object.assign({ direction: Hammer.DIRECTION_ALL }, opts));
|
|
} else if (type.startsWith('tap')) {
|
|
hammer.get('tap').set(opts);
|
|
}
|
|
|
|
hammer.on(type, event => {
|
|
cb(event);
|
|
});
|
|
|
|
if (Hammer.__hammers.has(elements[j])) {
|
|
const elementHammers = Hammer.__hammers.get(elements[j]);
|
|
elementHammers.push(hammer);
|
|
Hammer.__hammers.set(elements[j], elementHammers);
|
|
} else {
|
|
Hammer.__hammers.set(elements[j], [hammer]);
|
|
}
|
|
}
|
|
} else {
|
|
for (let j = 0; j < elements.length; j++) {
|
|
Hammer.on(elements[j], type, event => {
|
|
cb(event);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*
|
|
* @static
|
|
* @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements.
|
|
*/
|
|
static off(elements) {
|
|
if (typeof Hammer === 'undefined') {
|
|
console.error('Hammer.js not found!');
|
|
return this
|
|
}
|
|
|
|
// convert to array
|
|
if (elements instanceof NodeList || elements instanceof HTMLCollection) {
|
|
elements = Array.from(elements);
|
|
}
|
|
elements = Array.isArray(elements) ? elements : [elements];
|
|
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
|
|
if (Hammer.__hammers.has(element)) {
|
|
const elementHammers = Hammer.__hammers.get(element);
|
|
elementHammers.forEach(it => it.destroy());
|
|
Hammer.__hammers.delete(element);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
window.InteractionMapper = InteractionMapper$1;
|
|
|
|
/** Report capabilities with guaranteed values.
|
|
*/
|
|
class Capabilities {
|
|
/** Returns the browser userAgent.
|
|
@return {string}
|
|
*/
|
|
static get userAgent() {
|
|
return navigator.userAgent || 'Unknown Agent'
|
|
}
|
|
|
|
/** Tests whether the app is running on a mobile device.
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isMobile() {
|
|
return /Mobi/.test(navigator.userAgent)
|
|
}
|
|
|
|
/** Tests whether the app is running on a iOS device.
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isIOS() {
|
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream
|
|
}
|
|
|
|
/** Tests whether the app is running in a Safari environment.
|
|
See https://stackoverflow.com/questions/7944460/detect-safari-browser
|
|
Implemented as a readonly attribute.
|
|
@return {boolean}
|
|
*/
|
|
static get isSafari() {
|
|
return (
|
|
navigator.vendor &&
|
|
navigator.vendor.indexOf('Apple') > -1 &&
|
|
navigator.userAgent &&
|
|
!navigator.userAgent.match('CriOS')
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Distincts if the app is running inside electron or not.
|
|
*
|
|
* source: https://github.com/cheton/is-electron
|
|
*/
|
|
static get isElectron() {
|
|
// Renderer process
|
|
if (typeof window !== 'undefined' && typeof window.process === 'object' && window.process.type === 'renderer') {
|
|
return true
|
|
}
|
|
|
|
// Main process
|
|
if (typeof process !== 'undefined' && typeof process.versions === 'object' && !!process.versions.electron) {
|
|
return true
|
|
}
|
|
|
|
// Detect the user agent when the `nodeIntegration` option is set to true
|
|
if (
|
|
typeof navigator === 'object' &&
|
|
typeof navigator.userAgent === 'string' &&
|
|
navigator.userAgent.indexOf('Electron') >= 0
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/** Returns the display resolution. Necessary for retina displays.
|
|
@return {number}
|
|
*/
|
|
static get devicePixelRatio() {
|
|
return window.devicePixelRatio || 1
|
|
}
|
|
|
|
/** Returns true if the device is a multi-touch table. This method is currently not universal usable and not sure!
|
|
@return {boolean}
|
|
*/
|
|
static get isMultiTouchTable() {
|
|
return (
|
|
Capabilities.devicePixelRatio > 2 &&
|
|
Capabilities.isMobile === false &&
|
|
/Windows/i.test(Capabilities.userAgent)
|
|
)
|
|
}
|
|
|
|
/** Returns true if mouse events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsMouseEvents() {
|
|
return typeof window.MouseEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if touch events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsTouchEvents() {
|
|
return typeof window.TouchEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if pointer events are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsPointerEvents() {
|
|
return typeof window.PointerEvent != 'undefined'
|
|
}
|
|
|
|
/** Returns true if DOM templates are supported
|
|
@return {boolean}
|
|
*/
|
|
static supportsTemplate() {
|
|
return 'content' in document.createElement('template')
|
|
}
|
|
}
|
|
|
|
/** Basic tests for Capabilities.
|
|
*/
|
|
class CapabilitiesTests {
|
|
static testConfirm() {
|
|
let bool = confirm('Please confirm');
|
|
document.getElementById('demo').innerHTML = bool ? 'Confirmed' : 'Not confirmed';
|
|
}
|
|
|
|
static testPrompt() {
|
|
let person = prompt('Please enter your name', 'Harry Potter');
|
|
if (person != null) {
|
|
demo.innerHTML = 'Hello ' + person + '! How are you today?';
|
|
}
|
|
}
|
|
|
|
static testUserAgent() {
|
|
let agent = 'User-agent: ' + Capabilities.userAgent;
|
|
user_agent.innerHTML = agent;
|
|
}
|
|
|
|
static testDevicePixelRatio() {
|
|
let value = 'Device Pixel Ratio: ' + Capabilities.devicePixelRatio;
|
|
device_pixel_ratio.innerHTML = value;
|
|
}
|
|
|
|
static testMultiTouchTable() {
|
|
let value = 'Is the device a multi-touch table? ' + Capabilities.isMultiTouchTable;
|
|
multi_touch_table.innerHTML = value;
|
|
}
|
|
|
|
static testSupportedEvents() {
|
|
let events = [];
|
|
if (Capabilities.supportsMouseEvents()) {
|
|
events.push('MouseEvents');
|
|
}
|
|
if (Capabilities.supportsTouchEvents()) {
|
|
events.push('TouchEvents');
|
|
}
|
|
if (Capabilities.supportsPointerEvents()) {
|
|
events.push('PointerEvents');
|
|
}
|
|
supported_events.innerHTML = 'Supported Events: ' + events.join(', ');
|
|
}
|
|
|
|
static testAll() {
|
|
this.testUserAgent();
|
|
this.testDevicePixelRatio();
|
|
this.testMultiTouchTable();
|
|
this.testSupportedEvents();
|
|
}
|
|
}
|
|
|
|
/* Optional global variables, needed in DocTests. */
|
|
window.Capabilities = Capabilities;
|
|
window.CapabilitiesTests = CapabilitiesTests;
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/** This interface allows scatters to delegate tap events to other objects. */
|
|
class ITapDelegate extends Interface {
|
|
/** This method must be defined by the delegate. It handles the tap event. */
|
|
tap(event) {}
|
|
|
|
/** Tells the delegate that it should handle standard click events. */
|
|
handleClicks() {}
|
|
}
|
|
|
|
/**
|
|
* A base class for scatter specific events.
|
|
*
|
|
* @constructor
|
|
* @param {name} String - The name of the event
|
|
* @param {target} Object - The target of the event
|
|
*/
|
|
class BaseEvent {
|
|
constructor(name, target) {
|
|
this.name = name;
|
|
this.target = target;
|
|
}
|
|
}
|
|
|
|
// Event types
|
|
const START = 'onStart';
|
|
const UPDATE = 'onUpdate';
|
|
const END = 'onEnd';
|
|
|
|
/**
|
|
* A scatter event that describes how the scatter has changed.
|
|
*
|
|
* @constructor
|
|
* @param {target} Object - The target scatter of the event
|
|
* @param {optional} Object - Optional parameter
|
|
*/
|
|
class ScatterEvent extends BaseEvent {
|
|
constructor(
|
|
target,
|
|
{ translate = { x: 0, y: 0 }, scale = null, rotate = 0, about = null, fast = false, type = null } = {}
|
|
) {
|
|
super('scatterTransformed', { target: target });
|
|
this.translate = translate;
|
|
this.scale = scale;
|
|
this.rotate = rotate;
|
|
this.about = about;
|
|
this.fast = fast;
|
|
this.type = type;
|
|
}
|
|
|
|
toString() {
|
|
return (
|
|
"Event('scatterTransformed', scale: " + this.scale + ' about: ' + this.about.x + ', ' + this.about.y + ')'
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A scatter resize event that describes how the scatter has changed.
|
|
*
|
|
* @constructor
|
|
* @param {target} Object - The target scatter of the event
|
|
* @param {optional} Object - Optional parameter
|
|
*/
|
|
class ResizeEvent extends BaseEvent {
|
|
constructor(target, { width = 0, height = 0 } = {}) {
|
|
super('scatterResized', { width: width, height: height });
|
|
this.width = width;
|
|
this.height = height;
|
|
}
|
|
|
|
toString() {
|
|
return 'Event(scatterResized width: ' + this.width + 'height: ' + this.height + ')'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A abstract base class that implements the throwable behavior of a scatter
|
|
* object.
|
|
*
|
|
* @constructor
|
|
*/
|
|
class Throwable {
|
|
constructor({
|
|
movableX = true,
|
|
movableY = true,
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
autoThrow = true,
|
|
onThrowFinished = null
|
|
} = {}) {
|
|
this.movableX = movableX;
|
|
this.movableY = movableY;
|
|
this.throwVisibility = throwVisibility;
|
|
this.throwDamping = throwDamping;
|
|
this.autoThrow = autoThrow;
|
|
this.velocities = [];
|
|
this.velocity = null;
|
|
this.timestamp = null;
|
|
this.onThrowFinished = onThrowFinished;
|
|
//console.log("onThrowFinished", onThrowFinished)
|
|
}
|
|
|
|
observeVelocity() {
|
|
this.lastframe = performance.now();
|
|
}
|
|
|
|
addVelocity(delta, buffer = 5) {
|
|
let t = performance.now();
|
|
let dt = t - this.lastframe;
|
|
this.lastframe = t;
|
|
|
|
// When number is not set or is zero. Use one instead.
|
|
let number = delta.number ? delta.number : 1;
|
|
if (dt > 0) {
|
|
// Avoid division by zero errors later on
|
|
// and consider the number of involved pointers sind addVelocity will be called by the
|
|
// onMove events
|
|
let velocity = {
|
|
t: t,
|
|
dt: dt,
|
|
dx: delta.x / number,
|
|
dy: delta.y / number
|
|
};
|
|
this.velocities.push(velocity);
|
|
while (this.velocities.length > buffer) {
|
|
this.velocities.shift();
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
meanVelocity(milliseconds = 30) {
|
|
this.addVelocity({ x: 0, y: 0, number: 1 });
|
|
let sum = { x: 0, y: 0 };
|
|
let count = 0;
|
|
let t = 0;
|
|
for (let i = this.velocities.length - 1; i > 0; i--) {
|
|
let v = this.velocities[i];
|
|
t += v.dt;
|
|
let nv = { x: v.dx / v.dt, y: v.dy / v.dt };
|
|
sum = Points$1.add(sum, nv);
|
|
count += 1;
|
|
if (t > milliseconds) {
|
|
break
|
|
}
|
|
}
|
|
if (count === 0) return sum // empty vector
|
|
return Points$1.multiplyScalar(sum, 1 / count)
|
|
}
|
|
|
|
killAnimation() {
|
|
this.velocity = null;
|
|
this.velocities = [];
|
|
}
|
|
|
|
startThrow() {
|
|
this.velocity = this.meanVelocity();
|
|
if (this.velocity != null) {
|
|
// Call next velocity to ansure that specializations
|
|
// that use keepOnStage are called
|
|
this.velocity = this.nextVelocity(this.velocity);
|
|
if (this.autoThrow) this.animateThrow(performance.now());
|
|
} else {
|
|
this.onDragComplete();
|
|
}
|
|
}
|
|
|
|
_throwDeltaTime() {
|
|
let t = performance.now();
|
|
let dt = t - this.lastframe;
|
|
this.lastframe = t;
|
|
return dt
|
|
}
|
|
|
|
animateThrow(time) {
|
|
if (this.velocity != null) {
|
|
let dt = this._throwDeltaTime();
|
|
// console.log("animateThrow", dt)
|
|
let next = this.nextVelocity(this.velocity);
|
|
let prevLength = Points$1.length(this.velocity);
|
|
let nextLength = Points$1.length(next);
|
|
if (nextLength > prevLength) {
|
|
let factor = nextLength / prevLength;
|
|
next = Points$1.multiplyScalar(next, 1 / factor);
|
|
console.log('Prevent acceleration', factor, this.velocity, next);
|
|
}
|
|
this.velocity = next;
|
|
let d = Points$1.multiplyScalar(this.velocity, dt);
|
|
this._move(d);
|
|
|
|
this.onDragUpdate(d);
|
|
if (dt == 0 || this.needsAnimation()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
} else {
|
|
if (this.isOutside()) {
|
|
requestAnimationFrame(this.animateThrow.bind(this));
|
|
return
|
|
}
|
|
}
|
|
}
|
|
this.onDragComplete();
|
|
if (this.onThrowFinished != null) {
|
|
this.onThrowFinished();
|
|
}
|
|
}
|
|
|
|
needsAnimation() {
|
|
if (this.velocity == null) {
|
|
return false
|
|
}
|
|
return Points$1.length(this.velocity) > 0.01
|
|
}
|
|
|
|
nextVelocity(velocity) {
|
|
// Must be overwritten: computes the changed velocity. Implement
|
|
// damping, collison detection, etc. here
|
|
let next = Points$1.multiplyScalar(velocity, this.throwDamping);
|
|
return {
|
|
x: this.movableX ? next.x : 0,
|
|
y: this.movableY ? next.y : 0
|
|
}
|
|
}
|
|
|
|
_move(delta) {
|
|
// Overwrite if necessary
|
|
}
|
|
|
|
onDragComplete() {
|
|
// Overwrite if necessary
|
|
}
|
|
|
|
onDragUpdate(delta) {
|
|
// Overwrite if necessary
|
|
}
|
|
}
|
|
|
|
class AbstractScatter extends Throwable {
|
|
constructor({
|
|
minScale = 0.1,
|
|
maxScale = 1.0,
|
|
startScale = 1.0,
|
|
autoBringToFront = true,
|
|
autoThrow = true,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
resizable = false,
|
|
movableX = true,
|
|
movableY = true,
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
overdoScaling = 1,
|
|
mouseZoomFactor = 1.1,
|
|
rotationDegrees = null,
|
|
rotation = null,
|
|
onTransform = null,
|
|
interactive = true,
|
|
onClose = null,
|
|
onThrowFinished = null,
|
|
scaleAutoClose = false,
|
|
scaleCloseThreshold = 0.1,
|
|
scaleCloseBuffer = 0.05,
|
|
maxRotation = Angle.degree2radian(5),
|
|
minInteractionDistance = 0,
|
|
useLowPassFilter = false
|
|
} = {}) {
|
|
if (rotationDegrees != null && rotation != null) {
|
|
throw new Error('Use rotationDegrees or rotation but not both')
|
|
} else if (rotation != null) {
|
|
rotationDegrees = Angle.radian2degree(rotation);
|
|
} else if (rotationDegrees == null) {
|
|
rotationDegrees = 0;
|
|
}
|
|
super({
|
|
movableX,
|
|
movableY,
|
|
throwVisibility,
|
|
throwDamping,
|
|
autoThrow,
|
|
onThrowFinished
|
|
});
|
|
|
|
/**
|
|
* Closes the card when the minScale is reached and the
|
|
* card is released. Card can be saved by scaling it up again.
|
|
*/
|
|
this.scaleAutoClose = scaleAutoClose;
|
|
this.scaleCloseThreshold = scaleCloseThreshold;
|
|
this.scaleCloseBuffer = scaleCloseBuffer;
|
|
this.scaleAutoCloseTimeout = null;
|
|
|
|
this.interactive = interactive;
|
|
this.startRotationDegrees = rotationDegrees;
|
|
this.startScale = startScale; // Needed to reset object
|
|
this.minScale = minScale;
|
|
this.maxScale = maxScale;
|
|
this.maxRotation = maxRotation;
|
|
this.overdoScaling = overdoScaling;
|
|
this.translatable = translatable;
|
|
if (!translatable) {
|
|
this.movableX = false;
|
|
this.movableY = false;
|
|
}
|
|
this.scalable = scalable;
|
|
this.rotatable = rotatable;
|
|
this.resizable = resizable;
|
|
this.mouseZoomFactor = mouseZoomFactor;
|
|
this.autoBringToFront = autoBringToFront;
|
|
this.useLowPassFilter = useLowPassFilter;
|
|
this.minInteractionDistance = minInteractionDistance;
|
|
if (useLowPassFilter) {
|
|
this.rotateLPF = new LowPassFilter();
|
|
this.zoomLPF = new LowPassFilter();
|
|
this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
}
|
|
this.dragging = false;
|
|
this.onTransform = onTransform != null ? [onTransform] : null;
|
|
this.onClose = onClose != null ? [onClose] : null;
|
|
}
|
|
|
|
addCloseEventCallback(callback) {
|
|
if (this.onClose == null) {
|
|
this.onClose = [];
|
|
}
|
|
this.onClose.push(callback);
|
|
}
|
|
|
|
addTransformEventCallback(callback) {
|
|
if (this.onTransform == null) {
|
|
this.onTransform = [];
|
|
}
|
|
this.onTransform.push(callback);
|
|
}
|
|
|
|
startGesture(interaction) {
|
|
this.bringToFront();
|
|
this.killAnimation();
|
|
this.observeVelocity();
|
|
if (this.useLowPassFilter) {
|
|
this.rotateLPF.clear();
|
|
this.zoomLPF.clear();
|
|
this.zoomLPF.setup([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
}
|
|
return true
|
|
}
|
|
|
|
close() {
|
|
this._callCloseCallbacks();
|
|
this._removeCallbacks();
|
|
this._removeSelfFromScatterContainer();
|
|
}
|
|
|
|
_callCloseCallbacks() {
|
|
if (this.onClose) {
|
|
this.onClose.forEach(callback => callback(this));
|
|
}
|
|
}
|
|
|
|
_removeCallbacks() {
|
|
this.onClose = [];
|
|
this.onTransform = [];
|
|
}
|
|
|
|
_removeSelfFromScatterContainer() {
|
|
// Removes self from container when it's closed.
|
|
if (this.container) {
|
|
this.container.remove(this);
|
|
}
|
|
}
|
|
|
|
gesture(interaction) {
|
|
let delta = interaction.delta();
|
|
|
|
if (delta != null) {
|
|
this.addVelocity(delta); // uo: reverted commit fa0256d782dd498c6d3e51321260ca375ca9f855
|
|
let rotate = delta.rotate;
|
|
let zoom = delta.zoom;
|
|
if (this.maxRotation != null) {
|
|
if (Math.abs(rotate) > this.maxRotation) {
|
|
rotate = 0;
|
|
}
|
|
}
|
|
if (this.useLowPassFilter) {
|
|
rotate = this.rotateLPF.next(rotate);
|
|
zoom = this.zoomLPF.next(zoom);
|
|
}
|
|
if (delta.distance < this.minInteractionDistance) {
|
|
let ratio = delta.distance / this.minInteractionDistance;
|
|
rotate *= ratio;
|
|
let zoomDelta = zoom - 1;
|
|
zoomDelta *= ratio;
|
|
zoom = 1 + zoomDelta;
|
|
}
|
|
|
|
this.transform(delta, zoom, rotate, delta.about);
|
|
if (zoom != 1) this.interactionAnchor = delta.about;
|
|
}
|
|
}
|
|
|
|
get polygon() {
|
|
let w2 = (this.width * this.scale) / 2;
|
|
let h2 = (this.height * this.scale) / 2;
|
|
let center = this.center;
|
|
let polygon = new Polygon(center);
|
|
polygon.addPoint({ x: -w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: h2 });
|
|
polygon.addPoint({ x: -w2, y: h2 });
|
|
polygon.rotate(this.rotation);
|
|
return polygon
|
|
}
|
|
|
|
isOutside() {
|
|
let stagePolygon = this.containerPolygon;
|
|
if (stagePolygon == null) return false
|
|
let polygon = this.polygon;
|
|
if (polygon == null) return false
|
|
let result = stagePolygon.intersectsWith(polygon);
|
|
return result === false || result.overlap < this.throwVisibility
|
|
}
|
|
|
|
recenter() {
|
|
// Return a small vector that guarantees that the scatter is moving
|
|
// towards the center of the stage
|
|
let center = this.center;
|
|
let target = this.container.center;
|
|
let delta = Points$1.subtract(target, center);
|
|
return Points$1.normalize(delta)
|
|
}
|
|
|
|
nextVelocity(velocity) {
|
|
return this.keepOnStage(velocity)
|
|
}
|
|
|
|
bouncing() {
|
|
// Implements the bouncing behavior of the scatter. Moves the scatter
|
|
// to the center of the stage if the scatter is outside the stage or
|
|
// not within the limits of the throwVisibility.
|
|
|
|
let stagePolygon = this.containerPolygon;
|
|
let polygon = this.polygon;
|
|
let result = stagePolygon.intersectsWith(polygon);
|
|
if (result === false || result.overlap < this.throwVisibility) {
|
|
let cv = this.recenter();
|
|
let recentered = false;
|
|
while (result === false || result.overlap < this.throwVisibility) {
|
|
polygon.center.x += cv.x;
|
|
polygon.center.y += cv.y;
|
|
this._move(cv);
|
|
result = stagePolygon.intersectsWith(polygon);
|
|
recentered = true;
|
|
}
|
|
return recentered
|
|
}
|
|
return false
|
|
}
|
|
|
|
keepOnStage(velocity, collision = 0.5) {
|
|
let stagePolygon = this.containerPolygon;
|
|
// UO: since keepOnStage is called in nextVelocity we need to
|
|
// ensure a return value
|
|
if (!stagePolygon) return { x: 0, y: 0 }
|
|
let polygon = this.polygon;
|
|
let bounced = this.bouncing();
|
|
if (bounced) {
|
|
let stage = this.containerBounds;
|
|
let x = this.center.x;
|
|
let y = this.center.y;
|
|
let dx = this.movableX ? velocity.x : 0;
|
|
let dy = this.movableY ? velocity.y : 0;
|
|
let factor = this.throwDamping;
|
|
// if (recentered) {
|
|
if (x < 0) {
|
|
dx = -dx;
|
|
factor = collision;
|
|
}
|
|
if (x > stage.width) {
|
|
dx = -dx;
|
|
factor = collision;
|
|
}
|
|
if (y < 0) {
|
|
dy = -dy;
|
|
factor = collision;
|
|
}
|
|
if (y > stage.height) {
|
|
dy = -dy;
|
|
factor = collision;
|
|
}
|
|
// }
|
|
return Points$1.multiplyScalar({ x: dx, y: dy }, factor)
|
|
}
|
|
return super.nextVelocity(velocity)
|
|
}
|
|
|
|
endGesture(interaction) {
|
|
this.startThrow();
|
|
this._checkAutoClose();
|
|
}
|
|
|
|
_checkAutoClose() {
|
|
if (this.scaleAutoClose)
|
|
if (this.scale < this.minScale + this.scaleCloseThreshold - this.scaleCloseBuffer) {
|
|
this.zoom(this.minScale, {
|
|
animate: 0.2,
|
|
onComplete: this.close.bind(this)
|
|
});
|
|
} else if (this.scale < this.minScale + this.scaleCloseThreshold) {
|
|
this.zoom(this.minScale + this.scaleCloseThreshold, {
|
|
animate: 0.4
|
|
});
|
|
}
|
|
}
|
|
|
|
rotateDegrees(degrees, anchor) {
|
|
let rad = Angle.degree2radian(degrees);
|
|
this.rotate(rad, anchor);
|
|
}
|
|
|
|
rotate(rad, anchor) {
|
|
this.transform({ x: 0, y: 0 }, 1.0, rad, anchor);
|
|
}
|
|
|
|
move(d, { animate = 0 } = {}) {
|
|
if (this.translatable) {
|
|
if (animate > 0) {
|
|
let startPos = this.position;
|
|
TweenLite.to(this, animate, {
|
|
x: '+=' + d.x,
|
|
y: '+=' + d.y,
|
|
/* scale: scale, uo: not defined, why was this here? */
|
|
onUpdate: e => {
|
|
let p = this.position;
|
|
let dx = p.x - startPos.x;
|
|
let dy = p.x - startPos.y;
|
|
this.onMoved(dx, dy);
|
|
}
|
|
});
|
|
} else {
|
|
this._move(d);
|
|
this.onMoved(d.x, d.y);
|
|
}
|
|
}
|
|
}
|
|
|
|
moveTo(p, { animate = 0 } = {}) {
|
|
let c = this.origin;
|
|
let delta = Points$1.subtract(p, c);
|
|
this.move(delta, { animate: animate });
|
|
}
|
|
|
|
centerAt(p, { animate = 0 } = {}) {
|
|
let c = this.center;
|
|
let delta = Points$1.subtract(p, c);
|
|
this.move(delta, { animate: animate });
|
|
}
|
|
|
|
zoom(scale, { animate = 0, about = null, delay = 0, x = null, y = null, onComplete = null } = {}) {
|
|
let anchor = about || this.center;
|
|
if (scale != this.scale) {
|
|
if (animate > 0) {
|
|
TweenLite.to(this, animate, {
|
|
scale: scale,
|
|
delay: delay,
|
|
onComplete: onComplete,
|
|
onUpdate: this.onZoomed.bind(this)
|
|
});
|
|
} else {
|
|
this.scale = scale;
|
|
this.onZoomed(anchor);
|
|
}
|
|
}
|
|
}
|
|
|
|
_move(delta) {
|
|
// this.addVelocity(delta) uo: reverted commit fa0256d782dd498c6d3e51321260ca375ca9f855
|
|
this.x += this.movableX ? delta.x : 0;
|
|
this.y += this.movableX ? delta.y : 0;
|
|
}
|
|
|
|
transform(translate, zoom, rotate, anchor) {
|
|
let delta = {
|
|
x: this.movableX ? translate.x : 0,
|
|
y: this.movableY ? translate.y : 0
|
|
};
|
|
if (this.resizable) var vzoom = zoom;
|
|
if (!this.translatable) delta = { x: 0, y: 0 };
|
|
if (!this.rotatable) rotate = 0;
|
|
if (!this.scalable) zoom = 1.0;
|
|
if (zoom == 1.0 && rotate == 0) {
|
|
this._move(delta);
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: delta,
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: anchor,
|
|
fast: false,
|
|
type: UPDATE
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
return
|
|
}
|
|
let origin = this.rotationOrigin;
|
|
let beta = Points$1.angle(origin, anchor);
|
|
let distance = Points$1.distance(origin, anchor);
|
|
let { scale: newScale, zoom: thresholdedZoom } = this.calculateScale(zoom);
|
|
|
|
let newOrigin = Points$1.arc(anchor, beta + rotate, distance * thresholdedZoom);
|
|
let extra = Points$1.subtract(newOrigin, origin);
|
|
const anchorOffset = Points$1.subtract(anchor, origin);
|
|
// this._move(offset)
|
|
this.scale = newScale;
|
|
this.rotation += rotate;
|
|
let offset = Points$1.negate(anchorOffset);
|
|
offset = Points$1.add(offset, extra);
|
|
offset = Points$1.add(offset, translate);
|
|
this._move(Points$1.add(anchorOffset, offset));
|
|
|
|
delta.x += extra.x;
|
|
delta.y += extra.y;
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: delta,
|
|
scale: newScale,
|
|
rotate: rotate,
|
|
about: anchor
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
if (this.resizable) {
|
|
this.resizeAfterTransform(vzoom);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For a given zoom, a new scale is calculated, taking
|
|
* min and max scale into account.
|
|
*
|
|
* @param {number} zoom - The zoom factor, to scale the object with.
|
|
* @returns {object} - Returns an object containing the a value for a valid scale and the corrected zoom factor.
|
|
*/
|
|
calculateScale(zoom) {
|
|
let scale = this.scale * zoom;
|
|
|
|
let minScale = this.minScale / this.overdoScaling;
|
|
let maxScale = this.maxScale * this.overdoScaling;
|
|
if (scale < minScale) {
|
|
scale = minScale;
|
|
zoom = scale / this.scale;
|
|
}
|
|
if (scale > maxScale) {
|
|
scale = maxScale;
|
|
zoom = scale / this.scale;
|
|
}
|
|
|
|
if (this.scaleAutoClose) this._updateTransparency();
|
|
|
|
return { zoom, scale }
|
|
}
|
|
|
|
_updateTransparency() {
|
|
if (this.scale < this.minScale + this.scaleCloseThreshold) {
|
|
let transparency = this.calculateScaleTransparency();
|
|
this.element.style.opacity = transparency;
|
|
} else this.element.style.opacity = 1;
|
|
}
|
|
|
|
calculateScaleTransparency() {
|
|
let transparency = (this.scale - this.minScale) / this.scaleCloseThreshold;
|
|
transparency = transparency > 1 ? 1 : transparency < 0 ? 0 : transparency;
|
|
return transparency
|
|
}
|
|
|
|
resizeAfterTransform(zoom) {
|
|
// Overwrite this in subclasses.
|
|
}
|
|
|
|
validScale(scale) {
|
|
scale = Math.max(scale, this.minScale);
|
|
scale = Math.min(scale, this.maxScale);
|
|
return scale
|
|
}
|
|
|
|
animateZoomBounce(dt = 1) {
|
|
if (this.zoomAnchor != null) {
|
|
let zoom = 1;
|
|
let amount = Math.min(0.01, (0.3 * dt) / 100000.0);
|
|
if (this.scale < this.minScale) zoom = 1 + amount;
|
|
if (this.scale > this.maxScale) zoom = 1 - amount;
|
|
if (zoom != 1) {
|
|
this.transform({ x: 0, y: 0 }, zoom, 0, this.zoomAnchor);
|
|
requestAnimationFrame(dt => {
|
|
this.animateZoomBounce(dt);
|
|
});
|
|
return
|
|
}
|
|
this.zoomAnchor = null;
|
|
}
|
|
}
|
|
|
|
checkScaling(about, delay = 0) {
|
|
this.zoomAnchor = about;
|
|
clearTimeout(this.animateZoomBounce.bind(this));
|
|
setTimeout(this.animateZoomBounce.bind(this), delay);
|
|
}
|
|
|
|
onMouseWheel(event) {
|
|
if (event.claimedByScatter) {
|
|
if (event.claimedByScatter != this) return
|
|
}
|
|
this.killAnimation();
|
|
this.targetScale = null;
|
|
let direction = event.detail < 0 || event.wheelDelta > 0;
|
|
let globalPoint = { x: event.clientX, y: event.clientY };
|
|
let centerPoint = this.mapPositionToContainerPoint(globalPoint);
|
|
if (event.shiftKey) {
|
|
let degrees = direction ? 5 : -5;
|
|
let rad = Angle.degree2radian(degrees);
|
|
return this.transform({ x: 0, y: 0 }, 1.0, rad, centerPoint)
|
|
}
|
|
const zoomFactor = this.mouseZoomFactor;
|
|
let zoom = direction ? zoomFactor : 1 / zoomFactor;
|
|
this.transform({ x: 0, y: 0 }, zoom, 0, centerPoint);
|
|
this.checkScaling(centerPoint, 200);
|
|
|
|
if (this.scaleAutoClose) {
|
|
if (this.scale <= this.minScale + this.scaleCloseThreshold) {
|
|
if (this.scaleAutoCloseTimeout) clearTimeout(this.scaleAutoCloseTimeout);
|
|
this.scaleAutoCloseTimeout = setTimeout(() => {
|
|
this._checkAutoClose();
|
|
}, 600);
|
|
}
|
|
this._updateTransparency();
|
|
}
|
|
}
|
|
|
|
onStart(event, interaction) {
|
|
if (this.startGesture(interaction)) {
|
|
this.dragging = true;
|
|
this.interactionAnchor = null;
|
|
}
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: 0, y: 0 },
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: null,
|
|
fast: false,
|
|
type: START
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onMove(event, interaction) {
|
|
/** As long as mouseout && mouseleave interrupt we cannot be sure that
|
|
* dragging remains correct.
|
|
*/
|
|
if (this.dragging) {
|
|
this.gesture(interaction);
|
|
}
|
|
}
|
|
|
|
onEnd(event, interaction) {
|
|
console.log('Scatter.onEnd', this.dragging);
|
|
if (interaction.isFinished()) {
|
|
this.endGesture(interaction);
|
|
this.dragging = false;
|
|
for (let key of interaction.ended.keys()) {
|
|
if (interaction.isTap(key)) {
|
|
console.log('Scatter.isTap');
|
|
let point = interaction.ended.get(key);
|
|
this.onTap(event, interaction, point);
|
|
}
|
|
}
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: 0, y: 0 },
|
|
scale: this.scale,
|
|
rotate: 0,
|
|
about: null,
|
|
fast: false,
|
|
type: END
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
let about = this.interactionAnchor;
|
|
if (about != null) {
|
|
this.checkScaling(about, 100);
|
|
}
|
|
}
|
|
|
|
//onTap(event, interaction, point) {}
|
|
|
|
onTap(event, interaction, point) {
|
|
console.log('AbstractScatter.onTap', this.tapDelegate, interaction);
|
|
if (this.tapDelegate) {
|
|
Events.stop(event);
|
|
this.tapDelegate.tap(event, 'scatter');
|
|
}
|
|
}
|
|
|
|
onDragUpdate(delta) {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
fast: true,
|
|
translate: delta,
|
|
scale: this.scale,
|
|
about: this.currentAbout,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onDragComplete() {
|
|
if (this.onTransform) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
about: this.currentAbout,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onMoved(dx, dy, about) {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
translate: { x: dx, y: dy },
|
|
about: about,
|
|
fast: true,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onResizing() {
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
onZoomed(about) {
|
|
if (this.scaleAutoClose) this._updateTransparency();
|
|
|
|
if (this.onTransform != null) {
|
|
let event = new ScatterEvent(this, {
|
|
scale: this.scale,
|
|
about: about,
|
|
fast: false,
|
|
type: null
|
|
});
|
|
this.onTransform.forEach(function(f) {
|
|
f(event);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A container for scatter objects, which uses a single InteractionMapper
|
|
* for all children. This reduces the number of registered event handlers
|
|
* and covers the common use case that multiple objects are scattered
|
|
* on the same level.
|
|
*/
|
|
class DOMScatterContainer {
|
|
/**
|
|
* @constructor
|
|
* @param {DOM node} element - DOM element that receives events
|
|
* @param {Bool} stopEvents - Whether events should be stopped or propagated
|
|
* @param {Bool} claimEvents - Whether events should be marked as claimed
|
|
* if findTarget return as non-null value.
|
|
* @param {String} [touchAction=none] - CSS to set touch action style, needed to prevent
|
|
* pointer cancel events. Use null if the
|
|
* the touch action should not be set.
|
|
* @param {DOM node} debugCanvas - Shows debug infos about touches if not null
|
|
*/
|
|
constructor(
|
|
element,
|
|
{
|
|
stopEvents = 'auto',
|
|
claimEvents = true,
|
|
useCapture = true,
|
|
capturePointerEvents = true,
|
|
touchAction = 'none',
|
|
debugCanvas = null
|
|
} = {}
|
|
) {
|
|
this.onCapture = null;
|
|
this.element = element;
|
|
if (stopEvents === 'auto') {
|
|
/*
|
|
The events have to be stopped in Safari, otherwise the whole page will be zoomed with
|
|
a pinch gesture (preventDefault in method preventPinch). In order to enable the
|
|
movement of scatter objects, the touchmove event has to be bound again.
|
|
*/
|
|
if (Capabilities.isSafari) {
|
|
document.addEventListener('touchmove', event => this.preventPinch(event), false);
|
|
stopEvents = false;
|
|
} else {
|
|
stopEvents = true;
|
|
}
|
|
}
|
|
this.stopEvents = stopEvents;
|
|
this.claimEvents = claimEvents;
|
|
if (touchAction !== null) {
|
|
Elements$1.setStyle(element, { touchAction });
|
|
}
|
|
this.scatter = new Map();
|
|
this.delegate = new InteractionMapper$1(element, this, {
|
|
useCapture,
|
|
capturePointerEvents,
|
|
mouseWheelElement: window
|
|
});
|
|
|
|
if (debugCanvas !== null) {
|
|
requestAnimationFrame(dt => {
|
|
this.showTouches(dt, debugCanvas);
|
|
});
|
|
}
|
|
}
|
|
|
|
showTouches(dt, canvas) {
|
|
let resolution = window.devicePixelRatio;
|
|
let current = this.delegate.interaction.current;
|
|
let context = canvas.getContext('2d');
|
|
let radius = 20 * resolution;
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
context.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
context.lineWidth = 2;
|
|
context.strokeStyle = '#003300';
|
|
for (let [key, point] of current.entries()) {
|
|
let local = point;
|
|
context.beginPath();
|
|
context.arc(local.x * resolution, local.y * resolution, radius, 0, 2 * Math.PI, false);
|
|
context.fill();
|
|
context.stroke();
|
|
}
|
|
requestAnimationFrame(dt => {
|
|
this.showTouches(dt, canvas);
|
|
});
|
|
}
|
|
|
|
preventPinch(event) {
|
|
event = event.originalEvent || event;
|
|
if (event.scale !== 1) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes an element from the scatter.
|
|
*
|
|
* @param {Scatter} scatter - Element to remove.
|
|
* @memberof DOMScatterContainer
|
|
*/
|
|
remove(scatter) {
|
|
this.scatter.delete(scatter.element);
|
|
}
|
|
|
|
/**
|
|
* Adds an element to the ScatterContainer.
|
|
*
|
|
* @param {Scatter} scatter - Element to add to the ScatterContainer.
|
|
* @memberof DOMScatterContainer
|
|
*/
|
|
add(scatter) {
|
|
this.scatter.set(scatter.element, scatter);
|
|
}
|
|
|
|
capture(event) {
|
|
if (this.onCapture) {
|
|
return this.onCapture(event)
|
|
}
|
|
if (event.target == this.element && this.stopEvents) {
|
|
Events.stop(event);
|
|
}
|
|
return true
|
|
}
|
|
|
|
mapPositionToPoint(point) {
|
|
return Points$1.fromPageToNode(this.element, point)
|
|
}
|
|
|
|
isDescendant(parent, child, clickable = false) {
|
|
if (parent == child) return true
|
|
let node = child.parentNode;
|
|
while (node != null) {
|
|
if (!clickable && node.onclick) {
|
|
return false
|
|
}
|
|
if (node == parent) {
|
|
return true
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
/*** Note that elementFromPoint works with clientX, clientY, not pageX, pageY
|
|
The important point is that event should not be used, since the TouchEvent
|
|
points are hidden in sub objects.
|
|
***/
|
|
let found = document.elementFromPoint(global.x, global.y);
|
|
for (let target of this.scatter.values()) {
|
|
if (target.interactive && this.isDescendant(target.element, found)) {
|
|
if (this.stopEvents) Events.stop(event);
|
|
if (this.claimEvents) event.claimedByScatter = target;
|
|
return target
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
get center() {
|
|
let r = this.bounds;
|
|
let w2 = r.width / 2;
|
|
let h2 = r.height / 2;
|
|
return { x: w2, y: h2 }
|
|
}
|
|
|
|
get bounds() {
|
|
return this.element.getBoundingClientRect()
|
|
}
|
|
|
|
get polygon() {
|
|
let r = this.bounds;
|
|
let w2 = r.width / 2;
|
|
let h2 = r.height / 2;
|
|
let center = { x: w2, y: h2 };
|
|
let polygon = new Polygon(center);
|
|
polygon.addPoint({ x: -w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: -h2 });
|
|
polygon.addPoint({ x: w2, y: h2 });
|
|
polygon.addPoint({ x: -w2, y: h2 });
|
|
return polygon
|
|
}
|
|
}
|
|
|
|
class DOMScatter$1 extends AbstractScatter {
|
|
constructor(
|
|
element,
|
|
container,
|
|
{
|
|
startScale = 1.0,
|
|
minScale = 0.1,
|
|
maxScale = 1.0,
|
|
overdoScaling = 1.5,
|
|
autoBringToFront = true,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
movableX = true,
|
|
movableY = true,
|
|
rotationDegrees = null,
|
|
rotation = null,
|
|
onTransform = null,
|
|
transformOrigin = 'center center',
|
|
// extras which are in part needed
|
|
x = 0,
|
|
y = 0,
|
|
width = null, // required
|
|
height = null, // required
|
|
resizable = false,
|
|
tapDelegate = null,
|
|
triggerSVGClicks = false,
|
|
allowClickDistance = 44,
|
|
verbose = true,
|
|
onResize = null,
|
|
touchAction = 'none',
|
|
throwVisibility = 44,
|
|
throwDamping = 0.95,
|
|
autoThrow = true,
|
|
scaleAutoClose = false,
|
|
onClose = null,
|
|
scaleCloseThreshold = 0.1,
|
|
scaleCloseBuffer = 0.05,
|
|
useLowPassFilter = false,
|
|
maxRotation = Angle.degree2radian(15),
|
|
minInteractionDistance = 200
|
|
} = {}
|
|
) {
|
|
super({
|
|
minScale,
|
|
maxScale,
|
|
startScale,
|
|
overdoScaling,
|
|
autoBringToFront,
|
|
translatable,
|
|
scalable,
|
|
rotatable,
|
|
movableX,
|
|
movableY,
|
|
resizable,
|
|
rotationDegrees,
|
|
rotation,
|
|
onTransform,
|
|
throwVisibility,
|
|
throwDamping,
|
|
autoThrow,
|
|
scaleAutoClose,
|
|
scaleCloseThreshold,
|
|
scaleCloseBuffer,
|
|
onClose,
|
|
useLowPassFilter,
|
|
maxRotation,
|
|
minInteractionDistance
|
|
});
|
|
if (container == null || width == null || height == null) {
|
|
throw new Error('Invalid value: null')
|
|
}
|
|
element.scatter = this;
|
|
this.element = element;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.oldX = 0;
|
|
this.oldY = 0;
|
|
this.meanX = x;
|
|
this.meanY = y;
|
|
this.width = width;
|
|
this.height = height;
|
|
this.throwVisibility = Math.min(width, height, throwVisibility);
|
|
this.container = container;
|
|
this.tapDelegate = tapDelegate;
|
|
this.scale = startScale;
|
|
this.rotationDegrees = this.startRotationDegrees;
|
|
this.transformOrigin = transformOrigin;
|
|
this.initialValues = {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
scale: startScale,
|
|
rotation: this.startRotationDegrees,
|
|
transformOrigin: transformOrigin
|
|
};
|
|
this.tapNodes = new Map();
|
|
|
|
// For tweenlite we need initial values in _gsTransform
|
|
TweenLite.set(element, this.initialValues);
|
|
this.onResize = onResize;
|
|
this.verbose = verbose;
|
|
if (touchAction !== null) {
|
|
Elements$1.setStyle(element, { touchAction });
|
|
}
|
|
this.resizeButton = null;
|
|
if (resizable) {
|
|
let button = document.createElement('div');
|
|
button.style.position = 'absolute';
|
|
button.style.right = '0px';
|
|
button.style.bottom = '0px';
|
|
button.style.width = '50px';
|
|
button.style.height = '50px';
|
|
button.className = 'interactiveElement';
|
|
this.element.appendChild(button);
|
|
|
|
button.addEventListener('pointerdown', e => {
|
|
this.startResize(e);
|
|
});
|
|
|
|
button.addEventListener('pointermove', e => {
|
|
this.resize(e);
|
|
});
|
|
|
|
button.addEventListener('pointerup', e => {
|
|
this.stopResize(e);
|
|
});
|
|
this.resizeButton = button;
|
|
}
|
|
if (tapDelegate) {
|
|
tapDelegate.handleClicks();
|
|
}
|
|
container.add(this);
|
|
}
|
|
|
|
/** Returns geometry data as object. **/
|
|
getState() {
|
|
return {
|
|
scale: this.scale,
|
|
x: this.x,
|
|
y: this.y,
|
|
rotation: this.rotation
|
|
}
|
|
}
|
|
|
|
close() {
|
|
super.close();
|
|
let parent = this.element.parentNode;
|
|
if (parent) parent.removeChild(this.element);
|
|
}
|
|
|
|
get rotationOrigin() {
|
|
return this.center
|
|
}
|
|
|
|
get x() {
|
|
return this._x
|
|
}
|
|
|
|
get y() {
|
|
return this._y
|
|
}
|
|
|
|
set x(value) {
|
|
this._x = value;
|
|
TweenLite.set(this.element, { x: value });
|
|
}
|
|
|
|
set y(value) {
|
|
this._y = value;
|
|
TweenLite.set(this.element, { y: value });
|
|
}
|
|
|
|
get position() {
|
|
let transform = this.element._gsTransform;
|
|
let x = transform.x;
|
|
let y = transform.y;
|
|
return { x, y }
|
|
}
|
|
|
|
get origin() {
|
|
let p = this.fromNodeToPage(0, 0);
|
|
return Points$1.fromPageToNode(this.container.element, p)
|
|
}
|
|
|
|
get bounds() {
|
|
let stage = this.container.element.getBoundingClientRect();
|
|
let rect = this.element.getBoundingClientRect();
|
|
return {
|
|
top: rect.top - stage.top,
|
|
left: rect.left - stage.left,
|
|
width: rect.width,
|
|
height: rect.height
|
|
}
|
|
}
|
|
|
|
get center() {
|
|
let r = this.bounds;
|
|
let w2 = r.width / 2;
|
|
let h2 = r.height / 2;
|
|
// if (this.resizable) {
|
|
// w2 *= this.scale
|
|
// h2 *= this.scale
|
|
// }
|
|
var x = r.left + w2;
|
|
var y = r.top + h2;
|
|
return { x, y }
|
|
}
|
|
|
|
set rotation(radians) {
|
|
let rad = radians; // Angle.normalize(radians)
|
|
let degrees = Angle.radian2degree(rad);
|
|
TweenLite.set(this.element, { rotation: degrees });
|
|
this._rotation = rad;
|
|
}
|
|
|
|
set rotationDegrees(degrees) {
|
|
let deg = degrees; // Angle.normalizeDegree(degrees)
|
|
TweenLite.set(this.element, { rotation: deg });
|
|
this._rotation = Angle.degree2radian(deg);
|
|
}
|
|
|
|
get rotation() {
|
|
return this._rotation
|
|
}
|
|
|
|
get rotationDegrees() {
|
|
return this._rotation
|
|
}
|
|
|
|
set scale(scale) {
|
|
TweenLite.set(this.element, {
|
|
scale: scale,
|
|
transformOrigin: this.transformOrigin
|
|
});
|
|
this._scale = scale;
|
|
}
|
|
|
|
get scale() {
|
|
return this._scale
|
|
}
|
|
|
|
get containerBounds() {
|
|
return this.container.bounds
|
|
}
|
|
|
|
get containerPolygon() {
|
|
return this.container.polygon
|
|
}
|
|
|
|
mapPositionToContainerPoint(point) {
|
|
return this.container.mapPositionToPoint(point)
|
|
}
|
|
|
|
capture(event) {
|
|
return true
|
|
}
|
|
|
|
reset() {
|
|
TweenLite.set(this.element, this.initialValues);
|
|
}
|
|
|
|
hide() {
|
|
TweenLite.to(this.element, 0.1, {
|
|
display: 'none',
|
|
onComplete: e => {
|
|
this.element.parentNode.removeChild(this.element);
|
|
}
|
|
});
|
|
}
|
|
|
|
show() {
|
|
TweenLite.set(this.element, { display: 'block' });
|
|
}
|
|
|
|
showAt(p, rotationDegrees) {
|
|
TweenLite.set(this.element, {
|
|
display: 'block',
|
|
x: p.x,
|
|
y: p.y,
|
|
rotation: rotationDegrees,
|
|
transformOrigin: this.transformOrigin
|
|
});
|
|
}
|
|
|
|
bringToFront() {
|
|
// this.element.parentNode.appendChild(this.element)
|
|
// uo: On Chome and Electon appendChild leads to flicker
|
|
TweenLite.set(this.element, { zIndex: DOMScatter$1.zIndex++ });
|
|
}
|
|
|
|
isDescendant(parent, child) {
|
|
let node = child.parentNode;
|
|
while (node != null) {
|
|
if (node == parent) {
|
|
return true
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
return false
|
|
}
|
|
|
|
fromPageToNode(x, y) {
|
|
return Points$1.fromPageToNode(this.element, { x, y })
|
|
}
|
|
|
|
fromNodeToPage(x, y) {
|
|
return Points$1.fromNodeToPage(this.element, { x, y })
|
|
}
|
|
|
|
_move(delta) {
|
|
// UO: We need to keep TweenLite's _gsTransform and the private
|
|
// _x and _y attributes aligned
|
|
let x = this.element._gsTransform.x;
|
|
let y = this.element._gsTransform.y;
|
|
if (this.movableX) {
|
|
x += delta.x;
|
|
}
|
|
if (this.movableY) {
|
|
y += delta.y;
|
|
}
|
|
this._x = x;
|
|
this._y = y;
|
|
// this.addVelocity({ x: delta.x, y: delta.y }) uo: reverted commit fa0256d782dd498c6d3e51321260ca375ca9f855
|
|
TweenLite.set(this.element, { x, y });
|
|
}
|
|
|
|
resizeAfterTransform(zoom) {
|
|
if (this.onResize) {
|
|
let w = this.width * this.scale;
|
|
let h = this.height * this.scale;
|
|
let event = new ResizeEvent(this, { width: w, height: h });
|
|
this.onResize(event);
|
|
}
|
|
}
|
|
|
|
startResize(e) {
|
|
e.preventDefault();
|
|
let event = new CustomEvent('resizeStarted');
|
|
|
|
let oldPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
this.bringToFront();
|
|
|
|
this.element.style.transformOrigin = '0% 0%';
|
|
|
|
let newPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
|
|
let offset = Points$1.subtract(oldPostition, newPostition);
|
|
|
|
this.oldX = e.clientX;
|
|
this.oldY = e.clientY;
|
|
|
|
e.target.setAttribute('resizing', 'true');
|
|
e.target.setPointerCapture(e.pointerId);
|
|
|
|
TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
|
|
TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
|
|
resize(e) {
|
|
e.preventDefault();
|
|
|
|
let rotation = Angle.radian2degree(this.rotation);
|
|
rotation = (rotation + 360) % 360;
|
|
let event = new CustomEvent('resized');
|
|
if (e.target.getAttribute('resizing') == 'true') {
|
|
let deltaX = e.clientX - this.oldX;
|
|
let deltaY = e.clientY - this.oldY;
|
|
|
|
let r = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
|
|
let phi = Angle.radian2degree(Math.atan2(deltaX, deltaY));
|
|
|
|
phi = (phi + 630) % 360;
|
|
let rot = (rotation + 90 + 630) % 360;
|
|
|
|
let diffAngle = (0 + rot + 360) % 360;
|
|
let phiCorrected = (phi + diffAngle + 360) % 360;
|
|
|
|
let resizeW = r * Math.cos(Angle.degree2radian(phiCorrected));
|
|
let resizeH = -r * Math.sin(Angle.degree2radian(phiCorrected));
|
|
|
|
if (
|
|
(this.element.offsetWidth + resizeW) / this.scale > (this.width * 0.5) / this.scale &&
|
|
(this.element.offsetHeight + resizeH) / this.scale > (this.height * 0.3) / this.scale
|
|
)
|
|
TweenLite.to(this.element, 0, {
|
|
width: this.element.offsetWidth + resizeW / this.scale,
|
|
height: this.element.offsetHeight + resizeH / this.scale
|
|
});
|
|
|
|
this.oldX = e.clientX;
|
|
this.oldY = e.clientY;
|
|
this.onResizing();
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
stopResize(e) {
|
|
e.preventDefault();
|
|
|
|
let event = new CustomEvent('resizeEnded');
|
|
let oldPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
this.element.style.transformOrigin = '50% 50%';
|
|
let newPostition = {
|
|
x: this.element.getBoundingClientRect().left,
|
|
y: this.element.getBoundingClientRect().top
|
|
};
|
|
let offset = Points$1.subtract(oldPostition, newPostition);
|
|
|
|
TweenLite.to(this.element, 0, { css: { left: '+=' + offset.x + 'px' } });
|
|
TweenLite.to(this.element, 0, { css: { top: '+=' + offset.y + 'px' } });
|
|
|
|
e.target.setAttribute('resizing', 'false');
|
|
|
|
this.element.dispatchEvent(event);
|
|
}
|
|
}
|
|
|
|
DOMScatter$1.zIndex = 1000;
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
class CardWrapper extends Object {
|
|
constructor(domNode, { triggerSVGClicks = true, allowClickDistance = 44 } = {}) {
|
|
super();
|
|
this.domNode = domNode;
|
|
this.triggerSVGClicks = triggerSVGClicks;
|
|
this.allowClickDistance = allowClickDistance;
|
|
this.tapNodes = new Map();
|
|
this.tapHandler = new Map();
|
|
}
|
|
|
|
handleClicks() {
|
|
this.domNode.addEventListener(
|
|
'click',
|
|
event => {
|
|
if (event.isTrusted) {
|
|
Events.stop(event);
|
|
if (this.triggerSVGClicks && this.isSVGNode(event.target)) {
|
|
this.tap(event, 'triggerSVGClicks');
|
|
}
|
|
}
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
handleClicksAsTaps() {
|
|
this.domNode.addEventListener(
|
|
'click',
|
|
event => {
|
|
if (event.isTrusted) {
|
|
Events.stop(event);
|
|
}
|
|
this.tap(event);
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
isClickPrevented(node) {
|
|
if (node == null) {
|
|
return false
|
|
}
|
|
if (node.style && node.style.pointerEvents == 'none') {
|
|
return true
|
|
}
|
|
return this.isClickPrevented(node.parentNode)
|
|
}
|
|
|
|
isClickable(node) {
|
|
if (node == null) return false
|
|
// console.log("isClickable", node, this.isClickPrevented(node))
|
|
if (this.isClickPrevented(node)) {
|
|
return false
|
|
}
|
|
if (node.tagName == 'A' && node.hasAttribute('href')) return true
|
|
if (node.hasAttribute('onclick')) return true
|
|
return false
|
|
}
|
|
|
|
hasClickHandler(node) {
|
|
if (node == null) return false
|
|
if (this.tapNodes.has(node)) return true
|
|
for (let [selector, handler] of this.tapHandler.entries()) {
|
|
for (let obj of this.domNode.querySelectorAll(selector)) {
|
|
if (node == obj) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Returns an array of all active nodes.
|
|
* Unfortunately we cannot search for all nodes with an attached 'click' event listener
|
|
* See https://stackoverflow.com/questions/11455515/how-to-check-whether-dynamically-attached-event-listener-exists-or-not
|
|
* Therefore we can only detect the following standard cases:
|
|
* I. All clickable objects like activeNodes
|
|
* II. Objects that have been attached a click handler by the scatter itself via
|
|
*/
|
|
activeNodes() {
|
|
let result = [];
|
|
for (let node of this.domNode.querySelectorAll('*')) {
|
|
if (this.isClickable(node)) result.push(node);
|
|
if (this.hasClickHandler(node)) result.push(node);
|
|
}
|
|
return result
|
|
}
|
|
|
|
nearestActive(event) {
|
|
let element = this.domNode;
|
|
let activeNodes = this.activeNodes();
|
|
let globalClick = event.center ? event.center : { x: event.x, y: event.y };
|
|
let localClick = Points$1.fromPageToNode(element, globalClick);
|
|
|
|
let clickRects = activeNodes.map(link => {
|
|
let rect = link.getBoundingClientRect();
|
|
// Since the getBoundingClientRect is untransformed we cannot rely on it's size
|
|
// We need a transformed bottom right to calculate local width and height
|
|
let bottomRight = Points$1.fromPageToNode(element, {
|
|
x: rect.x + rect.width,
|
|
y: rect.y + rect.height
|
|
});
|
|
let topLeft = Points$1.fromPageToNode(element, rect);
|
|
let width = Math.abs(bottomRight.x - topLeft.x);
|
|
let height = Math.abs(bottomRight.y - topLeft.y);
|
|
let center = Points$1.fromPageToNode(element, {
|
|
x: rect.x + width / 2,
|
|
y: rect.y + height / 2
|
|
});
|
|
return {
|
|
x: topLeft.x,
|
|
y: topLeft.y,
|
|
width,
|
|
height,
|
|
center,
|
|
link
|
|
}
|
|
});
|
|
|
|
let distances = [];
|
|
clickRects.forEach(rect => {
|
|
let distance = Points$1.distanceToRect(localClick, rect);
|
|
distances.push(parseInt(distance));
|
|
});
|
|
|
|
let closestClickIndex = distances.indexOf(Math.min(...distances));
|
|
let closestClickable = activeNodes[closestClickIndex];
|
|
if (distances[closestClickIndex] < this.allowClickDistance) {
|
|
return closestClickable
|
|
}
|
|
return null
|
|
}
|
|
|
|
isSVGNode(node) {
|
|
return node.ownerSVGElement || node.tagName == 'svg'
|
|
}
|
|
|
|
simulateClick(node, event) {
|
|
/* https://stackoverflow.com/questions/49564905/is-it-possible-to-use-click-function-on-svg-tags-i-tried-element-click-on-a
|
|
proposes the dispatchEvent solution. But this leads to problems in flippable.html hiding the back page.
|
|
Therefore we use the original click event (see constructor). */
|
|
// if (this.isSVGNode(node)) {
|
|
// if (this.triggerSVGClicks) {
|
|
// let click = new Event('click')
|
|
// node.dispatchEvent(click)
|
|
// }
|
|
// return
|
|
// }
|
|
// node.click()
|
|
|
|
let click = new Event('click');
|
|
click.clientX = event.clientX;
|
|
click.clientY = event.clientY;
|
|
node.dispatchEvent(click);
|
|
}
|
|
|
|
nodeTapped(node, event) {
|
|
console.log('nodeTapped', node, this.isClickable(node));
|
|
if (this.isClickable(node)) {
|
|
this.simulateClick(node, event);
|
|
return true
|
|
}
|
|
if (this.tapNodes.has(node)) {
|
|
let handler = this.tapNodes.get(node);
|
|
handler(event, node);
|
|
return true
|
|
}
|
|
for (let [selector, handler] of this.tapHandler.entries()) {
|
|
console.log('nodeTapped', selector);
|
|
for (let obj of this.domNode.querySelectorAll(selector)) {
|
|
if (node == obj) {
|
|
handler(event, node);
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
tap(event, calledBy = 'unknown') {
|
|
if (event.isTrusted) {
|
|
let node = this.nearestActive(event);
|
|
console.log('tap', node);
|
|
this.nodeTapped(node, event);
|
|
|
|
/* let node = document.elementFromPoint(event.clientX, event.clientY)
|
|
if (!this.nodeTapped(node, event)) {
|
|
node = this.nearestActive(event)
|
|
this.nodeTapped(node, event)
|
|
} */
|
|
}
|
|
}
|
|
|
|
onTap(objOrSelector, handler) {
|
|
if (typeof objOrSelector == 'string') {
|
|
this.tapHandler.set(objOrSelector, handler);
|
|
} else {
|
|
this.tapNodes.set(objOrSelector, handler);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
class CardLoader {
|
|
constructor(
|
|
src,
|
|
{
|
|
x = 0,
|
|
y = 0,
|
|
width = 1000,
|
|
height = 800,
|
|
maxWidth = null,
|
|
maxHeight = null,
|
|
scale = 1,
|
|
minScale = 0.5,
|
|
maxScale = 1.5,
|
|
rotation = 0
|
|
} = {}
|
|
) {
|
|
this.src = src;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.scale = scale;
|
|
this.rotation = rotation;
|
|
this.maxScale = maxScale;
|
|
this.minScale = minScale;
|
|
this.wantedWidth = width;
|
|
this.wantedHeight = height;
|
|
this.maxWidth = maxWidth != null ? maxWidth : window.innerWidth;
|
|
this.maxHeight = maxHeight != null ? maxHeight : window.innerHeight;
|
|
this.addedNode = null;
|
|
}
|
|
|
|
unload() {
|
|
if (this.addedNode) {
|
|
this.addedNode.remove();
|
|
this.addedNode = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
class PDFLoader extends CardLoader {
|
|
constructor(src, { width = 1640, height = 800, scale = 1 } = {}) {
|
|
super(src, { width, height, scale });
|
|
if (typeof PDFJS == 'undefined') {
|
|
alert('PDF.js needed');
|
|
}
|
|
}
|
|
|
|
load(domNode) {
|
|
return new Promise((resolve, reject) => {
|
|
PDFJS.getDocument(this.src).then(pdf => {
|
|
pdf.getPage(1).then(page => {
|
|
let scale = this.scale * app.renderer.resolution;
|
|
let invScale = 1 / scale;
|
|
let viewport = page.getViewport(scale);
|
|
|
|
// Prepare canvas using PDF page dimensions.
|
|
let canvas = document.createElement('canvas');
|
|
let context = canvas.getContext('2d');
|
|
canvas.height = viewport.height;
|
|
canvas.width = viewport.width;
|
|
|
|
// Render PDF page into canvas context.
|
|
let renderContext = {
|
|
canvasContext: context,
|
|
viewport: viewport
|
|
};
|
|
page.render(renderContext);
|
|
domNode.appendChild(canvas);
|
|
this.wantedWidth = canvas.width;
|
|
this.wantedHeight = canvas.height;
|
|
this.scale = invScale;
|
|
this.addedNode = canvas;
|
|
resolve(this);
|
|
});
|
|
});
|
|
})
|
|
}
|
|
}
|
|
|
|
class ImageLoader extends CardLoader {
|
|
load(domNode) {
|
|
return new Promise((resolve, reject) => {
|
|
let isImage = domNode instanceof HTMLImageElement;
|
|
let image = isImage ? domNode : document.createElement('img');
|
|
image.onload = e => {
|
|
if (!isImage) {
|
|
domNode.appendChild(image);
|
|
this.addedNode = image;
|
|
}
|
|
this.wantedWidth = image.naturalWidth;
|
|
this.wantedHeight = image.naturalHeight;
|
|
|
|
let scaleW = this.maxWidth / image.naturalWidth;
|
|
let scaleH = this.maxHeight / image.naturalHeight;
|
|
this.scale = Math.min(this.maxScale, Math.min(scaleW, scaleH));
|
|
image.setAttribute('draggable', false);
|
|
image.width = image.naturalWidth;
|
|
image.height = image.naturalHeight;
|
|
resolve(this);
|
|
};
|
|
image.onerror = e => {
|
|
reject(this);
|
|
};
|
|
image.src = this.src;
|
|
})
|
|
}
|
|
}
|
|
|
|
class FrameLoader extends CardLoader {
|
|
load(domNode) {
|
|
return new Promise((resolve, reject) => {
|
|
let isFrame = domNode instanceof HTMLIFrameElement;
|
|
let iframe = isFrame ? domNode : document.createElement('iframe');
|
|
iframe.frameBorder = 0;
|
|
iframe.style.scrolling = false;
|
|
iframe.width = this.wantedWidth;
|
|
iframe.height = this.wantedHeight;
|
|
if (!isFrame) {
|
|
// Unlike img onload is only triggered if the iframe is part of the DOM tree
|
|
domNode.appendChild(iframe);
|
|
this.addedNode = iframe;
|
|
}
|
|
iframe.onload = e => {
|
|
resolve(this);
|
|
};
|
|
iframe.onerror = e => {
|
|
reject(this);
|
|
};
|
|
iframe.src = this.src;
|
|
})
|
|
}
|
|
}
|
|
|
|
class HTMLLoader extends CardLoader {
|
|
load(domNode) {
|
|
return new Promise((resolve, reject) => {
|
|
let xhr = new XMLHttpRequest();
|
|
xhr.open('GET', this.src, false);
|
|
xhr.onreadystatechange = e => {
|
|
if (xhr.readyState == 4) {
|
|
domNode.innerHTML = this.prepare(xhr.response);
|
|
this.addedNode = domNode.firstElementChild;
|
|
let { width, height } = this.size(this.addedNode);
|
|
if (width) this.wantedWidth = width || this.wantedWidth;
|
|
if (height) this.wantedHeight = height || this.wantedHeight;
|
|
resolve(this);
|
|
}
|
|
};
|
|
xhr.onerror = e => {
|
|
reject(this);
|
|
};
|
|
xhr.send();
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Preoares the html before it is assigned with innerHTML.
|
|
* Can be overwritten in subclasses.
|
|
*
|
|
* @param {*} html
|
|
* @returns
|
|
* @memberof HTMLLoader
|
|
*/
|
|
prepare(html) {
|
|
return html
|
|
}
|
|
|
|
/**
|
|
* Tries to determine the size of the addedNode.
|
|
* Checks for explicit width and height style attributes.
|
|
*
|
|
* Overwrite this method if you want to extract values from other infos.
|
|
*
|
|
* @returns { width: int, height: int }
|
|
* @memberof HTMLLoader
|
|
*/
|
|
size(node) {
|
|
let width = parseInt(node.style.width) || null;
|
|
let height = parseInt(node.style.height) || null;
|
|
return { width, height }
|
|
}
|
|
}
|
|
|
|
class DOMFlip {
|
|
constructor(
|
|
domScatterContainer,
|
|
flipTemplate,
|
|
frontLoader,
|
|
backLoader,
|
|
{
|
|
closeOnMinScale = false,
|
|
flipDuration = 1,
|
|
fadeDuration = 0.2,
|
|
overdoScaling = 1,
|
|
autoLoad = false,
|
|
center = null,
|
|
preloadBack = false,
|
|
translatable = true,
|
|
scalable = true,
|
|
rotatable = true,
|
|
tapDelegateFactory = null,
|
|
onFront = null,
|
|
onBack = null,
|
|
onClose = null,
|
|
onUpdate = null,
|
|
onRemoved = null,
|
|
onLoaded = null
|
|
} = {}
|
|
) {
|
|
this.domScatterContainer = domScatterContainer;
|
|
this.id = getId$1();
|
|
this.flipDuration = flipDuration;
|
|
this.fadeDuration = fadeDuration;
|
|
this.closeOnMinScale = closeOnMinScale;
|
|
this.flipTemplate = flipTemplate;
|
|
this.frontLoader = frontLoader;
|
|
this.backLoader = backLoader;
|
|
this.translatable = translatable;
|
|
this.scalable = scalable;
|
|
this.rotatable = rotatable;
|
|
this.tapDelegateFactory = tapDelegateFactory;
|
|
this.onFrontFlipped = onFront;
|
|
this.onBackFlipped = onBack;
|
|
this.onClose = onClose;
|
|
this.onRemoved = onRemoved;
|
|
this.onUpdate = onUpdate;
|
|
this.onLoaded = onLoaded;
|
|
this.center = center;
|
|
this.preloadBack = preloadBack;
|
|
this.overdoScaling = overdoScaling;
|
|
if (autoLoad) {
|
|
this.load();
|
|
}
|
|
}
|
|
|
|
load() {
|
|
return new Promise((resolve, reject) => {
|
|
let t = this.flipTemplate;
|
|
let dom = this.domScatterContainer.element;
|
|
let wrapper = t.content.querySelector('.flipWrapper');
|
|
wrapper.id = this.id;
|
|
let clone = document.importNode(t.content, true);
|
|
dom.appendChild(clone);
|
|
// We cannot use the document fragment itself because it
|
|
// is not part of the main dom tree. After the appendChild
|
|
// call we can access the new dom element by id
|
|
this.cardWrapper = dom.querySelector('#' + this.id);
|
|
let front = this.cardWrapper.querySelector('.front');
|
|
this.frontLoader.load(front).then(loader => {
|
|
this.frontLoaded(loader).then(obj => {
|
|
if (this.onLoaded) this.onLoaded();
|
|
resolve(this);
|
|
});
|
|
});
|
|
})
|
|
}
|
|
|
|
frontLoaded(loader) {
|
|
return new Promise((resolve, reject) => {
|
|
let scatter = new DOMScatter$1(this.cardWrapper, this.domScatterContainer, {
|
|
x: loader.x,
|
|
y: loader.y,
|
|
startScale: loader.scale,
|
|
scale: loader.scale,
|
|
maxScale: loader.maxScale,
|
|
minScale: loader.minScale,
|
|
width: loader.wantedWidth,
|
|
height: loader.wantedHeight,
|
|
rotation: loader.rotation,
|
|
translatable: this.translatable,
|
|
scalable: this.scalable,
|
|
rotatable: this.rotatable,
|
|
overdoScaling: this.overdoScaling,
|
|
tapDelegate: this.tapDelegateFactory ? new this.tapDelegateFactory(this.cardWrapper) : null
|
|
});
|
|
|
|
if (this.center) {
|
|
scatter.centerAt(this.center);
|
|
}
|
|
|
|
if (this.closeOnMinScale) {
|
|
const removeOnMinScale = function() {
|
|
if (scatter.scale <= scatter.minScale) {
|
|
this.flippable.close();
|
|
|
|
// 'Disable' overdoscaling to avoid weird jumps on close.
|
|
scatter.minScale /= scatter.overdoScaling;
|
|
scatter.overdoScaling = 1;
|
|
|
|
//Remove callback
|
|
if (scatter.onTransform) {
|
|
let callbackIdx = scatter.onTransform.indexOf(removeOnMinScale);
|
|
scatter.onTransform.splice(callbackIdx, 1);
|
|
}
|
|
}
|
|
}.bind(this);
|
|
scatter.addTransformEventCallback(removeOnMinScale);
|
|
}
|
|
|
|
let flippable = new DOMFlippable(this.cardWrapper, scatter, this);
|
|
let back = this.cardWrapper.querySelector('.back');
|
|
|
|
if (this.preloadBack) {
|
|
this.backLoader.load(back).then(loader => {
|
|
this.setupFlippable(flippable, loader);
|
|
});
|
|
}
|
|
this.flippable = flippable;
|
|
resolve(this);
|
|
})
|
|
}
|
|
|
|
centerAt(p) {
|
|
this.center = p;
|
|
this.flippable.centerAt(p);
|
|
}
|
|
|
|
zoom(scale) {
|
|
this.flippable.zoom(scale);
|
|
}
|
|
|
|
setupFlippable(flippable, loader) {
|
|
flippable.wantedWidth = loader.wantedWidth;
|
|
flippable.wantedHeight = loader.wantedHeight;
|
|
flippable.wantedScale = loader.scale;
|
|
flippable.minScale = loader.minScale;
|
|
flippable.maxScale = loader.maxScale;
|
|
flippable.scaleButtons();
|
|
}
|
|
|
|
start({ targetCenter = null } = {}) {
|
|
this.flippable.showFront();
|
|
if (this.preloadBack) {
|
|
this.flippable.start({ duration: this.flipDuration, targetCenter });
|
|
} else {
|
|
let back = this.cardWrapper.querySelector('.back');
|
|
let flippable = this.flippable;
|
|
this.backLoader.load(back).then(loader => {
|
|
this.setupFlippable(flippable, loader);
|
|
flippable.start({ duration: this.flipDuration, targetCenter });
|
|
});
|
|
}
|
|
}
|
|
|
|
fadeOutAndRemove() {
|
|
TweenLite.to(this.cardWrapper, this.fadeDuration, {
|
|
opacity: 0,
|
|
onComplete: () => {
|
|
this.cardWrapper.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
closed() {
|
|
this.unload();
|
|
}
|
|
|
|
unload() {
|
|
if (!this.preloadBack) {
|
|
this.backLoader.unload();
|
|
}
|
|
}
|
|
}
|
|
|
|
class DOMFlippable {
|
|
constructor(element, scatter, flip) {
|
|
// Set log to console.log or a custom log function
|
|
// define data structures to store our touchpoints in
|
|
|
|
this.element = element;
|
|
this.flip = flip;
|
|
this.card = element.querySelector('.flipCard');
|
|
this.front = element.querySelector('.front');
|
|
this.back = element.querySelector('.back');
|
|
this.flipped = false;
|
|
this.scatter = scatter;
|
|
this.onFrontFlipped = flip.onFrontFlipped;
|
|
this.onBackFlipped = flip.onBackFlipped;
|
|
this.onClose = flip.onClose;
|
|
this.onRemoved = flip.onRemoved;
|
|
this.onUpdate = flip.onUpdate;
|
|
|
|
this.wantedWidth = scatter.width;
|
|
this.wantedHeight = scatter.height;
|
|
this.wantedScale = scatter.scale;
|
|
this.minScale = scatter.minScale;
|
|
this.maxScale = scatter.maxScale;
|
|
|
|
this.flipDuration = flip.flipDuration;
|
|
this.fadeDuration = flip.fadeDuration;
|
|
scatter.addTransformEventCallback(this.scatterTransformed.bind(this));
|
|
|
|
TweenLite.set(this.element, { perspective: 5000 });
|
|
TweenLite.set(this.card, { transformStyle: 'preserve-3d' });
|
|
TweenLite.set(this.back, { rotationY: -180 });
|
|
TweenLite.set([this.back, this.front], {
|
|
backfaceVisibility: 'hidden',
|
|
perspective: 5000
|
|
});
|
|
TweenLite.set(this.front, { visibility: 'visible' });
|
|
this.infoBtn = element.querySelector('.infoBtn');
|
|
this.backBtn = element.querySelector('.backBtn');
|
|
this.closeBtn = element.querySelector('.closeBtn');
|
|
/* Buttons are not guaranteed to exist. */
|
|
if (scatter.tapDelegate == null) {
|
|
let tapDelegate = new CardWrapper(element);
|
|
scatter.tapDelegate = tapDelegate;
|
|
}
|
|
if (this.infoBtn) {
|
|
scatter.tapDelegate.onTap(this.infoBtn, event => {
|
|
this.flip.start();
|
|
});
|
|
this.enable(this.infoBtn);
|
|
}
|
|
if (this.backBtn) {
|
|
scatter.tapDelegate.onTap(this.backBtn, event => {
|
|
this.start();
|
|
});
|
|
}
|
|
if (this.closeBtn) {
|
|
scatter.tapDelegate.onTap(this.closeBtn, event => {
|
|
this.close();
|
|
});
|
|
this.enable(this.closeBtn);
|
|
}
|
|
this.scaleButtons();
|
|
this.bringToFront();
|
|
}
|
|
|
|
close() {
|
|
this.disable(this.infoBtn);
|
|
this.disable(this.closeBtn);
|
|
if (this.onClose) {
|
|
this.onClose(this);
|
|
this.flip.closed();
|
|
} else {
|
|
this.scatter.zoom(0.1, {
|
|
animate: this.fadeDuration,
|
|
onComplete: () => {
|
|
this.element.remove();
|
|
this.flip.closed();
|
|
if (this.onRemoved) {
|
|
this.onRemoved.call(this);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
showFront() {
|
|
TweenLite.set(this.front, { visibility: 'visible' });
|
|
}
|
|
|
|
centerAt(p) {
|
|
this.scatter.centerAt(p);
|
|
}
|
|
|
|
zoom(scale) {
|
|
this.scatter.zoom(scale);
|
|
}
|
|
|
|
get buttonScale() {
|
|
let iscale = 1.0;
|
|
|
|
if (this.scatter != null) {
|
|
let scale = this.scatter.scale || 1;
|
|
iscale = 1.0 / scale;
|
|
}
|
|
return iscale
|
|
}
|
|
|
|
scaleButtons() {
|
|
TweenLite.set([this.infoBtn, this.backBtn, this.closeBtn], {
|
|
scale: this.buttonScale
|
|
});
|
|
}
|
|
|
|
bringToFront() {
|
|
this.scatter.bringToFront();
|
|
TweenLite.set(this.element, { zIndex: DOMScatter$1.zIndex++ });
|
|
}
|
|
|
|
clickInfo() {
|
|
this.bringToFront();
|
|
this.infoBtn.click();
|
|
}
|
|
|
|
scatterTransformed(event) {
|
|
this.scaleButtons();
|
|
}
|
|
|
|
targetRotation(alpha) {
|
|
let ortho = 90;
|
|
let rest = alpha % ortho;
|
|
let delta = 0.0;
|
|
if (rest > ortho / 2.0) {
|
|
delta = ortho - rest;
|
|
} else {
|
|
delta = -rest;
|
|
}
|
|
return delta
|
|
}
|
|
|
|
infoValues(info) {
|
|
let startX = this.element._gsTransform.x;
|
|
let startY = this.element._gsTransform.y;
|
|
let startAngle = this.element._gsTransform.rotation;
|
|
let startScale = this.element._gsTransform.scaleX;
|
|
let w = this.element.style.width;
|
|
let h = this.element.style.height;
|
|
// eslint-disable-next-line no-console
|
|
console.log(info, startX, startY, startAngle, startScale, w, h);
|
|
}
|
|
|
|
show(element, duration = 0, alpha = 1) {
|
|
if (element) {
|
|
TweenLite.to(element, duration, { autoAlpha: alpha }); // visibility: 'visible', display: 'initial'})
|
|
}
|
|
}
|
|
|
|
hide(element, duration = 0, alpha = 0) {
|
|
if (element) {
|
|
TweenLite.to(element, duration, { autoAlpha: alpha }); // {visibility: 'hidden', display: 'none'})
|
|
}
|
|
}
|
|
|
|
enable(button) {
|
|
this.show(button, this.fadeDuration);
|
|
if (button) {
|
|
TweenLite.set(button, { pointerEvents: 'auto' });
|
|
}
|
|
}
|
|
|
|
disable(button) {
|
|
this.hide(button, this.fadeDuration);
|
|
if (button) {
|
|
TweenLite.set(button, { pointerEvents: 'none' });
|
|
}
|
|
}
|
|
|
|
start({ targetCenter = null } = {}) {
|
|
this.bringToFront();
|
|
console.log('flippable start', this.flipped);
|
|
if (!this.flipped) {
|
|
this.startX = this.element._gsTransform.x;
|
|
this.startY = this.element._gsTransform.y;
|
|
this.startAngle = this.element._gsTransform.rotation;
|
|
this.startScale = this.element._gsTransform.scaleX;
|
|
this.startWidth = this.element.style.width;
|
|
this.startHeight = this.element.style.height;
|
|
this.scatterStartWidth = this.scatter.width;
|
|
this.scatterStartHeight = this.scatter.height;
|
|
this.show(this.back);
|
|
this.disable(this.infoBtn);
|
|
this.disable(this.closeBtn);
|
|
} else {
|
|
this.show(this.front, this.fadeDuration);
|
|
this.disable(this.backBtn);
|
|
}
|
|
let { scalable, translatable, rotatable } = this.scatter;
|
|
this.saved = { scalable, translatable, rotatable };
|
|
this.scatter.scalable = false;
|
|
this.scatter.translatable = false;
|
|
this.scatter.rotatable = false;
|
|
this.scatter.killAnimation();
|
|
|
|
this.flipped = !this.flipped;
|
|
let targetY = this.flipped ? 180 : 0;
|
|
let targetZ = this.flipped ? this.startAngle + this.targetRotation(this.startAngle) : this.startAngle;
|
|
let targetScale = this.flipped ? this.wantedScale : this.startScale;
|
|
let w = this.flipped ? this.wantedWidth : this.startWidth;
|
|
let h = this.flipped ? this.wantedHeight : this.startHeight;
|
|
let dw = this.wantedWidth - this.scatter.width;
|
|
let dh = this.wantedHeight - this.scatter.height;
|
|
let tc = targetCenter;
|
|
let xx = tc != null ? tc.x - w / 2 : this.startX - dw / 2;
|
|
let yy = tc != null ? tc.y - h / 2 : this.startY - dh / 2;
|
|
let x = this.flipped ? xx : this.startX;
|
|
let y = this.flipped ? yy : this.startY;
|
|
|
|
let onUpdate = this.onUpdate !== null ? () => this.onUpdate(this) : null;
|
|
TweenLite.to(this.card, this.flipDuration, {
|
|
rotationY: targetY,
|
|
ease: Power1.easeOut,
|
|
transformOrigin: '50% 50%',
|
|
onUpdate,
|
|
onComplete: e => {
|
|
if (this.flipped) {
|
|
//this.hide(this.front)
|
|
this.enable(this.backBtn);
|
|
this.show(this.backBtn);
|
|
|
|
if (this.onFrontFlipped) {
|
|
this.onFrontFlipped(this);
|
|
}
|
|
} else {
|
|
if (this.onBackFlipped == null) {
|
|
this.enable(this.infoBtn, this.fadeDuration);
|
|
this.enable(this.closeBtn, this.fadeDuration);
|
|
} else {
|
|
this.onBackFlipped(this);
|
|
}
|
|
this.flip.unload();
|
|
}
|
|
this.scatter.scale = targetScale;
|
|
this.scaleButtons();
|
|
this.scatter.rotationDegrees = targetZ;
|
|
this.scatter.width = this.flipped ? w : this.scatterStartWidth;
|
|
this.scatter.height = this.flipped ? h : this.scatterStartHeight;
|
|
|
|
let { scalable, translatable, rotatable } = this.saved;
|
|
this.scatter.scalable = scalable;
|
|
this.scatter.translatable = translatable;
|
|
this.scatter.rotatable = rotatable;
|
|
},
|
|
force3D: true
|
|
});
|
|
// See https://greensock.com/forums/topic/7997-rotate-the-shortest-way/
|
|
TweenLite.to(this.element, this.flipDuration / 2, {
|
|
scale: targetScale,
|
|
ease: Power1.easeOut,
|
|
rotationZ: targetZ + '_short',
|
|
transformOrigin: '50% 50%',
|
|
width: w,
|
|
height: h,
|
|
x: x,
|
|
y: y,
|
|
onComplete: e => {
|
|
if (this.flipped) {
|
|
this.hide(this.front);
|
|
// this.hide(this.infoBtn)
|
|
} else {
|
|
this.hide(this.back);
|
|
// this.show(this.infoBtn)
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
class Index {
|
|
constructor(template, pages, notfound = 'thumbnails/notfound.png') {
|
|
this.template = template;
|
|
this.pages = pages;
|
|
this.notfound = notfound;
|
|
}
|
|
|
|
setup() {
|
|
for (let pair of this.pages) {
|
|
let [title, src] = pair;
|
|
let id = getId();
|
|
pair.push(id);
|
|
let t = this.template;
|
|
let wrapper = t.content.querySelector('.wrapper');
|
|
wrapper.id = id;
|
|
let clone = document.importNode(t.content, true);
|
|
container.appendChild(clone);
|
|
wrapper = container.querySelector('#' + id);
|
|
|
|
let icon = wrapper.querySelector('.icon');
|
|
|
|
icon.onerror = e => {
|
|
if (this.notfound) icon.src = this.notfound;
|
|
};
|
|
let iconSrc = src.replace('.html', '.png');
|
|
//console.log("iconSrc", iconSrc)
|
|
if (iconSrc.endsWith('index.png')) {
|
|
icon.src = iconSrc.replace('index.png', 'thumbnail.png');
|
|
} else {
|
|
icon.src = 'thumbnails/' + iconSrc;
|
|
}
|
|
|
|
// icon.src = 'thumbnails/' + iconSrc
|
|
// console.log(iconSrc)
|
|
wrapper.href = src;
|
|
let titleDiv = wrapper.querySelector('.title');
|
|
titleDiv.innerText = title;
|
|
}
|
|
}
|
|
|
|
frames() {
|
|
if (this.pages.length == 0) return
|
|
let [title, src, id] = this.pages.shift();
|
|
let iframe = document.createElement('iframe');
|
|
iframe.frameborder = 0;
|
|
let wrapper = document.getElementById(id);
|
|
let icon = wrapper.querySelector('.icon');
|
|
|
|
icon.parentNode.replaceChild(iframe, icon);
|
|
iframe.onload = e => {
|
|
this.frames();
|
|
};
|
|
iframe.src = src + window.location.search;
|
|
}
|
|
|
|
load() {
|
|
this.setup();
|
|
if (window.location.search.startsWith('?test')) this.frames();
|
|
}
|
|
|
|
loadAndTest() {
|
|
this.setup();
|
|
if (!Capabilities.isMobile) this.frames();
|
|
}
|
|
}
|
|
|
|
/** Basic class for poppable elements that need to be closed as soon as one poppable is
|
|
* shown.
|
|
*/
|
|
class Poppable {
|
|
/** Register the poppable element in a context. Closes previously registered ones.
|
|
* @param {*} context
|
|
*/
|
|
register(context) {
|
|
let registered = Poppable.get(context);
|
|
if (registered != null) {
|
|
registered.close();
|
|
}
|
|
Poppable.set(context, this);
|
|
}
|
|
|
|
/**
|
|
* Unregister object from context
|
|
* @param {*} context
|
|
*/
|
|
unregister(context) {
|
|
Poppable.delete(context);
|
|
}
|
|
|
|
/**
|
|
* Returns the given poppable in a context
|
|
* @static
|
|
* @param {*} context
|
|
* @returns
|
|
* @memberof Poppable
|
|
*/
|
|
static get(context) {
|
|
return Poppable.registrations.get(context)
|
|
}
|
|
|
|
/** Sets the poppable in the given context
|
|
* @static
|
|
* @param {*} context
|
|
* @param {*} poppable
|
|
* @returns
|
|
* @memberof Poppable
|
|
*/
|
|
static set(context, poppable) {
|
|
return Poppable.registrations.set(context, poppable)
|
|
}
|
|
|
|
/** Test whether a poppable exists in the given context
|
|
*
|
|
* @param {*} context
|
|
*/
|
|
static has(context) {
|
|
return Poppable.registrations.has(context)
|
|
}
|
|
|
|
/**
|
|
* Removes the poppable from the given context.
|
|
*
|
|
* @static
|
|
* @param {*} context
|
|
* @memberof Poppable
|
|
*/
|
|
static delete(context) {
|
|
Poppable.registrations.delete(context);
|
|
}
|
|
|
|
/** All poppable must implement a close method. */
|
|
close() {
|
|
console.error('Must be implemented');
|
|
}
|
|
}
|
|
|
|
Poppable.registrations = new Map();
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
/** A Popup that shows text labels, images, or html
|
|
*/
|
|
class Popup$1 extends Poppable {
|
|
/**
|
|
* Creates an instance of Popup.
|
|
* @param {any} [{
|
|
* parent = null, - The DOM parent element.
|
|
* content = null, - A dict object with type strings (text, img, html) as keys
|
|
* and corresponding values.
|
|
* context = window, - A context object for poppable elements
|
|
* fontSize = "1em", - Describes the font size as CSS value
|
|
* fontFamily = "Arial", - Describes the font family as CSS value
|
|
* padding = 16, - {number || string} padding - Describes the padding as CSS value
|
|
* notchSize = 10, - {number || string} notchSize - Describes the size of the notch (callout) as CSS value
|
|
* switchPos = false,
|
|
* minWidth = null,
|
|
* maxWidth = 800,
|
|
* backgroundColor = "#EEE", - The color of the background as CSS value
|
|
* normalColor = "#444", - normalColor - The color of textitems as CSS value
|
|
* notchPosition = "bottomLeft",
|
|
* zIndex = 0,
|
|
* keepWithin = null, - Ensure that popup is visible within the bounds of the given container
|
|
* autoClose = true, - Autoclose the Popup on tap
|
|
* closeIcon = null,
|
|
* resizeIcon = null,
|
|
* closeCommand = null,
|
|
* draggable = false
|
|
* noStyle = false - When true, prevents the popup from doing any aesthetic manipulations to the DOM leaving the styling completely to the style sheets.
|
|
* }={}]
|
|
* @memberof Popup
|
|
*/
|
|
constructor({
|
|
parent = null,
|
|
content = null,
|
|
context = window,
|
|
fontSize = '1em',
|
|
fontFamily = 'Arial',
|
|
padding = 16,
|
|
notchSize = 10,
|
|
switchPos = false,
|
|
minWidth = null,
|
|
maxWidth = 800,
|
|
backgroundColor = '#EEE',
|
|
normalColor = '#444',
|
|
notchPosition = 'bottomCenter',
|
|
zIndex = 0,
|
|
keepWithin = null,
|
|
autoClose = true,
|
|
closeIcon = null,
|
|
resizeIcon = null,
|
|
closeCommand = null,
|
|
draggable = false,
|
|
posOffset = 0,
|
|
targetBoundingBox = null,
|
|
useEventPosWithBoundingBox = false,
|
|
interactive = false,
|
|
onResize = null,
|
|
onMove = null,
|
|
noStyle = false,
|
|
hideOnUp = true
|
|
} = {}) {
|
|
super();
|
|
this.context = context;
|
|
this.noStyle = noStyle;
|
|
this.hideOnUp = hideOnUp;
|
|
this.padding = padding;
|
|
this.notchPosition = notchPosition;
|
|
this.notchSize = notchSize;
|
|
this.switchPos = switchPos;
|
|
this.fontSize = fontSize;
|
|
this.fontFamily = fontFamily;
|
|
this.minWidth = minWidth;
|
|
this.maxWidth = maxWidth;
|
|
this.normalColor = normalColor;
|
|
this.backgroundColor = backgroundColor;
|
|
this.keepWithin = keepWithin;
|
|
this.autoClose = autoClose;
|
|
this.resizeIcon = resizeIcon;
|
|
this.closeIcon = closeIcon;
|
|
this.closeCommand = closeCommand;
|
|
this.zIndex = zIndex;
|
|
this.parent = parent || document.body;
|
|
this.draggable = draggable;
|
|
this.posOffset = posOffset;
|
|
this.targetBoundingBox = targetBoundingBox;
|
|
this.useEventPosWithBoundingBox = useEventPosWithBoundingBox;
|
|
this.currentPos = null;
|
|
this.insertedNode = null;
|
|
this.loaded = false;
|
|
this.interactive = interactive;
|
|
this.onload = null;
|
|
this.onResize = onResize;
|
|
this.onMove = onMove;
|
|
if (content) {
|
|
this.show(content);
|
|
}
|
|
}
|
|
|
|
/** Setup popup with a dictionary of content types and contents.
|
|
* @param {Object} content - A dict object with type strings (text, img, html) as keys
|
|
* and corresponding values.
|
|
* @return {Popup} this
|
|
*/
|
|
setup(content) {
|
|
//console.log("Popup.setup", this.draggable)
|
|
this.content = content;
|
|
this.items = {};
|
|
this.element = document.createElement('div');
|
|
this.element.classList.add('popup');
|
|
this.setAlpha(this.element, 0);
|
|
// this.element.style.opacity = 0
|
|
Elements$1.addClass(this.element, 'unselectable');
|
|
this.notch = document.createElement('div');
|
|
Elements$1.setStyle(this.notch, this.notchStyle());
|
|
this.notch.className = 'notch';
|
|
this.setupDraggable();
|
|
if (this.closeIcon) {
|
|
let img = document.createElement('img');
|
|
img.setAttribute('draggable', false);
|
|
img.src = this.closeIcon;
|
|
img.style.position = 'absolute';
|
|
img.style.right = '0px';
|
|
img.style.top = '0px';
|
|
img.style.width = '16px';
|
|
img.style.height = '16px';
|
|
img.onclick = e => {
|
|
this.close();
|
|
};
|
|
this.element.appendChild(img);
|
|
}
|
|
if (this.resizeIcon) {
|
|
let img = document.createElement('img');
|
|
img.style.position = 'absolute';
|
|
img.style.right = '0px';
|
|
img.style.bottom = '0px';
|
|
img.style.width = '16px';
|
|
img.style.height = '16px';
|
|
img.src = this.resizeIcon;
|
|
img.setAttribute('draggable', true);
|
|
img.ondragstart = e => {
|
|
this.currentPos = { x: e.clientX, y: e.clientY };
|
|
return true
|
|
};
|
|
img.ondrag = e => {
|
|
e.preventDefault();
|
|
|
|
let target = this.element.querySelector('iframe') || this.element;
|
|
let delta = {
|
|
x: e.clientX - this.currentPos.x,
|
|
y: e.clientY - this.currentPos.y
|
|
};
|
|
|
|
this.currentPos = { x: e.clientX, y: e.clientY };
|
|
if (delta.x == 0 && delta.y == 0) return
|
|
|
|
let rect = target.getBoundingClientRect();
|
|
let width = rect.width + delta.x;
|
|
let height = rect.height + delta.y;
|
|
target.style.width = width + 'px';
|
|
target.style.height = height + 'px';
|
|
|
|
switch (this.notchPosition) {
|
|
case 'bottomLeft':
|
|
case 'bottomCenter':
|
|
let bottom = parseFloat(this.element.style.bottom);
|
|
this.element.style.bottom = bottom - delta.y + 'px';
|
|
break
|
|
}
|
|
//console.log("onResize", this.onResize)
|
|
if (this.onResize) {
|
|
this.onResize({ target, delta, width, height });
|
|
}
|
|
};
|
|
img.ondragend = e => {};
|
|
this.element.appendChild(img);
|
|
}
|
|
|
|
for (let key in content) {
|
|
console.log('using', key, this.loaded);
|
|
switch (key) {
|
|
case 'selector':
|
|
break
|
|
case 'text':
|
|
let text = document.createElement('span');
|
|
this.element.appendChild(text);
|
|
text.innerHTML = content[key];
|
|
Elements$1.setStyle(text, { color: this.normalColor });
|
|
Elements$1.addClass(text, 'unselectable');
|
|
Elements$1.addClass(text, 'PopupContent');
|
|
this.insertedNode = text;
|
|
this.loaded = true;
|
|
break
|
|
case 'img':
|
|
alert('img to be implemented');
|
|
break
|
|
case 'iframe':
|
|
let iframe = document.createElement('iframe');
|
|
iframe.setAttribute('frameBorder', 0);
|
|
iframe.src = content[key];
|
|
iframe.onload = e => {
|
|
let body = iframe.contentWindow.document.body;
|
|
let observer = new MutationObserver(() => {
|
|
this.iframeChanged(iframe);
|
|
});
|
|
observer.observe(iframe.contentWindow.document, {
|
|
attributes: true,
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true
|
|
});
|
|
let w = Math.max(body.scrollWidth, body.offsetWidth);
|
|
let h = Math.max(body.scrollHeight, body.offsetHeight);
|
|
iframe.style.width = w + 'px';
|
|
iframe.style.height = h + 'px';
|
|
this.layoutAfterInsert();
|
|
if (this.onload != null) {
|
|
this.onload();
|
|
}
|
|
this.loaded = true;
|
|
};
|
|
this.element.appendChild(iframe);
|
|
Elements$1.addClass(iframe, 'PopupContent');
|
|
this.insertIntoDOM();
|
|
return
|
|
case 'html':
|
|
this.loaded = false;
|
|
let div = document.createElement('div');
|
|
Elements$1.addClass(div, 'PopupContent');
|
|
this.element.appendChild(div);
|
|
div.innerHTML = content.html;
|
|
//console.log("insert", content)
|
|
let selector = content.selector;
|
|
if (selector) {
|
|
this.insertedNode = div.querySelector(selector);
|
|
if (this.insertedNode == null) {
|
|
div.innerHTML = `<p style="color:red;">Popup content not found. Missing ${selector}</p>`;
|
|
this.insertedNode = div.firstElementChild;
|
|
}
|
|
} else {
|
|
this.insertedNode = div.firstElementChild || div;
|
|
}
|
|
this.setAlpha(this.insertedNode, 0);
|
|
let images = this.element.querySelectorAll('img');
|
|
let total = 0;
|
|
if (images.length > 0) {
|
|
let count = 0;
|
|
for (let image of images) {
|
|
if (!image.complete && !image.src.startsWith('data:')) {
|
|
total += 1;
|
|
console.log('image not complete', image.src);
|
|
image.onload = e => {
|
|
count += 1;
|
|
if (count == total) {
|
|
this.loaded = true;
|
|
if (this.onload != null) {
|
|
this.onload();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (total == 0) {
|
|
this.loaded = true;
|
|
}
|
|
break
|
|
case 'node':
|
|
this.loaded = true;
|
|
Elements$1.addClass(content.node, 'PopupContent');
|
|
this.element.appendChild(content.node);
|
|
this.insertedNode = content.node;
|
|
this.setAlpha(this.insertedNode, 0);
|
|
break
|
|
default:
|
|
alert('Unexpected content type: ' + key);
|
|
break
|
|
}
|
|
}
|
|
this.insertIntoDOM();
|
|
this.layoutAfterInsert();
|
|
this.setupEventHandler();
|
|
return this
|
|
}
|
|
|
|
handleClose(e) {
|
|
let closing = this.closingEvent(e);
|
|
if (closing) {
|
|
this.close(e);
|
|
} else {
|
|
this.setupCloseHandler();
|
|
}
|
|
}
|
|
|
|
setupCloseHandler() {
|
|
let close = this.handleClose;
|
|
if (this.hideOnUp) {
|
|
if (window.PointerEvent)
|
|
this.parent.addEventListener('pointerup', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
else if (window.TouchEvent)
|
|
this.parent.addEventListener('touchend', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
else
|
|
this.parent.addEventListener('mouseup', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
} else {
|
|
if (window.PointerEvent)
|
|
this.parent.addEventListener('pointerdown', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
else if (window.TouchEvent)
|
|
this.parent.addEventListener('touchstart', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
else
|
|
this.parent.addEventListener('mousedown', close.bind(this), {
|
|
capture: true,
|
|
once: true
|
|
});
|
|
}
|
|
}
|
|
|
|
setupEventHandler() {
|
|
if (this.autoClose) {
|
|
this.setupCloseHandler();
|
|
}
|
|
}
|
|
|
|
closingEvent(e) {
|
|
if (this.interactive) {
|
|
let node = e.target.closest('.PopupContent');
|
|
return node == null
|
|
}
|
|
return true
|
|
}
|
|
|
|
iframeChanged(iframe) {
|
|
let body = iframe.contentWindow.document.body;
|
|
let w = Math.max(body.scrollWidth, body.offsetWidth);
|
|
let h = Math.max(body.scrollHeight, body.offsetHeight);
|
|
iframe.style.width = w + 'px';
|
|
iframe.style.height = h + 'px';
|
|
}
|
|
|
|
setupDraggable() {
|
|
if (this.draggable) {
|
|
let target = this.element;
|
|
target.setAttribute('draggable', true);
|
|
target.ondragstart = e => {
|
|
this.currentPos = { x: e.clientX, y: e.clientY };
|
|
var img = document.createElement('img');
|
|
img.src = 'data:image/gifbase64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
e.dataTransfer.setDragImage(img, 0, 0);
|
|
};
|
|
target.ondrag = e => {
|
|
e.preventDefault();
|
|
let delta = {
|
|
x: e.clientX - this.currentPos.x,
|
|
y: e.clientY - this.currentPos.y
|
|
};
|
|
this.currentPos = { x: e.clientX, y: e.clientY };
|
|
let left = parseFloat(target.style.left);
|
|
let top = parseFloat(target.style.top);
|
|
|
|
target.style.left = left + delta.x + 'px';
|
|
target.style.top = top + delta.y + 'px';
|
|
|
|
//console.log("Popup.ondrag", target, event.target)
|
|
if (this.onMove) {
|
|
this.onMove({ target, delta });
|
|
}
|
|
|
|
this.lastDrag = { left, top };
|
|
};
|
|
target.ondragend = e => {
|
|
target.style.left = this.lastDrag.left + 'px';
|
|
target.style.top = this.lastDrag.top + 'px';
|
|
this.currentPos = null;
|
|
};
|
|
}
|
|
}
|
|
|
|
moveDragged(target) {}
|
|
|
|
insertIntoDOM(layout = true) {
|
|
this.setAlpha(this.insertedNode, 0);
|
|
this.element.appendChild(this.notch);
|
|
this.parent.appendChild(this.element);
|
|
}
|
|
|
|
layoutAfterInsert() {
|
|
Elements$1.setStyle(this.element, this.defaultStyle());
|
|
this.layout();
|
|
//this.element.style.opacity = 1
|
|
}
|
|
|
|
/** Layout the menu items. Needed only in the subclass.
|
|
*/
|
|
layout() {}
|
|
|
|
remove() {
|
|
if (this.parent.contains(this.element)) this.parent.removeChild(this.element);
|
|
this.unregister(this.context);
|
|
}
|
|
|
|
/** Close and remove the Popup from the DOM tree.
|
|
*/
|
|
close(event) {
|
|
//console.log("Popup.close", this.closeCommand)
|
|
this.unregister(this.context);
|
|
if (this.closeCommand) {
|
|
this.closeCommand(this, () => this.remove(), event);
|
|
} else {
|
|
this.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the alpha value to show or hide the popup. Uses CSS transitions.
|
|
* (A former dependency on TweenLite has beeen removed.)
|
|
*
|
|
* @param {*} targets
|
|
* @param {*} value
|
|
* @memberof Popup
|
|
*/
|
|
setAlpha(targets, value) {
|
|
let objs = targets instanceof Array ? targets : [targets];
|
|
for (let obj of objs) {
|
|
if (value) {
|
|
obj.style.transition = 'opacity 0.2s ease-in';
|
|
}
|
|
obj.style.opacity = value;
|
|
}
|
|
// if (value) {
|
|
// TweenLite.to(targets, 0.2, { autoAlpha: value })
|
|
// }
|
|
// else {
|
|
// TweenLite.set(targets, { autoAlpha: 0 })
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* Starts a fade in animation.
|
|
*
|
|
* @memberof Popup
|
|
*/
|
|
fadeIn() {
|
|
this.setAlpha([this.element, this.insertedNode], 1);
|
|
}
|
|
|
|
/** Shows the Popup with the given commands at the specified point.
|
|
* @param {Object} content - A dict object with type strings (text, img, html) as keys
|
|
* and corresponding values.
|
|
* @param {Point} point - The position as x, y coordinates {px}.
|
|
* @return {Popup} this
|
|
*/
|
|
showAt(content, point) {
|
|
this.setup(content);
|
|
console.log('showAt', this.loaded);
|
|
if (this.loaded) {
|
|
this.placeAt(point);
|
|
this.fadeIn();
|
|
} else {
|
|
this.setAlpha([this.element, this.insertedNode], 0);
|
|
this.onload = () => {
|
|
this.layoutAfterInsert();
|
|
this.placeAt(point);
|
|
this.fadeIn();
|
|
};
|
|
}
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Place the origin, i.e. the upper left corner at the given position using CSS styles.
|
|
*
|
|
* @param {any} x
|
|
* @param {any} y
|
|
* @memberof Popup
|
|
*/
|
|
placeOrigin(x, y) {
|
|
Elements$1.setStyle(this.element, { left: x + 'px', top: y + 'px' });
|
|
}
|
|
|
|
/**
|
|
* Calculate the local coordinates within the keepWithin container.
|
|
*
|
|
* @param {any} x
|
|
* @param {any} y
|
|
* @returns
|
|
* @memberof Popup
|
|
*/
|
|
localPointWithin(x, y, width, height) {
|
|
let pt = { x, y };
|
|
return pt
|
|
}
|
|
|
|
withinDimensions() {
|
|
return {
|
|
width: this.keepWithin.offsetWidth,
|
|
height: this.keepWithin.offsetHeight
|
|
}
|
|
}
|
|
|
|
localDimensions() {
|
|
return {
|
|
width: this.element.offsetWidth,
|
|
height: this.element.offsetHeight
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the notch position depending on the local coordinates within the keepWithin container
|
|
* Divides the space vertically into top, center, bottom and horizontally into left, center, right
|
|
*
|
|
* @param {any} x
|
|
* @param {any} y
|
|
* @returns
|
|
* @memberof Popup
|
|
*/
|
|
notchPositionWithin(x, y) {
|
|
let horizontal = 'Center';
|
|
let vertical = 'center';
|
|
let { width, height } = this.withinDimensions();
|
|
let local = this.localPointWithin(x, y, width, height);
|
|
if (local.y < height * 0.33) {
|
|
vertical = 'top';
|
|
}
|
|
if (local.y > height * 0.66) {
|
|
vertical = 'bottom';
|
|
}
|
|
if (local.x < width * 0.33) {
|
|
horizontal = 'Left';
|
|
}
|
|
if (local.x > width * 0.66) {
|
|
horizontal = 'Right';
|
|
}
|
|
let result = vertical + horizontal;
|
|
if (result == 'centerCenter') return this.notchPosition
|
|
return result
|
|
}
|
|
|
|
placeAt(point) {
|
|
let x = point.x;
|
|
let y = point.y;
|
|
let notchPosition = this.notchPosition;
|
|
if (this.keepWithin != null) {
|
|
notchPosition = this.notchPositionWithin(x, y);
|
|
}
|
|
Elements$1.setStyle(this.notch, this.notchStyle(notchPosition));
|
|
this.notch.className = 'notch ' + notchPosition;
|
|
let { width, height } = this.localDimensions();
|
|
|
|
//if targetBoundingBox is set, popup is placed next to the rectangle
|
|
if (this.targetBoundingBox) {
|
|
let bbTop = this.targetBoundingBox.y;
|
|
let bbBottom = this.targetBoundingBox.y + this.targetBoundingBox.height;
|
|
let bbLeft = this.targetBoundingBox.x;
|
|
let bbRight = this.targetBoundingBox.x + this.targetBoundingBox.width;
|
|
//console.log("place popup with bb set:", x, y, bbTop, bbBottom, bbLeft, bbRight)
|
|
switch (notchPosition) {
|
|
case 'bottomLeft':
|
|
case 'bottomRight':
|
|
case 'bottomCenter':
|
|
y = bbTop;
|
|
if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2;
|
|
break
|
|
case 'topLeft':
|
|
case 'topRight':
|
|
case 'topCenter':
|
|
y = bbBottom;
|
|
if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2;
|
|
break
|
|
case 'centerRight':
|
|
x = bbLeft;
|
|
if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2;
|
|
break
|
|
case 'centerLeft':
|
|
x = bbRight;
|
|
if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2;
|
|
break
|
|
}
|
|
}
|
|
|
|
//calculate position depending on several (optional) parameters
|
|
switch (notchPosition) {
|
|
case 'bottomLeft':
|
|
x -= this.padding;
|
|
x -= this.notchSize;
|
|
y -= height;
|
|
y -= this.notchSize * 2;
|
|
y -= this.posOffset;
|
|
break
|
|
case 'bottomRight':
|
|
x -= width;
|
|
x += this.padding;
|
|
x += this.notchSize;
|
|
y -= height;
|
|
y -= this.notchSize * 2;
|
|
y -= this.posOffset;
|
|
break
|
|
case 'bottomCenter':
|
|
x -= width / 2;
|
|
y -= height;
|
|
y -= this.notchSize * 2;
|
|
y -= this.posOffset;
|
|
break
|
|
case 'topLeft':
|
|
x -= this.padding;
|
|
x -= this.notchSize;
|
|
y += this.notchSize * 2;
|
|
y += this.posOffset;
|
|
break
|
|
case 'topRight':
|
|
x -= width;
|
|
x += this.padding;
|
|
x += this.notchSize;
|
|
y += this.notchSize * 2;
|
|
y += this.posOffset;
|
|
break
|
|
case 'topCenter':
|
|
x -= width / 2;
|
|
y += this.notchSize * 2;
|
|
y += this.posOffset;
|
|
break
|
|
case 'centerRight':
|
|
x -= width + this.notchSize * 2;
|
|
x -= this.posOffset;
|
|
y -= height / 2;
|
|
break
|
|
case 'centerLeft':
|
|
//console.log("height", height)
|
|
y -= height / 2;
|
|
x += this.notchSize * 2;
|
|
x += this.posOffset;
|
|
break
|
|
}
|
|
this.placeOrigin(x, y);
|
|
}
|
|
|
|
/** Shows the Popup with the given commands at the current position.
|
|
* @param {Object} content - A dict object with type strings (text, img, html) as keys
|
|
* and corresponding values.
|
|
* @return {Popup} this
|
|
*/
|
|
show(content) {
|
|
this.setup(content);
|
|
this.fadeIn();
|
|
return this
|
|
}
|
|
|
|
/** Configuration object. Return default styles as CSS values.
|
|
*/
|
|
defaultStyle() {
|
|
let padding = this.padding;
|
|
let style = {
|
|
maxWidth: this.maxWidth + 'px',
|
|
zIndex: this.zIndex,
|
|
position: 'absolute'
|
|
};
|
|
if (this.minWidth) {
|
|
style.minWidth = this.minWidth + 'px';
|
|
}
|
|
if (!this.noStyle) {
|
|
Object.assign(style, {
|
|
borderRadius: Math.round(this.padding / 2) + 'px',
|
|
backgroundColor: this.backgroundColor,
|
|
padding: this.padding + 'px',
|
|
boxShadow: '0 10px 15px rgba(0, 0, 0, 0.3)',
|
|
fontFamily: this.fontFamily,
|
|
fontSize: this.fontSize,
|
|
stroke: 'black',
|
|
fill: 'white'
|
|
});
|
|
}
|
|
|
|
return style
|
|
}
|
|
|
|
/** Configuration object. Return notch styles as CSS values.
|
|
*/
|
|
notchStyle(notchPosition = null) {
|
|
if (notchPosition == null) {
|
|
notchPosition = this.notchPosition;
|
|
}
|
|
let width = 0;
|
|
let height = 0;
|
|
let left = this.padding;
|
|
let size = this.localDimensions();
|
|
if (notchPosition.endsWith('Right')) {
|
|
left = size.width - this.padding - this.notchSize * 2;
|
|
}
|
|
if (notchPosition.endsWith('Center')) {
|
|
left = size.width / 2 - this.notchSize;
|
|
}
|
|
left = Math.round(left) + 'px';
|
|
|
|
if (notchPosition.startsWith('bottom')) {
|
|
if (this.noStyle) {
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
bottom: -this.notchSize + 'px',
|
|
position: 'absolute',
|
|
borderStyle: 'solid',
|
|
borderTopWidth: this.notchSize + 'px',
|
|
borderRight: this.notchSize + 'px solid transparent',
|
|
borderLeft: this.notchSize + 'px solid transparent',
|
|
borderBottom: 0
|
|
}
|
|
} else {
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
boxShadow: '0 12px 15px rgba(0, 0, 0, 0.1)',
|
|
bottom: -this.notchSize + 'px',
|
|
position: 'absolute',
|
|
borderTop: this.notchSize + 'px solid ' + this.backgroundColor,
|
|
borderRight: this.notchSize + 'px solid transparent',
|
|
borderLeft: this.notchSize + 'px solid transparent',
|
|
borderBottom: 0
|
|
}
|
|
}
|
|
}
|
|
if (notchPosition.startsWith('top')) {
|
|
if (this.noStyle) {
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
top: -this.notchSize + 'px',
|
|
position: 'absolute',
|
|
borderStyle: 'solid',
|
|
borderBottomWidth: this.notchSize + 'px',
|
|
borderRight: this.notchSize + 'px solid transparent',
|
|
borderLeft: this.notchSize + 'px solid transparent',
|
|
borderTop: 0
|
|
}
|
|
} else {
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
top: -this.notchSize + 'px',
|
|
position: 'absolute',
|
|
borderBottom: this.notchSize + 'px solid ' + this.backgroundColor,
|
|
borderRight: this.notchSize + 'px solid transparent',
|
|
borderLeft: this.notchSize + 'px solid transparent',
|
|
borderTop: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.noStyle) {
|
|
if (notchPosition.endsWith('Left')) {
|
|
left = -this.notchSize * 2 + 'px';
|
|
}
|
|
|
|
if (notchPosition.endsWith('Right')) {
|
|
left = size.width + 'px';
|
|
}
|
|
|
|
let top = size.height / 2 - this.notchSize;
|
|
top = Math.round(top) + 'px';
|
|
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
top,
|
|
borderRightWidth: this.notchSize,
|
|
borderLeftWidth: this.notchSize,
|
|
position: 'absolute',
|
|
borderTop: this.notchSize + 'px solid transparent',
|
|
borderBottom: this.notchSize + 'px solid transparent'
|
|
}
|
|
} else {
|
|
let borderRight = this.notchSize + 'px solid transparent';
|
|
let borderLeft = this.notchSize + 'px solid transparent';
|
|
let top = size.height / 2 - this.notchSize;
|
|
if (notchPosition.endsWith('Left')) {
|
|
left = -this.notchSize * 2 + 'px';
|
|
borderRight = this.notchSize + 'px solid ' + this.backgroundColor;
|
|
this.element.style.boxShadow = '15px 10px 15px rgba(0, 0, 0, 0.3)';
|
|
}
|
|
if (notchPosition.endsWith('Right')) {
|
|
left = size.width + 'px';
|
|
borderLeft = this.notchSize + 'px solid ' + this.backgroundColor;
|
|
this.element.style.boxShadow = '15px 5px 15px rgba(0, 0, 0, 0.3)';
|
|
}
|
|
|
|
top = Math.round(top) + 'px';
|
|
|
|
return {
|
|
width,
|
|
height,
|
|
left,
|
|
top,
|
|
borderRight,
|
|
borderLeft,
|
|
// boxShadow,
|
|
position: 'absolute',
|
|
borderTop: this.notchSize + 'px solid transparent',
|
|
borderBottom: this.notchSize + 'px solid transparent'
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Convenient static methods to show and reuse a Popup implemented
|
|
* as a class variable.
|
|
* @param {Object} content - A dict object with type strings (text, img, html) as keys
|
|
* and corresponding values.
|
|
* @param {Point} point - The position as x, y coordinates {px}.
|
|
* @param {boolean} autoClose - Autoclose the menu after selecting an item.
|
|
*/
|
|
static open(
|
|
content,
|
|
point,
|
|
{
|
|
parent = null,
|
|
context = window,
|
|
fontSize = '1em',
|
|
fontFamily = 'Arial',
|
|
padding = 16,
|
|
notchSize = 10,
|
|
switchPos = false,
|
|
minWidth = null,
|
|
maxWidth = 800,
|
|
backgroundColor = '#EEE',
|
|
zIndex = 0,
|
|
normalColor = '#444',
|
|
closeIcon = null,
|
|
resizeIcon = null,
|
|
closeCommand = null,
|
|
autoClose = true,
|
|
keepWithin = null,
|
|
draggable = false,
|
|
posOffset = 0,
|
|
targetBoundingBox = null,
|
|
useEventPosWithBoundingBox = false,
|
|
interactive = false,
|
|
onResize = null,
|
|
onMove = null
|
|
} = {}
|
|
) {
|
|
let notchPosition = switchPos && point.y < 50 ? 'topCenter' : 'bottomCenter';
|
|
let popup = new Popup$1({
|
|
parent,
|
|
context,
|
|
fontFamily,
|
|
fontSize,
|
|
padding,
|
|
notchSize,
|
|
switchPos,
|
|
minWidth,
|
|
maxWidth,
|
|
backgroundColor,
|
|
normalColor,
|
|
notchPosition,
|
|
zIndex,
|
|
autoClose,
|
|
keepWithin,
|
|
closeCommand,
|
|
closeIcon,
|
|
resizeIcon,
|
|
draggable,
|
|
posOffset,
|
|
targetBoundingBox,
|
|
useEventPosWithBoundingBox,
|
|
interactive,
|
|
onResize,
|
|
onMove
|
|
});
|
|
popup.register(context);
|
|
popup.showAt(content, point);
|
|
return popup
|
|
}
|
|
|
|
/** Convenient static method to close the Popup implemented
|
|
* as a class variable. Calls the close command.
|
|
*/
|
|
static closePopup(context = window) {
|
|
let popup = Poppable.registrations.get(context);
|
|
if (popup != null) {
|
|
popup.close();
|
|
}
|
|
}
|
|
|
|
/** Convenient static methods to remove the Popup implemented
|
|
* as a class variable. Removes the popup without performing the close command.
|
|
*/
|
|
static remove(context = window) {
|
|
let popup = Poppable.registrations.get(context);
|
|
if (popup != null) {
|
|
popup.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenient static method to compute the clicked rect of objects that have multiple clients rects.
|
|
* Needed to position popups correctly above objects with line breaks, e.g. spans
|
|
*
|
|
* @static
|
|
* @param {*} event
|
|
* @returns {*} DOMRect
|
|
* @memberof Popup
|
|
*/
|
|
static targetRect(event) {
|
|
let target = event.target;
|
|
let x = event.pageX;
|
|
let y = event.pageY;
|
|
for (let rect of target.getClientRects()) {
|
|
let withinX = x >= rect.left && x <= rect.left + rect.width;
|
|
let withinY = y >= rect.top && y <= rect.top + rect.height;
|
|
if (withinX && withinY) {
|
|
return rect
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Convenient static method to compute the center of objects that have multiple clients rects.
|
|
* Needed to position popups correctly above objects with line breaks, e.g. spans
|
|
*
|
|
* @static
|
|
* @param {*} event
|
|
* @returns {*} Point
|
|
* @memberof Popup
|
|
*/
|
|
static targetCenter(event) {
|
|
let target = event.target;
|
|
let x = event.pageX;
|
|
let y = event.pageY;
|
|
let rect = Popup$1.targetRect(event);
|
|
if (rect != null) {
|
|
x = rect.left + rect.width / 2;
|
|
y = rect.top + rect.height / 2;
|
|
}
|
|
return { x, y }
|
|
}
|
|
}
|
|
|
|
/** A Popup Menu that shows text labels in a vertical row.
|
|
*/
|
|
class PopupMenu extends Popup$1 {
|
|
/**
|
|
* The constructor.
|
|
* @constructor
|
|
* @param {DOM Element} parent - The DOM parent element.
|
|
* @param {Object} commands - A dict object with command label strings as keys
|
|
* and command functions as values.
|
|
* @param {string} fontSize - Describes the font size as CSS value
|
|
* @param {number || string} padding - Describes the padding as CSS value
|
|
* @param {number || string} notchSize - Describes the size of the notch (callout) as CSS value
|
|
* @param {string} highlightColor - The color of highlighted menu items as CSS value
|
|
* @param {string} backgroundColor - The color of the background as CSS value
|
|
* @param {string} normalColor - The color of normal menu items as CSS value
|
|
* @param {DOM Element} keepWithin - The container to stay within
|
|
* @param {boolean} autoClose - Autoclose the menu after selecting an item
|
|
*/
|
|
constructor({
|
|
parent = null,
|
|
commands = null,
|
|
fontSize = '1em',
|
|
fontFamily = 'Arial',
|
|
padding = 16,
|
|
zIndex = 1,
|
|
spacing = '0px',
|
|
switchPos = false,
|
|
notchSize = 10,
|
|
maxWidth = 800,
|
|
backgroundColor = '#EEE',
|
|
normalColor = '#444',
|
|
highlightColor = 'black',
|
|
notchPosition = 'bottomLeft',
|
|
keepWithin = null,
|
|
autoClose = true
|
|
} = {}) {
|
|
super({
|
|
parent,
|
|
fontSize,
|
|
fontFamily,
|
|
padding,
|
|
notchSize,
|
|
notchPosition,
|
|
backgroundColor,
|
|
keepWithin,
|
|
normalColor,
|
|
autoClose
|
|
});
|
|
this.commands = commands;
|
|
this.zIndex = zIndex;
|
|
this.switchPos = switchPos;
|
|
this.spacing = spacing;
|
|
this.highlightColor = highlightColor;
|
|
}
|
|
|
|
/** Setup menu with a dictionary of command labels and command functions.
|
|
* @param {Object} commands - A dict object with command label strings as keys
|
|
* and command functions as values.
|
|
* @return {PopupMenu} this
|
|
*/
|
|
setup(commands) {
|
|
this.commands = commands;
|
|
this.items = {};
|
|
this.element = document.createElement('div');
|
|
this.element.style.zIndex = this.zIndex;
|
|
Elements$1.addClass(this.element, 'unselectable');
|
|
this.notch = document.createElement('div');
|
|
Elements$1.setStyle(this.notch, this.notchStyle());
|
|
for (let key in commands) {
|
|
let item = document.createElement('div');
|
|
this.element.appendChild(item);
|
|
item.innerHTML = key;
|
|
item.style.paddingBottom = item.style.paddingTop = this.spacing;
|
|
Elements$1.setStyle(item, {
|
|
color: this.normalColor,
|
|
cursor: 'default'
|
|
});
|
|
Elements$1.addClass(item, 'unselectable');
|
|
Elements$1.addClass(item, 'popupMenuItem');
|
|
this.items[key] = item;
|
|
item.onclick = event => {
|
|
this.perform(event, key);
|
|
};
|
|
item.ontap = event => {
|
|
this.perform(event, key);
|
|
};
|
|
item.onmouseover = event => {
|
|
this.over(event, key);
|
|
};
|
|
item.onmouseout = event => {
|
|
this.out(event, key);
|
|
};
|
|
}
|
|
|
|
this.element.appendChild(this.notch);
|
|
this.parent.appendChild(this.element);
|
|
this.insertedNode = this.element;
|
|
Elements$1.setStyle(this.element, this.defaultStyle());
|
|
this.layout();
|
|
return this
|
|
}
|
|
|
|
/** Execute a menu command.
|
|
* @param {object} event - The trigger event.
|
|
* @param {string} key - The selected key.
|
|
*/
|
|
perform(event, key) {
|
|
let func = this.commands[key];
|
|
if (this.autoClose) {
|
|
this.close();
|
|
}
|
|
setTimeout(
|
|
(event, key) => {
|
|
func(event, key);
|
|
},
|
|
20,
|
|
event,
|
|
key
|
|
);
|
|
}
|
|
|
|
/** Update the menu item denoted by key.
|
|
* @param {string} key - The selected key.
|
|
* @param {boolean} highlight - Show the item highlighted.
|
|
*/
|
|
update(key, highlight = false) {
|
|
let text = this.items[key];
|
|
text.style.color = highlight ? this.highlightColor : this.normalColor;
|
|
}
|
|
|
|
/** Mouse over handöer.
|
|
* @param {Event} event - The mouse event.
|
|
* @param {boolean} key - The selected key.
|
|
*/
|
|
over(event, key) {
|
|
for (let k in this.items) {
|
|
this.update(k, k == key);
|
|
}
|
|
}
|
|
|
|
/** Mouse out handöer.
|
|
* @param {Event} event - The mouse event.
|
|
* @param {boolean} key - The selected key.
|
|
*/
|
|
out(event, key) {
|
|
this.update(key);
|
|
}
|
|
|
|
/** Shows the PopupMenu with the given commands at the specified point.
|
|
* @param {Object} commands - A dict object with command label strings as keys
|
|
* and command functions as values.
|
|
* @param {Point} point - The position as x, y coordinates {px}.
|
|
* @return {PopupMenu} this
|
|
*/
|
|
showAt(commands, point) {
|
|
this.show(commands);
|
|
this.placeAt(point);
|
|
return this
|
|
}
|
|
|
|
/** Convenient static methods to show and reuse a PopupMenu implemented
|
|
* as a class variable.
|
|
* @param {Object} commands - A dict object with command label strings as keys
|
|
* and command functions as values.
|
|
* @param {Point} point - The position as x, y coordinates {px}.
|
|
* @param {string} fontSize - Describes the font size as CSS value
|
|
* @param {number || string} padding - Describes the padding as CSS value
|
|
* @param {number || string} notchSize - Describes the size of the notch (callout) as CSS value
|
|
* @param {string} highlightColor - The color of highlighted menu items as CSS value
|
|
* @param {string} backgroundColor - The color of the background as CSS value
|
|
* @param {string} normalColor - The color of normal menu items as CSS value
|
|
* @param {boolean} autoClose - Autoclose the menu after selecting an item
|
|
*/
|
|
static open(
|
|
commands,
|
|
point,
|
|
{
|
|
parent = null,
|
|
context = window,
|
|
fontSize = '1em',
|
|
fontFamily = 'Arial',
|
|
padding = 16,
|
|
zIndex = 1,
|
|
spacing = '0px',
|
|
switchPos = false,
|
|
notchSize = 10,
|
|
maxWidth = 800,
|
|
keepWithin = null,
|
|
backgroundColor = '#EEE',
|
|
normalColor = '#444',
|
|
autoClose = true
|
|
} = {}
|
|
) {
|
|
let registered = Poppable.get(context);
|
|
if (registered) {
|
|
this.closePopup();
|
|
return
|
|
}
|
|
console.log('open', point);
|
|
let notchPosition = point.y < 50 && switchPos ? 'topCenter' : 'bottomCenter';
|
|
let popup = new PopupMenu({
|
|
parent,
|
|
fontSize,
|
|
padding,
|
|
zIndex,
|
|
spacing,
|
|
switchPos,
|
|
notchSize,
|
|
notchPosition,
|
|
maxWidth,
|
|
backgroundColor,
|
|
normalColor,
|
|
notchPosition,
|
|
keepWithin,
|
|
autoClose
|
|
});
|
|
popup.showAt(commands, point);
|
|
popup.register(context);
|
|
popup.closeEventListener = e => {
|
|
if (this.eventOutside(e)) this.closePopup(context);
|
|
};
|
|
if (autoClose) {
|
|
context.addEventListener('mousedown', popup.closeEventListener, true);
|
|
context.addEventListener('touchstart', popup.closeEventListener, true);
|
|
context.addEventListener('pointerdown', popup.closeEventListener, true);
|
|
}
|
|
}
|
|
|
|
static eventOutside(e) {
|
|
return !Elements$1.hasClass(e.target, 'popupMenuItem')
|
|
}
|
|
|
|
/** Convenient static methods to close the PopupMenu implemented
|
|
* as a class variable.
|
|
*/
|
|
static closePopup(context = window) {
|
|
let registered = Poppable.get(context);
|
|
if (registered) {
|
|
registered.close();
|
|
context.removeEventListener('mousedown', registered.closeEventListener);
|
|
context.removeEventListener('touchstart', registered.closeEventListener);
|
|
context.removeEventListener('pointerdown', registered.closeEventListener);
|
|
}
|
|
}
|
|
}
|
|
|
|
class FrameContainer {
|
|
constructor(element) {
|
|
this.element = element;
|
|
this.delegate = new InteractionMapper(element, this, {
|
|
mouseWheelElement: window
|
|
});
|
|
}
|
|
|
|
capture(event) {
|
|
return true
|
|
}
|
|
|
|
findTarget(event, local, global) {
|
|
let found = document.elementFromPoint(global.x, global.y);
|
|
let iframe = found.querySelector('iframe');
|
|
if (iframe) {
|
|
let p = Points$1.fromPageToNode(found, global);
|
|
let doc = iframe.contentWindow.document;
|
|
let target = doc.elementFromPoint(p.x, p.y);
|
|
if (target != null) {
|
|
console.log('iframe element', target);
|
|
return new FrameTarget(iframe, target)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
|
|
class FrameTarget {
|
|
constructor(frame, target, debug = false) {
|
|
this.frame = frame;
|
|
this.target = target;
|
|
this.debug = debug;
|
|
}
|
|
|
|
capture(event) {
|
|
return true
|
|
}
|
|
|
|
simulateMouseEvent(type, point) {
|
|
let p = Points$1.fromPageToNode(this.frame, point);
|
|
let event = new MouseEvent(type, {
|
|
view: this.frame.contentWindow,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: p.x,
|
|
clientY: p.y
|
|
});
|
|
this.target.dispatchEvent(event);
|
|
}
|
|
|
|
createTouchList(pointMap) {
|
|
let touches = [];
|
|
let doc = this.frame.contentWindow.document;
|
|
for (let key of pointMap.keys()) {
|
|
let point = pointMap.get(key);
|
|
let p = Points$1.fromPageToNode(this.frame, point);
|
|
let touchTarget = doc.elementFromPoint(p.x, p.y);
|
|
let touch = new Touch(undefined, touchTarget, key, p.x, p.y, p.x, p.y);
|
|
touches.push(touch);
|
|
}
|
|
return new TouchList(...touches)
|
|
}
|
|
|
|
simulateTouchEventChrome(type, point, pointMap) {
|
|
let doc = this.frame.contentWindow.document;
|
|
let p = Points$1.fromPageToNode(this.frame, point);
|
|
let touchTarget = doc.elementFromPoint(p.x, p.y);
|
|
const touchObj = new Touch({
|
|
identifier: Date.now(),
|
|
target: touchTarget,
|
|
clientX: p.x,
|
|
clientY: p.y,
|
|
pageX: p.x,
|
|
pageY: p.y,
|
|
radiusX: 2.5,
|
|
radiusY: 2.5,
|
|
rotationAngle: 10,
|
|
force: 0.5
|
|
});
|
|
|
|
const touchEvent = new TouchEvent(type, {
|
|
cancelable: true,
|
|
bubbles: true,
|
|
touches: [touchObj],
|
|
targetTouches: [touchObj],
|
|
changedTouches: [touchObj],
|
|
shiftKey: false
|
|
});
|
|
if (this.debug) console.log('simulateTouchEventChrome', touchEvent);
|
|
this.target.dispatchEvent(touchEvent);
|
|
}
|
|
|
|
simulateTouchEventSafari(type, point, pointMap, touchEventKey = 'targetTouches') {
|
|
let p = Points$1.fromPageToNode(this.frame, point);
|
|
let data = {
|
|
view: this.frame.contentWindow,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: p.x,
|
|
clientY: p.y
|
|
};
|
|
data[touchEventKey] = this.createTouchList(pointMap);
|
|
let event = new TouchEvent(type, data);
|
|
if (this.debug) console.log('simulateTouchEventChrome', touchEvent);
|
|
this.target.dispatchEvent(event);
|
|
}
|
|
|
|
simulateTouchEvent(type, point, pointMap, touchEventKey = 'targetTouches') {
|
|
if (Capabilities.isSafari) {
|
|
this.simulateTouchEventSafari(type, point, pointMap, touchEventKey);
|
|
} else {
|
|
this.simulateTouchEventChrome(type, point, pointMap);
|
|
}
|
|
}
|
|
|
|
isMouseLikeEvent(event) {
|
|
return event.type.startsWith('mouse') || event.type.startsWith('pointer')
|
|
}
|
|
|
|
onStart(event, interaction) {
|
|
if (this.debug) console.log('onStart', this.frame.parentNode);
|
|
for (let [key, point] of interaction.current.entries()) {
|
|
if (this.isMouseLikeEvent(event)) {
|
|
this.simulateMouseEvent('mousedown', point);
|
|
} else {
|
|
this.simulateTouchEvent('touchstart', point, interaction.current);
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
onMove(event, interaction) {
|
|
if (this.debug) console.log('onMove');
|
|
for (let [key, point] of interaction.current.entries()) {
|
|
if (this.isMouseLikeEvent(event)) {
|
|
this.simulateMouseEvent('mousemove', point);
|
|
} else {
|
|
this.simulateTouchEvent('touchmove', point, interaction.current);
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
onEnd(event, interaction) {
|
|
if (this.debug) console.log('onEnd');
|
|
for (let [key, point] of interaction.current.entries()) {
|
|
if (this.isMouseLikeEvent(event)) {
|
|
this.simulateMouseEvent('mouseend', point);
|
|
} else {
|
|
this.simulateTouchEvent('touchend', point, interaction.ended, 'changedTouches');
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class Inspect {
|
|
// Code inspection functions
|
|
|
|
static allScriptSources() {
|
|
let sources = [];
|
|
let scripts = document.getElementsByTagName('script');
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
console.dir(scripts[i]);
|
|
sources.push(scripts[i]);
|
|
}
|
|
return sources
|
|
}
|
|
}
|
|
|
|
/* globals Power0 */
|
|
/* eslint no-console: ["error", { allow: ["log", "info", "warn", "error"] }] */
|
|
|
|
/**
|
|
* Imports
|
|
*/
|
|
|
|
/**
|
|
* A class that can be used to perform automated user interface tests.
|
|
*
|
|
* @example
|
|
* // Create the UITest object
|
|
* const test = new UITest({
|
|
* timeScale: 2
|
|
* })
|
|
*
|
|
* // Add an action to the test case
|
|
* test.tap(button, {eventType: 'click'})
|
|
*
|
|
* // Start the test case
|
|
* test.start()
|
|
*
|
|
* @class
|
|
* @see {@link https://www.iwm-tuebingen.de/iwmbrowser/lib/pixi/uitest.html|DocTest}
|
|
*/
|
|
class UITest {
|
|
/**
|
|
* Creates an instance of an UITest.
|
|
*
|
|
* In the background, the class UITest uses the Greensock TimelineMax class. The opts object is passed directly to the TimelineMax class, so it can use any key that uses the TimelineMax class.
|
|
*
|
|
* @constructor
|
|
* @param {object} [opts] - An options object to specify the behaviour of the test case.
|
|
* @param {number} [opts.timeScale=1] - The speed at which the test should run, see https://greensock.com/docs/TimelineMax/timeScale().
|
|
* @param {string} [opts.eventType=auto] - The type of events which should be used. Possible values: pointer, touch, mouse, auto. If set to auto, the eventType is set depending on the support of the browser used.
|
|
* @param {boolean} [opts.debug=false] - If set to true, multiple informations will be print to the console.
|
|
* @param {number} [opts.defaultInterval] - The interval used when no action is specified for an action.
|
|
*/
|
|
constructor(opts = {}) {
|
|
this.opts = Object.assign(
|
|
{},
|
|
{
|
|
timeScale: 1,
|
|
eventType: 'auto',
|
|
debug: false,
|
|
defaultInterval: null
|
|
},
|
|
opts
|
|
);
|
|
|
|
// timeline
|
|
//--------------------
|
|
this._timeline = new TimelineMax(
|
|
Object.assign(
|
|
{},
|
|
{
|
|
paused: true
|
|
},
|
|
this.opts
|
|
)
|
|
);
|
|
this._timeline.timeScale(this.opts.timeScale);
|
|
|
|
// eventType
|
|
//--------------------
|
|
if (this.opts.eventType === 'auto') {
|
|
if (window.PointerEvent) {
|
|
this.opts.eventType = 'pointer';
|
|
} else if ('ontouchstart' in window) {
|
|
this.opts.eventType = 'touch';
|
|
} else {
|
|
this.opts.eventType = 'mouse';
|
|
}
|
|
}
|
|
|
|
if (this.opts.debug) {
|
|
console.log(`Event type: ${this.opts.eventType}`);
|
|
}
|
|
|
|
this._timelinePositions = [0];
|
|
this._actions = 0;
|
|
|
|
// setup
|
|
//-----------------
|
|
this.setup();
|
|
}
|
|
|
|
/**
|
|
* Generates the required structure.
|
|
*
|
|
* @private
|
|
* @return {UITest} A reference to the UITest for chaining.
|
|
*/
|
|
setup() {
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Gets the Greensock TimelineMax object, used in the background of UITest.
|
|
*
|
|
* @member {TimelineMax}
|
|
*/
|
|
get timeline() {
|
|
return this._timeline
|
|
}
|
|
|
|
/**
|
|
* Starts the test case and executes the corresponding statements in the specified order.
|
|
*
|
|
* @return {UITest} A reference to the UITest for chaining.
|
|
*/
|
|
start() {
|
|
this._timeline.play();
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Stops the test case and stops executing any further instructions.
|
|
*
|
|
* @return {UITest} A reference to the UITest for chaining.
|
|
*/
|
|
stop() {
|
|
this._timeline.pause();
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Clears all instructions of the test case.
|
|
*
|
|
* @return {UITest} A reference to the UITest for chaining.
|
|
*/
|
|
clear() {
|
|
this._timeline.clear();
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Restarts the test case.
|
|
*
|
|
* @return {UITest} A reference to the UITest for chaining.
|
|
*/
|
|
restart() {
|
|
this._timeline.restart();
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Executes a tap event (pointerdown, pointerup) on a specific element.
|
|
*
|
|
* @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method.
|
|
* @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object.
|
|
* @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback().
|
|
* @param {object} [opts] - An options object to specify the behaviour of the action.
|
|
* @param {function} [opts.onStart] - A function that runs after the first event is fired. Will not be fired if only one event is running (for example, a click event). Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {function} [opts.onComplete] - A function that runs after the second event is fired. Always fired, even if only one event is running (for example, a click event). Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {string[]} [opts.eventTypes=['pointerdown', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified).
|
|
* @param {string} [opts.eventType] - If you want the tap method to fire only one event (for example, a click event), you can specify the opts.eventType parameter. If eventType is not null, the parameter opts.eventTypes is ignored.
|
|
* @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed.
|
|
* @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not.
|
|
* @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring.
|
|
*/
|
|
tap(element, position, timelinePosition, opts = {}) {
|
|
[position, timelinePosition, opts] = this.reorderArguments(arguments);
|
|
this._timelinePositions.push(timelinePosition);
|
|
|
|
// debug
|
|
//--------------------
|
|
if (this.opts.debug)
|
|
console.log('tap params', {
|
|
element,
|
|
position,
|
|
timelinePosition,
|
|
opts
|
|
});
|
|
|
|
// opts
|
|
//--------------------
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
onStart: null,
|
|
onComplete: null,
|
|
eventTypes: this.resolveEvents(['down', 'up']),
|
|
eventType: null,
|
|
context: window,
|
|
bubbles: true,
|
|
cancelable: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
if (opts.eventType) {
|
|
opts.eventTypes = opts.eventType;
|
|
}
|
|
opts.eventTypes = Array.isArray(opts.eventTypes) ? opts.eventTypes : [opts.eventTypes];
|
|
|
|
// timeline
|
|
//--------------------
|
|
this._timeline.addCallback(
|
|
position => {
|
|
// element
|
|
//--------------------
|
|
const elem = Util.extractElement(opts.context, element);
|
|
|
|
// position
|
|
//--------------------
|
|
if (position === null) {
|
|
const rect = elem.getBoundingClientRect();
|
|
position = [rect.width / 2, rect.height / 2];
|
|
}
|
|
|
|
// coords
|
|
//--------------------
|
|
const coords = Util.extractPosition(position);
|
|
if (this.opts.debug) console.log('local coords', coords);
|
|
|
|
// eventTypes
|
|
//--------------------
|
|
if (opts.eventTypes.length === 1) {
|
|
opts.eventTypes.unshift(null);
|
|
}
|
|
|
|
// event opts
|
|
//--------------------
|
|
const eventOpts = {
|
|
bubbles: opts.bubbles,
|
|
cancelable: opts.cancelable
|
|
};
|
|
|
|
if (opts.eventTypes[0]) {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const eventStart = Event$1.create(elem, coords, opts.eventTypes[0], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', eventStart);
|
|
elem.dispatchEvent(eventStart);
|
|
|
|
// onStart
|
|
//--------------------
|
|
if (opts.onStart) {
|
|
opts.onStart.call(this, eventStart);
|
|
}
|
|
}
|
|
|
|
// create and dispatch event
|
|
//--------------------
|
|
const eventComplete = Event$1.create(elem, coords, opts.eventTypes[1], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', eventComplete);
|
|
elem.dispatchEvent(eventComplete);
|
|
|
|
// onComplete
|
|
//--------------------
|
|
if (opts.onComplete) {
|
|
opts.onComplete.call(this, eventComplete);
|
|
}
|
|
},
|
|
timelinePosition,
|
|
[position]
|
|
);
|
|
|
|
this._actions++;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Executes a pan event (pointerdown, pointermove, pointerup) on a specific element.
|
|
*
|
|
* @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method.
|
|
* @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object.
|
|
* @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback().
|
|
* @param {object} [opts] - An options object to specify the behaviour of the action.
|
|
* @param {function} [opts.onStart] - A function that runs after the first event is fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {function} [opts.onUpdate] - A function that runs after each execution of the second event. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {function} [opts.onComplete] - A function that runs after the third event is fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {number[]|object|PIXI.DisplayObject} [opts.to={x: 0, y: 0}] - The target of the pan process. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object.
|
|
* @param {number} [opts.duration=1] - The duration of the pan animation in seconds, see https://greensock.com/docs/TweenLite/duration().
|
|
* @param {Ease} [opts.ease=Power0.easeNone] - The easing of the pan animation, see https://greensock.com/docs/Easing.
|
|
* @param {string[]} [opts.eventTypes=['pointerdown', 'pointermove', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified).
|
|
* @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed.
|
|
* @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not.
|
|
* @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring.
|
|
*/
|
|
pan(element, position, timelinePosition, opts = {}) {
|
|
[position, timelinePosition, opts] = this.reorderArguments(arguments);
|
|
this._timelinePositions.push(timelinePosition);
|
|
|
|
// debug
|
|
//--------------------
|
|
if (this.opts.debug)
|
|
console.log('tap params', {
|
|
element,
|
|
position,
|
|
timelinePosition,
|
|
opts
|
|
});
|
|
|
|
// opts
|
|
//--------------------
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
onStart: null,
|
|
onUpdate: null,
|
|
onComplete: null,
|
|
to: { x: 0, y: 0 },
|
|
duration: 1,
|
|
ease: Power0.easeNone,
|
|
eventTypes: this.resolveEvents(['down', 'move', 'up']),
|
|
context: window,
|
|
bubbles: true,
|
|
cancelable: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
// timeline
|
|
//--------------------
|
|
this._timeline.addCallback(
|
|
position => {
|
|
// element
|
|
//--------------------
|
|
const elem = Util.extractElement(opts.context, element);
|
|
|
|
// coords
|
|
//--------------------
|
|
const from = Util.extractPosition(position);
|
|
|
|
// event opts
|
|
//--------------------
|
|
const eventOpts = {
|
|
bubbles: opts.bubbles,
|
|
cancelable: opts.cancelable
|
|
};
|
|
|
|
const gsOpts = {
|
|
ease: opts.ease,
|
|
onStart: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[0], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onStart
|
|
//--------------------
|
|
if (opts.onStart) {
|
|
opts.onStart.call(this, event);
|
|
}
|
|
},
|
|
onUpdate: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[1], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onUpdate
|
|
//--------------------
|
|
if (opts.onUpdate) {
|
|
opts.onUpdate.call(this, event);
|
|
}
|
|
},
|
|
onComplete: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[2], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onComplete
|
|
//--------------------
|
|
if (opts.onComplete) {
|
|
opts.onComplete.call(this, event);
|
|
}
|
|
}
|
|
};
|
|
|
|
// to
|
|
//--------------------
|
|
const object = Util.extractTo(opts);
|
|
Object.assign(gsOpts, object);
|
|
|
|
// drag animation
|
|
//--------------------
|
|
TweenLite.to(from, opts.duration, gsOpts);
|
|
},
|
|
timelinePosition,
|
|
[position]
|
|
);
|
|
|
|
this._actions++;
|
|
|
|
return this
|
|
}
|
|
|
|
/**
|
|
* Executes a pinch event (pointerdown, pointermove, pointerup) on a specific element with two "fingers" simultaneously.
|
|
*
|
|
* @param {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method.
|
|
* @param {number[]|object|PIXI.DisplayObject} [position=The center of the element.] - The local position of the event in the context of the specified HTML element. If no position is specified, the center of the HTML element is used. The position can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object.
|
|
* @param {number} [timelinePosition=One second after the last action.] - The position in seconds when the event should be triggered, see shttps://greensock.com/docs/TimelineMax/addCallback().
|
|
* @param {object} [opts] - An options object to specify the behaviour of the action.
|
|
* @param {function} [opts.onStart] - A function that runs after the first events are fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {function} [opts.onUpdate] - A function that runs after each execution of the second events. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {function} [opts.onComplete] - A function that runs after the third events are fired. Receives the fired event object as the first parameter. The test case (UITest) is bound to this.
|
|
* @param {boolean} [opts.doubleCallbacks=false] - The callbacks onStart, onUpdate and onComplete will be fired only for one finger. If set to true, the events will be fired for both fingers.
|
|
* @param {number} [opts.distance=100] - The distance in pixels, how far the two "fingers" should move apart. If to or bezier specified, distance is ignored.
|
|
* @param {number[][]|object[]|PIXI.DisplayObject[]} [opts.to] - The targets of the pinch process. The position must be an array with two entries. An entry can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. If bezier is specified, to is ignored.
|
|
* @param {number[][]|object[]|PIXI.DisplayObject[]} [opts.bezier] - The targets of the pinch process. The position must be an array with two entries. An entry may be an array of positions or a bezier object (https://greensock.com/docs/Plugins/BezierPlugin). A position in the array or the values array of the bezier object can be specified as an array of numbers, as an object with the two properties x and y, or as a PIXI.Display object. If bezier is specified, to is ignored.
|
|
* @param {number} [opts.duration=1] - The duration of the pan animation in seconds, see https://greensock.com/docs/TweenLite/duration().
|
|
* @param {Ease} [opts.ease=Power0.easeNone] - The easing of the pan animation, see https://greensock.com/docs/Easing.
|
|
* @param {string[]} [opts.eventTypes=['pointerdown', 'pointermove', 'pointerup']] - The event types to use. If no types are specified, the event types specified in the UITest constructor are used (or auto if not specified).
|
|
* @param {Window|Frame} [opts.context=window] - The context within which the optionally specified element selector should be executed.
|
|
* @param {boolean} [opts.bubbles=true] - The Event property bubbles indicates whether the event bubbles up through the DOM or not.
|
|
* @param {boolean} [opts.cancelable=true] - Events' cancelable property indicates if the event can be canceled, and therefore prevented as if the event never happened. If the event is not cancelable, then its cancelable property will be false and the event listener cannot stop the event from occurring.
|
|
*/
|
|
pinch(element, position, timelinePosition, opts = {}) {
|
|
[position, timelinePosition, opts] = this.reorderArguments(arguments);
|
|
this._timelinePositions.push(timelinePosition);
|
|
|
|
// debug
|
|
//--------------------
|
|
if (this.opts.debug)
|
|
console.log('tap params', {
|
|
element,
|
|
position,
|
|
timelinePosition,
|
|
opts
|
|
});
|
|
|
|
// opts
|
|
//--------------------
|
|
opts = Object.assign(
|
|
{},
|
|
{
|
|
onStart: null,
|
|
onUpdate: null,
|
|
onComplete: null,
|
|
doubleCallbacks: false,
|
|
duration: 1,
|
|
distance: 100,
|
|
to: null,
|
|
bezier: null,
|
|
ease: Power0.easeNone,
|
|
eventTypes: this.resolveEvents(['down', 'move', 'up']),
|
|
context: window,
|
|
bubbles: true,
|
|
cancelable: true
|
|
},
|
|
opts
|
|
);
|
|
|
|
// timeline
|
|
//--------------------
|
|
this._timeline.addCallback(
|
|
position => {
|
|
// element
|
|
//--------------------
|
|
const elem = Util.extractElement(opts.context, element);
|
|
|
|
// from
|
|
//--------------------
|
|
let from1 = null;
|
|
let from2 = null;
|
|
|
|
if (Array.isArray(position) && !Util.isNumber(position[0])) {
|
|
from1 = Util.extractPosition(position[0]);
|
|
from2 = Util.extractPosition(position[1]);
|
|
} else {
|
|
from1 = Util.extractPosition(position);
|
|
from2 = { x: from1.x, y: from1.y };
|
|
}
|
|
|
|
// to
|
|
//--------------------
|
|
let gsOpts1 = {};
|
|
let gsOpts2 = {};
|
|
|
|
if (opts.to || opts.bezier) {
|
|
[gsOpts1, gsOpts2] = Util.extractMultiTo(opts);
|
|
} else {
|
|
const distance = opts.distance != null ? opts.distance : 100;
|
|
gsOpts1.x = from1.x - distance / 2;
|
|
gsOpts1.y = from1.y;
|
|
gsOpts2.x = from2.x + distance / 2;
|
|
gsOpts2.y = from2.y;
|
|
}
|
|
|
|
// pointers
|
|
//--------------------
|
|
const pointers = new Map();
|
|
pointers.set(0, { element: from1, gsOpts: gsOpts1 });
|
|
pointers.set(1, { element: from2, gsOpts: gsOpts2 });
|
|
|
|
// loop
|
|
//--------------------
|
|
pointers.forEach((value, key) => {
|
|
// from
|
|
//--------------------
|
|
const from = value.element;
|
|
|
|
// event opts
|
|
//--------------------
|
|
const eventOpts = {
|
|
bubbles: opts.bubbles,
|
|
cancelable: opts.cancelable,
|
|
pointerId: key,
|
|
isPrimary: key === 0
|
|
};
|
|
|
|
const gsOpts = {
|
|
ease: opts.ease,
|
|
onStart: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[0], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onStart
|
|
//--------------------
|
|
if (opts.onStart && (opts.doubleCallbacks || key === 0)) {
|
|
opts.onStart.call(this, event);
|
|
}
|
|
},
|
|
onUpdate: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[1], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onUpdate
|
|
//--------------------
|
|
if (opts.onUpdate && (opts.doubleCallbacks || key === 0)) {
|
|
opts.onUpdate.call(this, event);
|
|
}
|
|
},
|
|
onComplete: () => {
|
|
// create and dispatch event
|
|
//--------------------
|
|
const event = Event$1.create(elem, from, opts.eventTypes[2], eventOpts);
|
|
if (this.opts.debug) console.log('dispatch event', event);
|
|
elem.dispatchEvent(event);
|
|
|
|
// onComplete
|
|
//--------------------
|
|
if (opts.onComplete && (opts.doubleCallbacks || key === 0)) {
|
|
opts.onComplete.call(this, event);
|
|
}
|
|
}
|
|
};
|
|
|
|
// to
|
|
//--------------------
|
|
Object.assign(gsOpts, value.gsOpts);
|
|
|
|
// drag animation
|
|
//--------------------
|
|
TweenLite.to(from, opts.duration, gsOpts);
|
|
});
|
|
},
|
|
timelinePosition,
|
|
[position]
|
|
);
|
|
|
|
this._actions++;
|
|
|
|
return this
|
|
}
|
|
|
|
// /**
|
|
// * Adds a tap event to the timeline.
|
|
// *
|
|
// * @return {UITest} A reference to the uitest for chaining.
|
|
// */
|
|
// rotate() {
|
|
// return this
|
|
// }
|
|
|
|
// /**
|
|
// * Adds a tap event to the timeline.
|
|
// *
|
|
// * @return {UITest} A reference to the uitest for chaining.
|
|
// */
|
|
// swipe() {
|
|
// return this
|
|
// }
|
|
|
|
// /**
|
|
// * Adds a tap event to the timeline.
|
|
// *
|
|
// * @return {UITest} A reference to the uitest for chaining.
|
|
// */
|
|
// press() {
|
|
// return this
|
|
// }
|
|
|
|
// /**
|
|
// * Adds a tap event to the timeline.
|
|
// *
|
|
// * @return {UITest} A reference to the uitest for chaining.
|
|
// */
|
|
// event() {
|
|
// return this
|
|
// }
|
|
|
|
/**
|
|
* Sorts the parameters so that the second, third, and fourth parameters can be optional (and possibly slip forward).
|
|
*
|
|
* @private
|
|
* @param {arguments} params - The arguments which were passed to the function.
|
|
* @returns {array} - Returns an array of the position, the timelinePosition and the opts object.
|
|
*/
|
|
reorderArguments(params) {
|
|
// first parameter
|
|
//--------------------
|
|
const element = params[0];
|
|
|
|
// other parameter
|
|
//--------------------
|
|
let position = null;
|
|
let timelinePosition = null;
|
|
let opts = null;
|
|
|
|
// second parameter
|
|
//--------------------
|
|
if (Util.isNumber(params[1])) {
|
|
timelinePosition = params[1];
|
|
} else if (
|
|
Util.isObject(params[1]) &&
|
|
!Util.isPixiDisplayObject(params[1]) &&
|
|
(params[1].x == null || params[1].y == null)
|
|
) {
|
|
opts = params[1];
|
|
} else if (params[1] != null) {
|
|
position = params[1];
|
|
}
|
|
|
|
// third parameter
|
|
//--------------------
|
|
if (Util.isNumber(params[2])) {
|
|
timelinePosition = params[2];
|
|
} else if (Util.isObject(params[2])) {
|
|
opts = params[2];
|
|
}
|
|
|
|
// fourth parameter
|
|
//--------------------
|
|
if (Util.isObject(params[3])) {
|
|
opts = params[3];
|
|
}
|
|
|
|
if (timelinePosition === null) {
|
|
if (this.opts.defaultInterval === null && this._actions > 1) {
|
|
throw new Error(
|
|
'No execution time was specified for this action, and a default interval was not set in the class constructor!'
|
|
)
|
|
}
|
|
timelinePosition = Math.max(...this._timelinePositions) + (this.opts.defaultInterval || 1);
|
|
}
|
|
|
|
if (opts === null) {
|
|
opts = {};
|
|
}
|
|
|
|
return [position, timelinePosition, opts]
|
|
}
|
|
|
|
/**
|
|
* Converts event type shortcuts to real event names.
|
|
*
|
|
* @private
|
|
* @param {string[]} events - An array of event types.
|
|
*/
|
|
resolveEvents(events) {
|
|
const data = [];
|
|
|
|
if (this.opts.eventType === 'pointer') {
|
|
events.forEach(it => {
|
|
if (it === 'down') {
|
|
data.push('pointerdown');
|
|
} else if (it === 'move') {
|
|
data.push('pointermove');
|
|
} else if (it === 'up') {
|
|
data.push('pointerup');
|
|
}
|
|
});
|
|
} else if (this.opts.eventType === 'touch') {
|
|
events.forEach(it => {
|
|
if (it === 'down') {
|
|
data.push('touchstart');
|
|
} else if (it === 'move') {
|
|
data.push('touchmove');
|
|
} else if (it === 'up') {
|
|
data.push('touchend');
|
|
}
|
|
});
|
|
} else {
|
|
events.forEach(it => {
|
|
if (it === 'down') {
|
|
data.push('mousedown');
|
|
} else if (it === 'move') {
|
|
data.push('mousemove');
|
|
} else if (it === 'up') {
|
|
data.push('mouseup');
|
|
}
|
|
});
|
|
}
|
|
|
|
return data
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper class.
|
|
*
|
|
* @example
|
|
* // Checks if a thing is a number.
|
|
* const num = Util.isNumber(20)
|
|
*
|
|
* @private
|
|
* @ignore
|
|
* @class
|
|
*/
|
|
class Util {
|
|
/**
|
|
* Resolves the element from a specific context.
|
|
*
|
|
* @static
|
|
* @param {Window|Frame} context - The context within which the optionally specified element selector should be executed.
|
|
* @return {HTMLElement|string} element - The HTML element on which the event is to be executed, e.g. button, document, h2, canvas, etc. or an selector string. If a selector has been specified, it is evaluated immediately before the event is called using the querySelector method.
|
|
*/
|
|
static extractElement(context, element) {
|
|
const cont = Util.isFrame(context) ? context.contentDocument : context.document;
|
|
const elem = Util.isString(element) ? cont.querySelector(element) : element;
|
|
|
|
return elem
|
|
}
|
|
|
|
/**
|
|
* Extracts the position of the second parameter.
|
|
*
|
|
* @static
|
|
* @param {object} object - Something were the coords should be extracted.
|
|
* @return {object} - Returns an object with the keys x and y.
|
|
*/
|
|
static extractPosition(object) {
|
|
// event coords
|
|
//--------------------
|
|
const position = { x: 0, y: 0 };
|
|
|
|
// get the position
|
|
//--------------------
|
|
if (!object) {
|
|
position.x = 0;
|
|
position.y = 0;
|
|
} else if (typeof object.getBounds === 'function') {
|
|
const bounds = object.getBounds();
|
|
position.x = bounds.x + bounds.width / 2;
|
|
position.y = bounds.y + bounds.height / 2;
|
|
} else if (Array.isArray(object)) {
|
|
position.x = object[0];
|
|
position.y = object[1];
|
|
} else if (object.x != null && object.y != null) {
|
|
position.x = object.x;
|
|
position.y = object.y;
|
|
}
|
|
|
|
return position
|
|
}
|
|
|
|
/**
|
|
* Extracts the to or bezier key.
|
|
*
|
|
* @static
|
|
* @param {object} opts - An options object where to or bezier should be extracted.
|
|
* @return {object} - Returns an object with the to or bezier keys.
|
|
*/
|
|
static extractTo(opts) {
|
|
const object = {};
|
|
|
|
if (opts.bezier) {
|
|
let bezier = null;
|
|
|
|
if (Array.isArray(opts.bezier)) {
|
|
bezier = {
|
|
values: opts.bezier.map(it => Util.extractPosition(it)),
|
|
type: 'thru'
|
|
};
|
|
} else {
|
|
opts.bezier.values = opts.bezier.values.map(it => Util.extractPosition(it));
|
|
bezier = opts.bezier;
|
|
}
|
|
|
|
object.bezier = bezier;
|
|
} else {
|
|
const to = Util.extractPosition(opts.to);
|
|
object.x = to.x;
|
|
object.y = to.y;
|
|
}
|
|
|
|
return object
|
|
}
|
|
|
|
/**
|
|
* Extracts multiple to or bezier keys.
|
|
*
|
|
* @static
|
|
* @param {object} opts - An options object where to or bezier should be extracted.
|
|
* @return {object[]} - Returns an array of objects with the keys x and y.
|
|
*/
|
|
static extractMultiTo(opts) {
|
|
const objects = [];
|
|
|
|
if (opts.bezier) {
|
|
opts.bezier.forEach(it => {
|
|
let bezier = null;
|
|
|
|
if (Array.isArray(it)) {
|
|
bezier = {
|
|
values: it.map(it => Util.extractPosition(it)),
|
|
type: 'thru'
|
|
};
|
|
} else {
|
|
it.values = it.values.map(it => Util.extractPosition(it));
|
|
bezier = it;
|
|
}
|
|
|
|
objects.push({
|
|
bezier
|
|
});
|
|
});
|
|
} else {
|
|
opts.to.forEach(it => {
|
|
const to = Util.extractPosition(it);
|
|
objects.push({
|
|
x: to.x,
|
|
y: to.y
|
|
});
|
|
});
|
|
}
|
|
|
|
return objects
|
|
}
|
|
|
|
/**
|
|
* Checks if a thing is a string.
|
|
*
|
|
* @static
|
|
* @param {object} object - The object to test for.
|
|
* @return {boolean} - true if the thing is a string, otherwise false.
|
|
*/
|
|
static isString(object) {
|
|
return typeof object === 'string'
|
|
}
|
|
|
|
/**
|
|
* Checks if a thing is a number.
|
|
*
|
|
* @static
|
|
* @param {object} object - The object to test for.
|
|
* @return {boolean} - true if the thing is a number, otherwise false.
|
|
*/
|
|
static isNumber(object) {
|
|
return typeof object === 'number'
|
|
}
|
|
|
|
/**
|
|
* Checks if a thing is an object.
|
|
*
|
|
* @static
|
|
* @param {object} object - The object to test for.
|
|
* @return {boolean} - true if the thing is an object, otherwise false.
|
|
*/
|
|
static isObject(object) {
|
|
return typeof object === 'object' && !Array.isArray(object)
|
|
}
|
|
|
|
/**
|
|
* Checks if a thing is an PIXI.DisplayObject.
|
|
*
|
|
* @static
|
|
* @param {object} object - The object to test for.
|
|
* @return {boolean} - true if the thing is a PIXI.DisplayObject, otherwise false.
|
|
*/
|
|
static isPixiDisplayObject(object) {
|
|
return (
|
|
typeof object.getBounds === 'function' &&
|
|
typeof object.renderWebGL === 'function' &&
|
|
typeof object.setTransform === 'function'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Checks if a thing is a frame.
|
|
*
|
|
* @static
|
|
* @param {object} object - The object to test for.
|
|
* @return {boolean} - true if the thing is a frame, otherwise false.
|
|
*/
|
|
static isFrame(object) {
|
|
return object.contentDocument != null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Event helper class.
|
|
*
|
|
* @example
|
|
* // Creates an event object.
|
|
* const event = Event.create(h2, {x: 5, y: 10}, 'touchstart')
|
|
*
|
|
* @private
|
|
* @ignore
|
|
* @class
|
|
*/
|
|
class Event$1 {
|
|
/**
|
|
* Creates an event object.
|
|
*
|
|
* @static
|
|
* @param {HTMLElement} target - The element on which the event should be executed.
|
|
* @param {object} position - The local position of the event in relation to the target. The object must have the keys x and y.
|
|
* @param {string} type - The type of the event, see https://developer.mozilla.org/de/docs/Web/Events
|
|
* @param {object} opts - An options object. Every paramter of the event object can be overridden, see e.g. https://developer.mozilla.org/de/docs/Web/API/MouseEvent for all the properties.
|
|
*/
|
|
static create(target, position = { x: 0, y: 0 }, type = 'pointerup', opts = {}) {
|
|
const rect =
|
|
typeof target.getBoundingClientRect === 'function' ? target.getBoundingClientRect() : { x: 0, y: 0 };
|
|
|
|
// EventInit
|
|
const eventOpts = {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
composed: false
|
|
};
|
|
|
|
// UIEventInit
|
|
const uiEventOpts = {
|
|
detail: 0,
|
|
view: window
|
|
};
|
|
|
|
// MouseEvent
|
|
const mouseEventOpts = {
|
|
screenX: window.screenX + (target.offsetLeft || 0) + position.x,
|
|
screenY: window.screenY + (target.offsetTop || 0) + position.y,
|
|
clientX: rect.x + position.x,
|
|
clientY: rect.y + position.y,
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
metaKey: false,
|
|
button: 0,
|
|
buttons: 1,
|
|
relatedTarget: null,
|
|
region: null
|
|
};
|
|
|
|
// TouchEvent
|
|
const touchEventOpts = {
|
|
touches: [],
|
|
targetTouches: [],
|
|
changedTouches: [],
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
metaKey: false
|
|
};
|
|
|
|
// PointerEvent
|
|
const pointerEventOpts = {
|
|
pointerId: 0,
|
|
width: 1,
|
|
height: 1,
|
|
pressure: 0,
|
|
tangentialPressure: 0,
|
|
tiltX: 0,
|
|
tiltY: 0,
|
|
twist: 0,
|
|
pointerType: 'touch',
|
|
isPrimary: true
|
|
};
|
|
|
|
if (type.startsWith('pointer')) {
|
|
return new PointerEvent(
|
|
type,
|
|
Object.assign({}, eventOpts, uiEventOpts, mouseEventOpts, pointerEventOpts, opts)
|
|
)
|
|
} else if (type.startsWith('touch')) {
|
|
return new TouchEvent(type, Object.assign({}, eventOpts, uiEventOpts, touchEventOpts, opts))
|
|
} else {
|
|
return new MouseEvent(type, Object.assign({}, eventOpts, uiEventOpts, mouseEventOpts, opts))
|
|
}
|
|
}
|
|
}
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
let _HighlightEnabled = true;
|
|
let _CircleIds = 0;
|
|
|
|
/** Helper method to round values with one digit precision */
|
|
function round(value) {
|
|
return Math.round(parseFloat(value) * 10) / 10
|
|
}
|
|
|
|
/**
|
|
* A namespace with static functions to expand and shrink highlighted image regions.
|
|
* Assumes an SVG image with the following structure:
|
|
*
|
|
* <svg viewbox="0 0 100 100">
|
|
* <!-- The defs section must be defined and cannot be generated in JavaScript -->
|
|
* <defs>
|
|
* </defs>
|
|
* <image width="100" height="100" xlink:href="../assets/chess.jpg"/>
|
|
* <circle onclick="Highlight.animateZoom(event)" cx="47" cy="18" r="8" stroke-width="0.5" />
|
|
* <circle onclick="Highlight.animateZoom(event)" cx="60" cy="67" r="8" stroke-width="0.5" />
|
|
* </svg>
|
|
*
|
|
* The SVG root element should use a viewbox with 0 0 100 100 to ensure that the positions and size of the
|
|
* circles can be represented in percent.
|
|
*
|
|
* @class Highlight
|
|
* @extends {Object}
|
|
*/
|
|
|
|
const SCALE = 1.5;
|
|
|
|
class Highlight extends Object {
|
|
static disableAnimations() {
|
|
_HighlightEnabled = false;
|
|
let expanded = document.querySelectorAll('.' + this.expandedClass);
|
|
for (let obj of expanded) {
|
|
this.shrink(obj);
|
|
}
|
|
}
|
|
|
|
static enableAnimations() {
|
|
_HighlightEnabled = true;
|
|
}
|
|
|
|
static removeAnimations(svgRoot) {
|
|
let expanded = svgRoot.querySelectorAll('.' + this.expandedClass);
|
|
for (let obj of expanded) {
|
|
TweenLite.set(obj, { scale: 1 });
|
|
obj.classList.remove('zooming');
|
|
obj.classList.remove(this.expandedClass);
|
|
}
|
|
let defs = svgRoot.querySelector('defs');
|
|
while (defs.firstChild) {
|
|
defs.firstChild.remove();
|
|
}
|
|
let maskImages = svgRoot.querySelectorAll('.addedImage');
|
|
for (let m of maskImages) {
|
|
m.remove();
|
|
}
|
|
let circles = svgRoot.querySelectorAll('circle');
|
|
for (let circle of circles) {
|
|
if (circle.classList.length == 0) {
|
|
circle.removeAttribute('class');
|
|
}
|
|
if (circle.hasAttribute('id') && circle.getAttribute('id').startsWith('@@')) {
|
|
circle.removeAttribute('id');
|
|
}
|
|
circle.removeAttribute('data-svg-origin');
|
|
circle.removeAttribute('transform');
|
|
circle.removeAttribute('style');
|
|
let cx = circle.getAttribute('cx');
|
|
let cy = circle.getAttribute('cy');
|
|
let r = circle.getAttribute('r');
|
|
circle.setAttribute('cx', round(cx));
|
|
circle.setAttribute('cy', round(cy));
|
|
circle.setAttribute('r', round(r));
|
|
}
|
|
}
|
|
|
|
static expand(obj, { scale = SCALE, duration = 3, stroke = 2, onComplete = null } = {}) {
|
|
if (obj == null) return
|
|
//console.log("expand")
|
|
obj.classList.add('zooming');
|
|
TweenLite.to(obj, duration, {
|
|
scale,
|
|
onUpdate: () => {
|
|
let scale = obj._gsTransform.scaleX;
|
|
obj.setAttribute('stroke-width', stroke / scale);
|
|
},
|
|
onComplete: () => {
|
|
console.log('expand complete');
|
|
obj.classList.remove('zooming');
|
|
obj.classList.add(this.expandedClass);
|
|
obj.setAttribute('stroke-width', stroke / scale);
|
|
if (onComplete) onComplete();
|
|
}
|
|
});
|
|
}
|
|
|
|
static shrink(obj, { duration = 0.5, stroke = 2 } = {}) {
|
|
//console.log("shrink")
|
|
if (obj == null) return
|
|
obj.classList.add('zooming');
|
|
TweenLite.to(obj, duration, {
|
|
scale: 1,
|
|
onUpdate: () => {
|
|
let scale = obj._gsTransform.scaleX;
|
|
obj.setAttribute('stroke-width', stroke / scale);
|
|
},
|
|
onComplete: () => {
|
|
//console.log("shrink complete")
|
|
obj.classList.remove('zooming');
|
|
obj.classList.remove(this.expandedClass);
|
|
obj.setAttribute('stroke-width', stroke);
|
|
}
|
|
});
|
|
}
|
|
|
|
static animateCircle(target, callback) {
|
|
console.log('ANIMATE CIRCLE', this);
|
|
// ** DEBUG OUTPUTS **
|
|
|
|
let circle = target;
|
|
// We need a unique id to ensure correspondence between circle, mask, and maskImage
|
|
if (!circle.hasAttribute('id')) {
|
|
_CircleIds += 1;
|
|
circle.setAttribute('id', '@@' + _CircleIds);
|
|
}
|
|
let id = circle.getAttribute('id');
|
|
TweenLite.set(circle, { transformOrigin: '50% 50%' });
|
|
/*if (circle.classList.contains('zooming')) {
|
|
console.log("already zooming")
|
|
return
|
|
}*/
|
|
|
|
let svgRoot = circle.closest('svg');
|
|
let circleGroup = circle.parentNode;
|
|
let image = svgRoot.querySelector('image');
|
|
|
|
let stroke = parseFloat(circleGroup.getAttribute('stroke-width') || 6);
|
|
|
|
let defs = svgRoot.querySelector('defs');
|
|
if (defs == null) {
|
|
defs = document.createElementNS(svgRoot, 'defs');
|
|
svgRoot.insertBefore(defs, image);
|
|
}
|
|
|
|
// // We need direct children, therefore we cannot use querySelectorAll
|
|
let maskImageId = 'maskImage' + id;
|
|
let maskImage = svgRoot.getElementById(maskImageId);
|
|
|
|
if (circle.classList.contains(this.expandedClass)) {
|
|
if (!circle.classList.contains('zooming')) {
|
|
this.shrink(circle, { stroke });
|
|
this.shrink(maskImage, { stroke });
|
|
return
|
|
}
|
|
//console.log("animate called while zooming out -> expand")
|
|
} else if (circle.classList.contains('zooming')) {
|
|
//console.log("animate called while zooming in -> shrink")
|
|
this.shrink(circle, { stroke });
|
|
this.shrink(maskImage, { stroke });
|
|
return
|
|
}
|
|
let circles = Array.from(circleGroup.children).filter(e => e.tagName == 'circle');
|
|
for (let c of circles) {
|
|
//console.log("shrinking all circles")
|
|
this.shrink(c, { stroke });
|
|
}
|
|
let maskImages = circleGroup.querySelectorAll('.addedImage');
|
|
for (let m of maskImages) {
|
|
this.shrink(m, { stroke });
|
|
}
|
|
|
|
this._createSVGMask(svgRoot, image, id);
|
|
|
|
// TweenLite.set(maskImage, { transformOrigin: `${tx}% ${ty}%` })
|
|
|
|
this.expand(circle, { stroke, onComplete: callback });
|
|
this.expand(maskImage);
|
|
|
|
return false
|
|
}
|
|
|
|
static openHighlight(target, { animation = 0.5, scale = SCALE, onExpanded = null } = {}) {
|
|
if (this._isExpanded(target)) {
|
|
console.log('Target is already expanded!');
|
|
return
|
|
} else {
|
|
let targetId = target.getAttribute('id');
|
|
if (targetId && targetId.startsWith('@@')) {
|
|
let id = targetId.slice(2);
|
|
const imageId = '#maskImage' + id;
|
|
const parent = target.parentNode;
|
|
if (parent != null) {
|
|
let image = parent.querySelector(imageId);
|
|
if (image) {
|
|
this._bringToFront(image);
|
|
} else console.error('Could not find corresponding image element.');
|
|
} else console.log('Element was no parent:', target);
|
|
}
|
|
this._bringToFront(target);
|
|
let svgRoot = target.closest('svg');
|
|
if (svgRoot == null) {
|
|
return
|
|
}
|
|
let image = svgRoot.querySelector('image');
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
let [mask, maskImage] = this._getSVGMask(target, {
|
|
svgRoot,
|
|
image
|
|
});
|
|
let center = this._calculateCenterRelativeTo(target, image);
|
|
console.log('_calculateCenterRelativeTo', center);
|
|
TweenLite.set(maskImage, {
|
|
transformOrigin: `${center.x} ${center.y}`
|
|
});
|
|
TweenLite.set(target, { transformOrigin: '50% 50%' });
|
|
|
|
TweenLite.to([target, maskImage], animation, {
|
|
scale,
|
|
onComplete: onExpanded
|
|
});
|
|
|
|
target.classList.add(this.expandedClass);
|
|
}
|
|
}
|
|
|
|
static toggleHighlight(node, options = {}) {
|
|
console.log('toggleHighlight', this._isExpanded(node));
|
|
if (this._isExpanded(node)) {
|
|
this.closeHighlight(node, options);
|
|
} else {
|
|
this.openHighlight(node, options);
|
|
}
|
|
}
|
|
|
|
static _bringToFront(target) {
|
|
const parent = target.parentNode;
|
|
if (target && parent) {
|
|
parent.removeChild(target);
|
|
parent.appendChild(target);
|
|
} else console.error('Could not bring to front. Either no target or no parent.', target, parent);
|
|
}
|
|
|
|
static _getSVGMask(circle, { svgRoot = null, image = null } = {}) {
|
|
const id = this._retrieveId(circle);
|
|
const maskId = 'mask' + id;
|
|
const maskImageId = 'maskImage' + id;
|
|
|
|
if (!svgRoot) svgRoot = circle.closest('svg');
|
|
|
|
let mask = svgRoot.getElementById(maskId);
|
|
let maskImage = svgRoot.getElementById(maskImageId);
|
|
|
|
if (!mask || !maskImage)
|
|
[mask, maskImage] = this._createSVGMask(circle, {
|
|
svgRoot,
|
|
image,
|
|
id
|
|
});
|
|
|
|
return [mask, maskImage]
|
|
}
|
|
|
|
/**
|
|
* Creates an SVG mask for a provided svgElement.
|
|
*
|
|
* @static
|
|
* @param {SVGElement} element - Element that should be masked.
|
|
* @param {object} opts - Optional parameters to avoid unnecessary fetching of elements.
|
|
* @param {SVGElement} opts.svgRoot - The root <svg> element of the element.
|
|
* @param {SVGImageElement} opts.image - The image that is used in the mask.
|
|
* @param {number} opts.id - The id of the mask.
|
|
* @returns
|
|
* @memberof Highlight
|
|
*/
|
|
static _createSVGMask(element, { svgRoot = null, image = null, id = null } = {}) {
|
|
// We can fetch these values here, but it's more efficient to
|
|
// simply pass them in, as it's likely they were already retrieved beforehand.
|
|
if (svgRoot == null) svgRoot = element.closest('svg');
|
|
if (image == null) image = svgRoot.querySelector('image');
|
|
if (id == null) id = this._retrieveId(element);
|
|
|
|
let svg = 'http://www.w3.org/2000/svg';
|
|
let xlink = 'http://www.w3.org/1999/xlink';
|
|
|
|
let svgGroup = element.parentNode;
|
|
|
|
let src = image.getAttributeNS(xlink, 'href');
|
|
|
|
let maskId = 'mask' + id;
|
|
let maskImageId = 'maskImage' + id;
|
|
let mask = svgRoot.getElementById(maskId);
|
|
let maskImage = svgRoot.getElementById(maskImageId);
|
|
|
|
let defs = svgRoot.querySelector('defs');
|
|
if (defs == null) {
|
|
defs = document.createElementNS(svgRoot, 'defs');
|
|
svgRoot.insertBefore(defs, image);
|
|
}
|
|
|
|
if (mask == null) {
|
|
mask = document.createElementNS(svg, 'mask');
|
|
mask.setAttribute('id', maskId);
|
|
let maskCircle = element.cloneNode(true);
|
|
mask.appendChild(maskCircle);
|
|
defs.appendChild(mask);
|
|
}
|
|
|
|
let bbox = svgRoot.getElementsByTagName('image')[0].getBBox();
|
|
let width = bbox.width;
|
|
let height = bbox.height;
|
|
|
|
if (maskImage == null) {
|
|
maskImage = document.createElementNS(svg, 'image');
|
|
maskImage.style.pointerEvents = 'none';
|
|
maskImage.setAttribute('id', maskImageId);
|
|
maskImage.setAttributeNS(xlink, 'href', src);
|
|
maskImage.setAttribute('width', width);
|
|
maskImage.setAttribute('height', height);
|
|
maskImage.setAttribute('class', 'addedImage');
|
|
TweenLite.set(maskImage, { scale: 1 });
|
|
maskImage.style.mask = 'url(#' + maskId + ')';
|
|
}
|
|
|
|
element.insertAdjacentElement('beforebegin', maskImage); // image.nextSibling)
|
|
// svgGroup.appendChild(element)
|
|
|
|
return [mask, maskImage]
|
|
}
|
|
|
|
static _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 x = cx.endsWith('%') ? cx : round((cx / width) * 100) + '%';
|
|
let y = cy.endsWith('%') ? cy : round((cy / height) * 100) + '%';
|
|
return { x, y }
|
|
}
|
|
|
|
static _isExpanded(target) {
|
|
return target.classList.contains(this.expandedClass)
|
|
}
|
|
|
|
static closeHighlight(target, { animation = 0.5 } = {}) {
|
|
target.classList.remove(this.expandedClass);
|
|
// eslint-disable-next-line no-unused-vars
|
|
let [mask, maskImage] = this._getSVGMask(target);
|
|
// console.log('Close Highlight', maskImage)
|
|
TweenLite.to([target, maskImage], animation, {
|
|
scale: 1
|
|
});
|
|
}
|
|
|
|
static animate(event) {
|
|
if (!_HighlightEnabled) return
|
|
|
|
event.stopPropagation();
|
|
this.animateCircle(event.target);
|
|
|
|
return false
|
|
}
|
|
|
|
static _retrieveId(target) {
|
|
let id = target.getAttribute('id');
|
|
// We need a unique id to ensure correspondence between circle, mask, and maskImage
|
|
if (!id) {
|
|
_CircleIds += 1;
|
|
target.setAttribute('id', '@@' + _CircleIds);
|
|
id = _CircleIds;
|
|
} else {
|
|
id = parseInt(id.substring(2));
|
|
}
|
|
|
|
return id
|
|
}
|
|
}
|
|
|
|
Highlight.expandedClass = 'expanded';
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
class Card {
|
|
static setup(context, modules = []) {
|
|
if (this.debug) console.log('Setup Card...', modules);
|
|
|
|
/**
|
|
* This is required for the callback functions to work properly.
|
|
*/
|
|
window.Card = Card;
|
|
|
|
context.modules = [];
|
|
context.module = {};
|
|
|
|
context.onClose = null;
|
|
|
|
context.classList.add('info-card');
|
|
context.setAttribute('data-id', Card.id++);
|
|
|
|
modules.forEach(module => {
|
|
if (module.apply(context)) {
|
|
const moduleName = module.constructor.name;
|
|
context.modules.push(moduleName);
|
|
context.module[moduleName] = module;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes the context and all registered components of the card.
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - The DOMElement representing the card.
|
|
* @memberof Card
|
|
*/
|
|
static remove(context) {
|
|
//Remove all modules that are registered to the context.
|
|
for (let module of Object.values(context.module)) {
|
|
const moduleHasRemoveFunction = typeof module.remove === 'function';
|
|
if (moduleHasRemoveFunction) {
|
|
module.remove();
|
|
}
|
|
}
|
|
|
|
// Remove the context itself.
|
|
if (context.parentNode != null) {
|
|
context.parentNode.removeChild(context);
|
|
} else {
|
|
console.warn('Tried removing card but it has already been removed.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Closes the card when an event happens on the card.
|
|
* Either the card is removed or the onClose callback is called.
|
|
*
|
|
* NOTE: Make sure to remove the card manually, when using the onClose callback!
|
|
*
|
|
* INFO: I just revisited this function and was wondering, why it takes the event as
|
|
* parameter instead of the context. The problem is that the card template defines
|
|
* the functions inside the HTML. When loaded, the HTML attributes need to be adjusted
|
|
* and the functions are overwritten. Using during the post processing step withing the postProcessResponseText function.
|
|
*
|
|
* @static
|
|
* @param {Event} event - event triggered on the car
|
|
* @memberof Card
|
|
*/
|
|
static close(event) {
|
|
let context = this.getContext(event.target);
|
|
|
|
if (context) {
|
|
if (context.onClose != null) {
|
|
context.onClose();
|
|
} else {
|
|
this.remove(context);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds an on close method to the provided context.
|
|
* This will overwrite the default closing behaviour.
|
|
* Removing the
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - Context on which the onClose will be set.
|
|
* @param {Function} callback - Callback function of the onClose.
|
|
* @memberof Card
|
|
*/
|
|
static setOnClose(context, callback) {
|
|
if (context.onClose != null) console.error('OnClose was already set. It was overwritten by the new method.');
|
|
context.onClose = callback;
|
|
}
|
|
|
|
/**
|
|
* Unsets the onClose.
|
|
*
|
|
* Note: This may be used in conjunction with the setOnClose method.
|
|
* Using the setOnClose method to adjust behaviour before closing the card.
|
|
* Then unsetting the onClose to close the Card appropriately by calling the
|
|
* Card.Close again.
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - Context on which the remove will be executed.
|
|
* @memberof Card
|
|
*/
|
|
static removeOnClose(context) {
|
|
context.onClose = null;
|
|
}
|
|
|
|
/**
|
|
* Replaces a set of attributes using a provided function.
|
|
*
|
|
* @static
|
|
* @param {*} html
|
|
* @param {*} attribute
|
|
* @param {*} replaceFunc
|
|
* @memberof Card
|
|
*/
|
|
static _replaceAttributes(context, html, attribute, replaceFunc) {
|
|
let attributeCarrier = html.querySelectorAll(`[${attribute}]`);
|
|
attributeCarrier.forEach(element => {
|
|
let attributeVal = element.getAttribute(attribute);
|
|
element.removeAttribute(attribute);
|
|
replaceFunc.call(this, context, element, attributeVal);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replaces the onClick callback of the element with an
|
|
* Interaction mapper event.
|
|
*
|
|
* @static
|
|
* @param {*} element
|
|
* @param {*} attributeVal
|
|
* @returns
|
|
* @memberof Card
|
|
*/
|
|
static _replaceCallback(context, element, attributeVal) {
|
|
if (element.tagName == 'A') {
|
|
element.addEventListener('pointerdown', 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() != ''
|
|
});
|
|
/**
|
|
* As we determine a function by a string we must traverse from the window object to
|
|
* get the associated javascript function.
|
|
*/
|
|
let callStack = window;
|
|
let last = 'window';
|
|
do {
|
|
let func = callParts.shift().trim();
|
|
if (callStack[func] == null) {
|
|
callStack = null;
|
|
console.error(
|
|
`Could not access callback function: ${attributeVal}. Member ${func} of ${last} could not be found.`
|
|
);
|
|
break
|
|
} else callStack = callStack[func];
|
|
} while (callParts.length > 0)
|
|
let targetFunc = callStack;
|
|
|
|
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
|
|
|
|
this.registerEvent(context, interactionType, element, event => {
|
|
/**
|
|
* Replaces the strings from the listener with the corresponding 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 (targetFunc) {
|
|
targetFunc.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 occurrence 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() {
|
|
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
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Concatenates 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 = {}) {
|
|
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();
|
|
InteractionMapper.off(popup.element);
|
|
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 flashing 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, () => {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* LEGACY but this may be required still for the editor
|
|
*/
|
|
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 interferes 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 conflict 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');
|
|
if (this.debug) 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 x = node.getAttribute('cx');
|
|
let y = node.getAttribute('cy');
|
|
let position = { x, y };
|
|
|
|
let radius = parseFloat(node.getAttribute('r'));
|
|
|
|
let svgRoot = node.closest('svg');
|
|
|
|
/*
|
|
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 svgPoint = svgRoot.createSVGPoint();
|
|
svgPoint.x = position.x;
|
|
svgPoint.y = position.y - radius;
|
|
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(context, src)
|
|
.then(content => {
|
|
this._openPopup(context, src, local, content, {
|
|
highlight: node,
|
|
closeCommand: popup => {
|
|
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(context, 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(context, 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: ${source}`);
|
|
}
|
|
}
|
|
};
|
|
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');
|
|
if (zoomable == null) {
|
|
return
|
|
}
|
|
|
|
// 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: Renamed 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, () => {
|
|
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 = () => {
|
|
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 = () => {
|
|
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 an zoomable object with an animation.
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - Context of the zoomable.
|
|
* @param {*} zoomable
|
|
* @param {*} zoomedFig
|
|
* @memberof Card
|
|
*/
|
|
static closeZoomable(context, zoomable, zoomedFig) {
|
|
if (this.debug) console.log('Close Zoomable', context, zoomable, zoomedFig);
|
|
|
|
//TODO: Why do I need this check. Shouldn't it be always present?! - SO
|
|
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);
|
|
}
|
|
});
|
|
|
|
InteractionMapper.off(zoomedFig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 expanded 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, () => {
|
|
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'));
|
|
|
|
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);
|
|
clone.setAttribute('data-source', src);
|
|
|
|
let titlebar = clone.querySelector('.titlebar');
|
|
let title = titlebar.querySelector('h2');
|
|
let titlebarStyle = window.getComputedStyle(titlebar);
|
|
let start = {
|
|
height: parseInt(titlebarStyle.height)
|
|
};
|
|
|
|
if (this.dynamicHeight) {
|
|
subcardContent.classList.add('dynamic-height');
|
|
/**
|
|
* Scale the content from 100% to it's target size.
|
|
*/
|
|
// TweenLite.set(subcardContent, {
|
|
// height: "100%"
|
|
// })
|
|
//
|
|
// let targetHeight = subcardContent.offsetHeight
|
|
// TweenLite.to(subcardContent, Card.animation.articleTransition, {
|
|
// height: targetHeight + "px"
|
|
// })
|
|
}
|
|
|
|
//jquery hyphenate below
|
|
if (this._isJQueryPresent()) {
|
|
$('.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);
|
|
|
|
//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');
|
|
}
|
|
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);
|
|
}
|
|
|
|
const eventElements = [indexbox, iconClone, clone];
|
|
|
|
// 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);
|
|
this._closeIndexCard(context, card, clone, articleClone, {
|
|
eventElements,
|
|
src
|
|
});
|
|
});
|
|
} else {
|
|
this._closeIndexCard(context, card, clone, articleClone);
|
|
}
|
|
} else {
|
|
this._closeIndexCard(context, card, clone, articleClone);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Closes the index card again.
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - The Card element.
|
|
* @param {DOMElement} subcard - The original subcard element visible on the main card.
|
|
* @param {DOMElement} clonedSubcard - The cloned subcard that's going to be expanded.
|
|
* @param {DOMElement} clonedArticle - The article part of the ClonedSubcard.
|
|
* @param {Object} [{ eventElements = [], src = null }=[]]
|
|
* @memberof Card
|
|
*/
|
|
static _closeIndexCard(
|
|
context,
|
|
subcard,
|
|
clonedSubcard,
|
|
clonedArticle,
|
|
|
|
{ src = null } = []
|
|
) {
|
|
let indexbox = context.querySelector('.mainview');
|
|
let padding = parseInt(this.css(indexbox, 'padding'));
|
|
|
|
let globalPreviewRect = Card._getGlobalRect(subcard);
|
|
let globalIndexCardRect = Card._getGlobalRect(indexbox);
|
|
|
|
let scale = {
|
|
x: globalPreviewRect.width / globalIndexCardRect.width,
|
|
y: globalPreviewRect.height / globalIndexCardRect.height
|
|
};
|
|
|
|
let titlebar = clonedSubcard.querySelector('.titlebar');
|
|
|
|
let desiredBorderBottomWidth = parseInt(window.getComputedStyle(titlebar).borderBottomWidth);
|
|
let localOrigin = Points.fromPageToNode(indexbox, Rect.getPosition(globalPreviewRect));
|
|
|
|
//logging
|
|
if (src) {
|
|
let strparts = src.split('/');
|
|
let cardID = strparts[strparts.length - 2];
|
|
let cardName = strparts[strparts.length - 1];
|
|
strparts = context.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._subcardChanged(context, true);
|
|
this._enableCardCloseButton(context);
|
|
|
|
let previewTitlebar = subcard.querySelector('.titlebar');
|
|
let titlebarStyle = window.getComputedStyle(previewTitlebar);
|
|
|
|
TweenLite.to(titlebar, this.animation.articleTransition, {
|
|
height: parseInt(titlebarStyle.height)
|
|
});
|
|
|
|
TweenLite.to(clonedArticle, this.animation.articleTransition / 2, {
|
|
autoAlpha: 0
|
|
});
|
|
|
|
let title = titlebar.querySelector('h2');
|
|
let original = {
|
|
height: parseInt(titlebarStyle.height)
|
|
};
|
|
|
|
if (this.dynamicHeight) {
|
|
console.error('Dynamic height is not implemented yet.');
|
|
// TweenLite.to(subcardContent, this.animation.articleTransition, {
|
|
// height: '100%'
|
|
// })
|
|
}
|
|
|
|
let maxWidth = this.css(subcard, 'max-width');
|
|
TweenLite.set(subcard, { autoAlpha: 1, css: { maxWidth } });
|
|
TweenLite.to(clonedSubcard, this.animation.articleTransition, {
|
|
x: localOrigin.x - padding,
|
|
y: localOrigin.y - padding,
|
|
scaleX: scale.x,
|
|
scaleY: scale.y,
|
|
ease: ExpoScaleEase.config(1, scale.x),
|
|
// rotation: angle,
|
|
onComplete: () => {
|
|
// article.remove()
|
|
TweenLite.to(clonedSubcard, this.animation.fade, {
|
|
//delay: 0.2,
|
|
autoAlpha: 0,
|
|
onComplete: () => {
|
|
if (Card.isEditable()) {
|
|
mainController.popController();
|
|
}
|
|
clonedSubcard.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';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Tests if jQuery is properly included in the project.
|
|
* Otherwise specific features may not work correctly (e.g. hyphenation)
|
|
*/
|
|
static _isJQueryPresent() {
|
|
let jQueryInitialized = typeof $ != 'undefined';
|
|
if (!jQueryInitialized) console.error('No jQuery is provided. Specific features may fail.');
|
|
return jQueryInitialized
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
if (this.debug) console.log('openIndexCard', src);
|
|
/*
|
|
* Called by the expandIndexCard(...)
|
|
*/
|
|
let target = event.target;
|
|
const saveCallback = url => {
|
|
let handler = `Card.openIndexCard(event, '${url}')`;
|
|
if (this.debug) 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 })
|
|
//Don't proceed 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(context, html);
|
|
Card.expandIndexCard(card, parsedHTML, 'article', relativeSource, saveCallback);
|
|
|
|
this._subcardChanged(context);
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
alert('Unable to load ' + src);
|
|
};
|
|
xhr.send();
|
|
}
|
|
|
|
static _selectCardCloseButton(context) {
|
|
return context.querySelector('.mainview > .button.close')
|
|
}
|
|
|
|
static _enableCardCloseButton(context) {
|
|
let btn = this._selectCardCloseButton(context);
|
|
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(context, htmlString) {
|
|
let editable = this.isEditable();
|
|
|
|
htmlString = this._adjustRelativeLinks(htmlString);
|
|
let parser = new DOMParser();
|
|
let html = parser.parseFromString(htmlString, 'text/html');
|
|
if (!editable) {
|
|
this._replaceAttributes(context, 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 occurred 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.propagate 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 (this._isJQueryPresent()) {
|
|
//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 provided 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 _subcardChanged(context, closed = false) {
|
|
for (let module of Object.values(context.module)) {
|
|
if (module.subcardChanged) {
|
|
module.subcardChanged(closed);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
static getRegisteredEvents(context) {
|
|
return context._registeredEvents == null ? [] : context._registeredEvents
|
|
}
|
|
|
|
/**
|
|
* Helper method that registers InteractionMapper events on the info card.
|
|
* Those events are saved on the context element. An they are unregistered when the
|
|
* card is closed again.
|
|
*
|
|
* @static
|
|
* @param {DOMElement} context - Context of the Card.
|
|
* @param {string} types
|
|
* @param {DOMElement} element -Element on which the event is registered.
|
|
* @param {Function} callback - Function thats called when the event occurs.
|
|
* @memberof Card
|
|
*/
|
|
static registerEvent(context, types, element, callback) {
|
|
InteractionMapper.on(types, element, callback);
|
|
if (context._registeredEvents == null) context._registeredEvents = [];
|
|
if (context._registeredEvents.indexOf(element) == -1) context._registeredEvents.push(element);
|
|
}
|
|
|
|
/**
|
|
* Unregister all events on the infocard.
|
|
*
|
|
*
|
|
* @static
|
|
* @param {DomElement} context - Context of the card.
|
|
* @memberof Card
|
|
*/
|
|
static unregisterAllEvents(context) {
|
|
let eventElements = this.getRegisteredEvents(context);
|
|
InteractionMapper.off(eventElements);
|
|
}
|
|
}
|
|
|
|
Card.id = 0;
|
|
Card.debug = false;
|
|
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
|
|
*/
|
|
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 ScatterCard
|
|
*/
|
|
static setup(context, htmlString, { basePath = './', modules = [] } = {}) {
|
|
if (typeof context.scatter == 'undefined') {
|
|
console.error(
|
|
"You need to wrap the context inside a DOMScatter before executing the ScatterCard's setup function."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* This is required for the callback functions to work properly
|
|
*/
|
|
window.ScatterCard = ScatterCard;
|
|
|
|
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(context, html, 'onclick', this._replaceCallback);
|
|
|
|
let content = html.querySelector('.mainview');
|
|
context.appendChild(content);
|
|
|
|
super.setup(context, modules);
|
|
return context
|
|
}
|
|
|
|
/**
|
|
* Creates a scatter for the card and applies the card to it,
|
|
*
|
|
* @static
|
|
* @param {*} html
|
|
* @param {*} scatterContainer
|
|
* @param {string} [basePath=""]
|
|
* @param {*} [opts={}]
|
|
* @returns
|
|
* @memberof ScatterCard
|
|
*/
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Closes but NOT removes the scatter element.
|
|
*
|
|
* The remove must be called separately, it may be used in the close callback
|
|
* of the scatter.
|
|
*/
|
|
static close(context) {
|
|
if (typeof context.scatter != 'undefined') context.scatter.close();
|
|
else {
|
|
console.error('Expected a scatter element to close!', this);
|
|
}
|
|
|
|
// Card.close(context)
|
|
|
|
// if (context['scatter']) {
|
|
// console.error('CLOSED CARD')
|
|
// context.scatter.close()
|
|
// } else {
|
|
// console.error('Expected a scatter element to close!', this)
|
|
// }
|
|
}
|
|
|
|
/**
|
|
* Cleans up the card.
|
|
*
|
|
* @static
|
|
* @override
|
|
* @memberof ScatterCard
|
|
*/
|
|
static remove(context) {
|
|
if (context['scatter']) {
|
|
context.scatter = null;
|
|
} else {
|
|
console.error('Expected a scatter element to remove!', this);
|
|
}
|
|
|
|
Card.remove(context);
|
|
}
|
|
|
|
/**
|
|
*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 = [] } = {}) {
|
|
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
|
|
});
|
|
resolve(element);
|
|
})
|
|
.catch(e => reject(e));
|
|
})
|
|
}
|
|
|
|
static _setLanguage(context, language) {
|
|
context.language = language;
|
|
}
|
|
|
|
static _getLanguage(context) {
|
|
return context.language
|
|
}
|
|
}
|
|
|
|
ScatterCard.selectedLanguage = 0;
|
|
ScatterCard.languages = ['Deutsch', 'English'];
|
|
ScatterCard.languageTags = {
|
|
Deutsch: 'de',
|
|
English: 'en'
|
|
};
|
|
ScatterCard.scatterContainer = null;
|
|
|
|
var CardPlugin = CardPlugin || {};
|
|
|
|
class CardPluginBase {
|
|
apply(context) {
|
|
this.context = context;
|
|
if (this.verify(context)) {
|
|
this.append(context);
|
|
console.log('Plugin ' + this.name + ' was verified successfully.');
|
|
return true
|
|
} else console.error('Could not verify module ' + this.name + '.');
|
|
return false
|
|
}
|
|
|
|
get name() {
|
|
return this.constructor.name
|
|
}
|
|
|
|
verify(context) {
|
|
let funcs = this._getVerificationFunctions(context);
|
|
for (let func of funcs) {
|
|
if (!func()) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
_verifyElementsExist(context, ...selectors) {
|
|
let missing = [];
|
|
|
|
for (let selector of selectors) {
|
|
let requiredElement = context.querySelector(selector);
|
|
if (requiredElement == null) {
|
|
missing.push(selector);
|
|
}
|
|
}
|
|
const valid = missing.length == 0;
|
|
if (!valid) console.error('Elements were missing: ', missing.join(', '));
|
|
return valid
|
|
}
|
|
|
|
/**
|
|
* Appends the Plugin to the context.
|
|
*
|
|
* @memberof CardPlugin
|
|
*/
|
|
append(context) {
|
|
console.error(
|
|
'Call of abstract method CardPlugin.prototype.append(context). Plugins need to overwrite the append method!'
|
|
);
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
return [this._verifyContext.bind(this, context), this._verifyRequirements.bind(this, context)]
|
|
}
|
|
|
|
_verifyContext(context) {
|
|
if (!(context instanceof HTMLElement)) {
|
|
console.error('Context is not of type HTML Element.', context);
|
|
return false
|
|
} else return true
|
|
}
|
|
|
|
_verifyRequirements(context) {
|
|
let requirements = this._collectAllRequirements();
|
|
let missing = [];
|
|
|
|
requirements.forEach(module => {
|
|
if (context.modules.indexOf(module.name) == -1) {
|
|
missing.push(module.name);
|
|
}
|
|
});
|
|
|
|
const valid = missing.length == 0;
|
|
if (!valid)
|
|
console.error(
|
|
"Could not apply module '" +
|
|
this.name +
|
|
"'. Following modules are required but were missing: " +
|
|
missing.join(',')
|
|
);
|
|
else console.log('All requirements were met! Well done!');
|
|
return valid
|
|
}
|
|
|
|
_collectAllRequirements() {
|
|
let requirements = [];
|
|
let klass = this.__proto__;
|
|
while (klass) {
|
|
if (klass.require != null) {
|
|
requirements = requirements.concat(klass.require);
|
|
}
|
|
klass = klass.__proto__;
|
|
}
|
|
return requirements
|
|
}
|
|
|
|
/**
|
|
* Called when the card is removed.
|
|
* Can be used to cleanup the plugin.
|
|
*
|
|
* @memberof CardPluginBase
|
|
*/
|
|
remove() {}
|
|
}
|
|
|
|
CardPlugin.LightBox = class LightBox extends CardPluginBase {
|
|
constructor(className, style = {}) {
|
|
super();
|
|
this.className = className;
|
|
this.style = style;
|
|
}
|
|
|
|
append(context) {
|
|
let wrapper = document.createElement('div');
|
|
wrapper.className = this.className;
|
|
|
|
Object.assign(
|
|
wrapper.style,
|
|
{
|
|
zIndex: 1000,
|
|
// backgroundColor: "black",
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%'
|
|
},
|
|
this.style,
|
|
{
|
|
display: 'none',
|
|
position: 'absolute'
|
|
}
|
|
);
|
|
|
|
context.appendChild(wrapper);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The Enlargeable Overlay module allows the user to click on the thumbnail image,
|
|
* and the images gets enlarged inside the card.
|
|
*
|
|
* @class EnlargeableThumbnail
|
|
* @extends {CardPlugin}
|
|
*/
|
|
CardPlugin.EnlargeableThumbnail = class EnlargeableThumbnail extends CardPluginBase {
|
|
constructor(
|
|
wrapperSelector,
|
|
overlaySelector = null,
|
|
{ zoomAnimationDuration = 0.4, fadeAnimationDuration = 0.4, interactionType = 'tap' } = {}
|
|
) {
|
|
super();
|
|
this.wrapperSelector = wrapperSelector;
|
|
this.overlaySelector = overlaySelector;
|
|
|
|
this.zoomAnimationDuration = zoomAnimationDuration;
|
|
this.fadeAnimationDuration = fadeAnimationDuration;
|
|
this.interactionType = interactionType;
|
|
}
|
|
get require() {
|
|
return [CardPlugin.LightBox]
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
let arr = super._getVerificationFunctions(context);
|
|
let funcs = [this._verifyElementsExist.bind(this, context, this.wrapperSelector, this.overlaySelector)];
|
|
return arr.concat(funcs)
|
|
}
|
|
|
|
append(context) {
|
|
let source = this._retrieveSource(context);
|
|
this.setupEnlargeableThumbnail(context, source);
|
|
}
|
|
|
|
/**
|
|
* Get the preview image.
|
|
*
|
|
* It depends on the fact, that the thumbnail image is in the same directory
|
|
*
|
|
*
|
|
* @param {*} context
|
|
* @returns
|
|
* @memberof EnlargeableThumbnail
|
|
*/
|
|
_retrieveSource(context) {
|
|
let img = context.querySelector(this.wrapperSelector + ' img');
|
|
let src = img.getAttribute('src');
|
|
let parts = src.split('/');
|
|
parts.pop();
|
|
parts.push(parts[parts.length - 1]);
|
|
let imagePath = parts.join('/') + '.jpg';
|
|
return imagePath
|
|
}
|
|
|
|
setupEnlargeableThumbnail(context, src) {
|
|
let wrapper = context.querySelector(this.wrapperSelector);
|
|
let overlay = context.querySelector(this.overlaySelector);
|
|
|
|
let icon = document.createElement('div');
|
|
icon.className = 'button corner-button bottom-right icon zoom';
|
|
wrapper.appendChild(icon);
|
|
|
|
Object.assign(wrapper.style, {
|
|
cursor: 'pointer'
|
|
});
|
|
|
|
InteractionMapper.on(this.interactionType, wrapper, () => {
|
|
this.openThumbnailDetail(context, src);
|
|
});
|
|
|
|
InteractionMapper.on(this.interactionType, overlay, () => {
|
|
this.closeThumnailDetail(context);
|
|
});
|
|
}
|
|
|
|
openThumbnailDetail(context, src) {
|
|
let overlay = context.querySelector('.img-overlay');
|
|
overlay.innerHTML = '';
|
|
let source = context.querySelector(this.wrapperSelector);
|
|
let sourceStyle = window.getComputedStyle(source);
|
|
let imageWrapper = source.cloneNode(true);
|
|
let image = imageWrapper.querySelector('img');
|
|
|
|
Object.assign(imageWrapper.style, {
|
|
maxWidth: 'none',
|
|
maxHeight: 'none'
|
|
});
|
|
|
|
Object.assign(image.style, {
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover'
|
|
});
|
|
|
|
this._replaceIcon(imageWrapper);
|
|
|
|
image.onload = () => {
|
|
let header = context.querySelector('header');
|
|
let headerStlye = window.getComputedStyle(header);
|
|
|
|
/**
|
|
* First the maxFillRatio is considered.
|
|
* It describes how much the image is allowed to exceed the context element.
|
|
*/
|
|
const maxFillRatio = 1.5;
|
|
|
|
/**
|
|
* The minor side should not exceed the height of the context window.
|
|
*/
|
|
const maxMinorSize =
|
|
context.offsetHeight - 2 * parseInt(headerStlye.paddingTop) - 2 * parseInt(headerStlye.marginTop);
|
|
|
|
const max = {
|
|
width: context.offsetWidth * maxFillRatio,
|
|
height: context.offsetHeight * maxFillRatio
|
|
};
|
|
|
|
let majorSide;
|
|
let minorSide;
|
|
const _width = { name: 'width', axis: 'x' };
|
|
const _height = { name: 'height', axis: 'y' };
|
|
if (image.naturalHeight > image.naturalWidth) {
|
|
majorSide = _height;
|
|
minorSide = _width;
|
|
} else {
|
|
majorSide = _width;
|
|
minorSide = _height;
|
|
}
|
|
|
|
function capitalize(string) {
|
|
return string.charAt(0).toUpperCase() + string.slice(1)
|
|
}
|
|
function getImageSize(side) {
|
|
return image['natural' + capitalize(side.name)]
|
|
}
|
|
|
|
const majorImageSize = getImageSize(majorSide);
|
|
// const minorImageSize = getImageSize(minorSide)
|
|
|
|
let ratio = getImageSize(minorSide) / getImageSize(majorSide);
|
|
let size = majorImageSize > max[majorSide.name] ? max[majorSide.name] : majorImageSize;
|
|
|
|
if (size * ratio > maxMinorSize) {
|
|
size = maxMinorSize / ratio;
|
|
}
|
|
|
|
let targetDimensions = {
|
|
width: 0,
|
|
height: 0
|
|
};
|
|
|
|
let position = Points.fromPageToNode(context, Points.fromNodeToPage(source, { x: 0, y: 0 }));
|
|
|
|
let targetOffset = {
|
|
x: 0,
|
|
y: 0
|
|
};
|
|
|
|
targetDimensions[majorSide.name] = size;
|
|
targetDimensions[minorSide.name] = size * ratio;
|
|
|
|
targetOffset[majorSide.axis] =
|
|
(context['offset' + capitalize(majorSide.name)] - targetDimensions[majorSide.name]) / 2;
|
|
targetOffset[minorSide.axis] =
|
|
(context['offset' + capitalize(minorSide.name)] - targetDimensions[minorSide.name]) / 2;
|
|
|
|
overlay.appendChild(imageWrapper);
|
|
|
|
TweenMax.set(imageWrapper, {
|
|
left: 0,
|
|
top: 0,
|
|
x: position.x,
|
|
y: position.y,
|
|
position: 'absolute',
|
|
width: parseInt(sourceStyle.width),
|
|
height: parseInt(sourceStyle.height)
|
|
});
|
|
|
|
TweenMax.set(overlay, {
|
|
display: 'flex',
|
|
autoAlpha: 0
|
|
});
|
|
|
|
TweenMax.to(imageWrapper, this.zoomAnimationDuration, {
|
|
x: targetOffset.x,
|
|
y: targetOffset.y,
|
|
width: targetDimensions.width,
|
|
height: targetDimensions.height
|
|
});
|
|
TweenMax.to(overlay, this.fadeAnimationTime, {
|
|
autoAlpha: 1
|
|
});
|
|
};
|
|
|
|
image.src = src;
|
|
}
|
|
|
|
_replaceIcon(clone) {
|
|
let zoomIcon = clone.querySelector('.icon.zoom');
|
|
zoomIcon.classList.remove('zoom');
|
|
zoomIcon.classList.add('close');
|
|
}
|
|
|
|
getBorderHeight(style) {
|
|
const borderWidth = parseInt(style.borderTopWidth) + parseInt(style.borderBottomWidth);
|
|
const padding = parseInt(style.paddingTop) + parseInt(style.paddingBottom);
|
|
return parseInt(style.width) + borderWidth + padding
|
|
}
|
|
|
|
getBorderWidth(style) {
|
|
const borderWidth = parseInt(style.borderLeftWidth) + parseInt(style.borderRightWidth);
|
|
const padding = parseInt(style.paddingLeft) + parseInt(style.paddingRight);
|
|
return parseInt(style.width) + borderWidth + padding
|
|
}
|
|
|
|
closeThumnailDetail(context) {
|
|
let overlay = context.querySelector('.img-overlay');
|
|
|
|
let timeline = new TimelineLite();
|
|
|
|
timeline
|
|
.to(overlay, this.fadeAnimationDuration, {
|
|
autoAlpha: 0
|
|
})
|
|
.set(overlay, {
|
|
display: 'none'
|
|
});
|
|
}
|
|
};
|
|
|
|
CardPlugin.Ui = class UiPlugin extends CardPluginBase {
|
|
constructor(className, parent = null) {
|
|
super();
|
|
this.parent = parent;
|
|
this.className = className;
|
|
}
|
|
|
|
_getVerificationFunctions(context) {
|
|
let arr = super._getVerificationFunctions(context);
|
|
let func = [this._doesParentExist.bind(this, context, this.parent)];
|
|
return arr.concat(func)
|
|
}
|
|
|
|
_doesParentExist(context, parent) {
|
|
if (parent == null) return true
|
|
let valid = context.querySelector(parent) != null;
|
|
if (!valid) console.error('Could not find parent on context.', context, parent);
|
|
return valid
|
|
}
|
|
|
|
append(context) {
|
|
parent = this.parent == null ? context : context.querySelector(this.parent).appendChild(container);
|
|
let container = document.createElement('div');
|
|
container.className = this.className;
|
|
parent.appendChild(container);
|
|
}
|
|
};
|
|
|
|
CardPlugin.Speech = class SpeechPlugin extends CardPluginBase {
|
|
constructor(parentSelector, className, interactionType = 'tap') {
|
|
super();
|
|
this.className = className;
|
|
this.parentSelector = parentSelector;
|
|
this.interactionType = interactionType;
|
|
|
|
// We directly overwriting the function with a version that has a binded
|
|
// reference to itself. Doing so provides an easy and reliable way to remove
|
|
// the event listener using this function. - SO
|
|
this._domWasChanged = this._domWasChanged.bind(this);
|
|
|
|
/*
|
|
Speech doesn't stop when page is navigated.
|
|
Therefore we do it manually here.
|
|
*/
|
|
window.addEventListener('beforeunload', () => {
|
|
window.speechSynthesis.cancel();
|
|
});
|
|
|
|
// Binding the function beforehand ensures, that the end function is always the same.
|
|
this._end = this._end.bind(this);
|
|
|
|
this._setupUtterance();
|
|
this.utterance.addEventListener('end', event => {
|
|
this._end();
|
|
});
|
|
}
|
|
|
|
get require() {
|
|
return [CardPlugin.Ui]
|
|
}
|
|
|
|
subcardChanged(closed) {
|
|
if (this.cardActive) {
|
|
this._updateText(closed);
|
|
}
|
|
}
|
|
|
|
get cardActive() {
|
|
return this.activeUtterance == this.utterance
|
|
}
|
|
|
|
_updateText(ignoreSubcard = false) {
|
|
let node = this.context;
|
|
let subcard = node.querySelector('.mainview > .subcard');
|
|
|
|
if (ignoreSubcard) {
|
|
if (subcard != null) {
|
|
let clone = node.cloneNode(true);
|
|
let clonedSubcard = clone.querySelector('.mainview > .subcard');
|
|
clonedSubcard.parentNode.removeChild(clonedSubcard);
|
|
node = clone;
|
|
}
|
|
} else {
|
|
if (subcard) {
|
|
let clone = subcard.cloneNode(true);
|
|
clone.querySelectorAll('figure').forEach(figure => {
|
|
figure.parentNode.removeChild(figure);
|
|
});
|
|
|
|
node = clone;
|
|
}
|
|
}
|
|
|
|
let id = this.context.getAttribute('data-id');
|
|
let src = this.context.getAttribute('data-source');
|
|
let subcardSource = null;
|
|
if (subcard != null) {
|
|
subcardSource = subcard.getAttribute('data-source');
|
|
}
|
|
|
|
if (!window.speechSynthesis.speaking) {
|
|
this._start(node);
|
|
Logging.log(`Started speech on card: id:${id} - source: ${src} - subcard: ${subcardSource}`);
|
|
} else if (this.cardActive && this._sameText(node)) {
|
|
Logging.log(`Stopped speech on card: id:${id} - source: ${src} - subcard: ${subcardSource}`);
|
|
this._stop();
|
|
} else {
|
|
Logging.log(`Updated Text on card: id:${id} - source: ${src} - subcard: ${subcardSource}`);
|
|
this._stop()
|
|
.then(() => {
|
|
this._start(node);
|
|
})
|
|
.catch(console.error);
|
|
}
|
|
}
|
|
|
|
_sameText(node) {
|
|
return this.utterance.text == this._cleanupText(node)
|
|
}
|
|
|
|
_setupUtterance() {
|
|
this.utterance = new SpeechSynthesisUtterance();
|
|
this.utterance.lang = 'de-DE';
|
|
}
|
|
|
|
get require() {
|
|
return [CardPlugin.Ui]
|
|
}
|
|
|
|
remove() {
|
|
this.button = null;
|
|
this._stopThisSpeechIfPlaying();
|
|
this.context.removeEventListener('DOMNodeRemoved', this._domWasChanged);
|
|
super.remove();
|
|
}
|
|
|
|
append(context) {
|
|
let container = context.querySelector(this.parentSelector);
|
|
this.button = document.createElement('div');
|
|
this.button.className = 'icon button ' + this.className;
|
|
container.appendChild(this.button);
|
|
|
|
InteractionMapper.on(this.interactionType, this.button, () => {
|
|
this.speak();
|
|
});
|
|
|
|
context.addEventListener('DOMNodeRemoved', this._domWasChanged);
|
|
}
|
|
|
|
/**
|
|
* Don't remember why this was required - SO 20-11-2019
|
|
*/
|
|
_domWasChanged(event) {
|
|
if (event.target == this.context) this._stopThisSpeechIfPlaying();
|
|
}
|
|
|
|
/**
|
|
* Stops the module if it is set in the context.
|
|
*/
|
|
_stopThisSpeechIfPlaying() {
|
|
if (this.context == null || this.context['lastSpeechNode'] == window.speechSynthesis['speechPluginNode']) {
|
|
this._stop();
|
|
}
|
|
}
|
|
|
|
_isSameNode(node) {
|
|
return this.currentText == node.textContent
|
|
}
|
|
|
|
speak() {
|
|
/**
|
|
* This is a little bit ugly, but imho the most elegant of all dirty solutions.
|
|
*
|
|
5ht * Within the plugins we have no knowledge of other cards and such. But must differentiate the
|
|
* clicks by their corresponding owner. The SpeechUtterance just takes a text and has no knowledge
|
|
* about the node that is currently read to the user.
|
|
*
|
|
* This means, that we can identify same text, but not differentiate same text on different nodes.
|
|
* To account for that, we add the node to the speechSynthesis object (#benefitsOfJavaScript) and
|
|
* have access to the node, by - let's say - expanding the functionality of the SpeechSynthesis object.
|
|
*
|
|
* SO -17.07.19
|
|
*/
|
|
|
|
this._updateText();
|
|
}
|
|
|
|
async _stop() {
|
|
return new Promise(resolve => {
|
|
if (this.activeUtterance) {
|
|
this.activeUtterance.addEventListener('end', resolve, {
|
|
once: true
|
|
});
|
|
}
|
|
|
|
window.speechSynthesis.cancel();
|
|
})
|
|
}
|
|
|
|
get activeUtterance() {
|
|
return window.speechSynthesis['speechPluginUtterance']
|
|
}
|
|
|
|
_end() {
|
|
window.speechSynthesis['speechPluginNode'] = null;
|
|
window.speechSynthesis['speechPluginUtterance'] = null;
|
|
this._deactivateButton();
|
|
this.context.classList.remove('speech-plugin-is-reading');
|
|
}
|
|
|
|
_start(node) {
|
|
window.speechSynthesis.cancel();
|
|
|
|
window.speechSynthesis['speechPluginUtterance'] = this.utterance;
|
|
window.speechSynthesis['speechPluginNode'] = node;
|
|
this.context['lastSpeechNode'] = node;
|
|
|
|
let cleanText = this._cleanupText(node);
|
|
this.utterance.text = cleanText;
|
|
window.speechSynthesis.speak(this.utterance);
|
|
this._activateButton();
|
|
|
|
this.context.classList.add('speech-plugin-is-reading');
|
|
}
|
|
|
|
closed() {}
|
|
|
|
_cleanupText(node) {
|
|
let text = node.textContent;
|
|
text = this._removeShy(text);
|
|
return text
|
|
}
|
|
|
|
_removeShy(text) {
|
|
return text.replace(/\u00AD/g, '')
|
|
}
|
|
|
|
_activateButton() {
|
|
if (this.button) this.button.classList.add('active');
|
|
}
|
|
_deactivateButton() {
|
|
if (this.button) this.button.classList.remove('active');
|
|
}
|
|
};
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/**
|
|
* The Theme class helps manage the card themes.
|
|
* Usually it overrides or enhances aspects of the card.js.
|
|
*
|
|
* @class Theme
|
|
*/
|
|
class Theme {
|
|
/**
|
|
* Loads a config file and parses it to JSON.
|
|
*
|
|
* @static
|
|
* @param {string} [path=null] - Optional: A path where the config file is located at (including the filename and extension).
|
|
* @returns {Promise} - Returns a promise, that returns the parsed json file when resolved.
|
|
* @memberof Theme
|
|
*/
|
|
static loadConfig(path = null) {
|
|
return new Promise((resolve, reject) => {
|
|
path = path ? path : './config.json';
|
|
|
|
let xhttp = new XMLHttpRequest();
|
|
xhttp.onreadystatechange = function() {
|
|
if (this.readyState == 4) {
|
|
if (this.status == 200 || Theme._isLocal()) {
|
|
try {
|
|
const json = JSON.parse(this.responseText);
|
|
resolve(json);
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
} else reject('Invalid request : ' + this.status);
|
|
}
|
|
};
|
|
xhttp.open('GET', path, true);
|
|
xhttp.send();
|
|
})
|
|
}
|
|
|
|
static _isLocal() {
|
|
return window.location.protocol == 'file:'
|
|
}
|
|
}
|
|
|
|
/* Needed to ensure that rollup.js includes class definitions and the classes
|
|
are visible inside doctests.
|
|
*/
|
|
|
|
window.AbstractScatter = AbstractScatter;
|
|
window.Angle = Angle;
|
|
window.App = App;
|
|
window.BaseEvent = BaseEvent;
|
|
window.Capabilities = Capabilities;
|
|
window.CapabilitiesTests = CapabilitiesTests;
|
|
window.Colors = Colors;
|
|
window.Cycle = Cycle;
|
|
|
|
window.DOMFlip = DOMFlip;
|
|
window.DOMFlippable = DOMFlippable;
|
|
window.CardLoader = CardLoader;
|
|
window.PDFLoader = PDFLoader;
|
|
window.HTMLLoader = HTMLLoader;
|
|
window.ImageLoader = ImageLoader;
|
|
window.FrameLoader = FrameLoader;
|
|
|
|
window.DOMScatter = DOMScatter$1;
|
|
window.DOMScatterContainer = DOMScatterContainer;
|
|
window.Dates = Dates;
|
|
window.Doctest = Doctest;
|
|
window.Elements = Elements$1;
|
|
window.Errors = Errors;
|
|
window.EventRecorder = EventRecorder;
|
|
window.Events = Events;
|
|
window.FrameContainer = FrameContainer;
|
|
window.FrameTarget = FrameTarget;
|
|
window.IApp = IApp;
|
|
window.IInteractionMapperTarget = IInteractionMapperTarget;
|
|
window.IInteractionTarget = IInteractionTarget;
|
|
window.ITapDelegate = ITapDelegate;
|
|
window.Index = Index;
|
|
window.Inspect = Inspect;
|
|
window.Interaction = Interaction;
|
|
window.InteractionDelegate = InteractionDelegate;
|
|
window.InteractionDelta = InteractionDelta;
|
|
window.InteractionMapper = InteractionMapper$1;
|
|
window.InteractionPoints = InteractionPoints;
|
|
window.Interface = Interface;
|
|
window.Logging = Logging$1;
|
|
window.LowPassFilter = LowPassFilter;
|
|
window.PointMap = PointMap;
|
|
window.Rect = Rect$1;
|
|
window.Points = Points$1;
|
|
window.Polygon = Polygon;
|
|
window.Poppable = Poppable;
|
|
window.Popup = Popup$1;
|
|
window.PopupMenu = PopupMenu;
|
|
window.ResizeEvent = ResizeEvent;
|
|
window.ScatterEvent = ScatterEvent;
|
|
window.Sets = Sets;
|
|
window.Strings = Strings;
|
|
window.UITest = UITest;
|
|
window.getId = getId$1;
|
|
window.isEmpty = isEmpty;
|
|
window.lerp = lerp;
|
|
window.debounce = debounce;
|
|
window.randomInt = randomInt;
|
|
window.randomFloat = randomFloat;
|
|
|
|
window.CardWrapper = CardWrapper;
|
|
window.Card = Card;
|
|
window.CardPlugin = CardPlugin;
|
|
window.CardPluginBase = CardPluginBase;
|
|
window.ScatterCard = ScatterCard;
|
|
window.Highlight = Highlight;
|
|
window.Theme = Theme;
|
|
|
|
}());
|