Added support for DOM cards.

This commit is contained in:
Uwe Oestermeier 2019-07-12 14:33:15 +02:00
parent d04f92ee7f
commit e1b5c45b52
10 changed files with 941 additions and 386 deletions

325
dist/iwmlib.js vendored
View File

@ -4003,6 +4003,7 @@
}
class DOMScatter extends AbstractScatter {
constructor(
element,
@ -4028,7 +4029,7 @@
width = null, // required
height = null, // required
resizable = false,
clickOnTap = false,
tapDelegate = null,
triggerSVGClicks = false,
allowClickDistance = 44,
verbose = true,
@ -4085,8 +4086,7 @@
this.height = height;
this.throwVisibility = Math.min(width, height, throwVisibility);
this.container = container;
this.clickOnTap = clickOnTap;
this.triggerSVGClicks = triggerSVGClicks;
this.tapDelegate = tapDelegate;
this.scale = startScale;
this.rotationDegrees = this.startRotationDegrees;
this.transformOrigin = transformOrigin;
@ -4100,8 +4100,7 @@
transformOrigin: transformOrigin
};
this.tapNodes = new Map();
this.allowClickDistance = allowClickDistance;
// For tweenlite we need initial values in _gsTransform
TweenLite.set(element, this.initialValues);
this.onResize = onResize;
@ -4133,24 +4132,8 @@
});
this.resizeButton = button;
}
if (clickOnTap) {
/* Since the tap triggers a synthetic click event
we must prevent the original trusted click event which
is also dispatched by the system.
*/
element.addEventListener('click', event => {
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.target.ownerSVGElement) {
if (this.triggerSVGClicks) {
return
}
}
else if (event.isTrusted) {
Events.stop(event);
}
}, true);
if (tapDelegate) {
tapDelegate.handleClicks();
}
container.add(this);
}
@ -4312,110 +4295,12 @@
}
onTap(event, interaction, point) {
if (this.clickOnTap) {
let directNode = document.elementFromPoint(event.clientX, event.clientY);
console.log("onTap", event);
if (this.isClickable(directNode)) {
directNode.click();
}
else {
let nearestNode = this.nearestClickable(event);
if (this.isClickable(nearestNode)) {
/* 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 (nearestNode.tagName == 'svg') {
let handler = this.tapNodes.get(nearestNode);
console.log("Clicking near SVG: to be done", handler);
if (this.triggerSVGClicks)
nearestNode.dispatchEvent(new Event('click'));
return
}
console.log("nearestNode clicked");
nearestNode.click();
}
}
if (this.tapDelegate) {
Events.stop(event);
this.tapDelegate.tap(event, "scatter");
}
}
/**
* Adds a click or tap behavior to the node. Uses
* either the scatter clickOnTap version which requires click handlers
* or uses the hammer.js driven tap handler.
*
* @param {*} node
* @param {*} handler
* @memberof DOMScatter
*/
addTapListener(node, handler) {
if (this.clickOnTap) {
node.addEventListener('click', handler);
this.tapNodes.set(node, handler);
}
else {
InteractionMapper$1.on('tap', node, handler);
}
}
isClickable(node) {
if (node == null)
return false
if (node.tagName == 'A')
return true
if (node.hasAttribute("onclick"))
return true
if (this.tapNodes.has(node))
return true
return false
}
/**
* Returns an array of all clickable 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 clickables
* II. Objects that have been attached a click handler by the scatter itself via
*/
clickableNodes() {
let result = [];
for (let node of this.element.querySelectorAll("*")) {
if (this.isClickable(node))
result.push(node);
}
return result
}
nearestClickable(event) {
let element = this.element;
let clickables = this.clickableNodes();
let globalClick = (event.center) ? event.center : { x: event.x, y: event.y };
let localClick = Points.fromPageToNode(element, globalClick);
let clickRects = clickables.map(link => {
let rect = link.getBoundingClientRect();
let topLeft = Points.fromPageToNode(element, rect);
let center = Points.fromPageToNode(element, { 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 = [];
clickRects.forEach(rect => {
let distance = Points.distanceToRect(localClick, rect);
distances.push(parseInt(distance));
});
let closestClickIndex = distances.indexOf(Math.min(...distances));
let closestClickable = clickables[closestClickIndex];
if (distances[closestClickIndex] < this.allowClickDistance) {
return closestClickable
}
return null
}
isDescendant(parent, child) {
let node = child.parentNode;
while (node != null) {
@ -4735,7 +4620,7 @@
translatable = true,
scalable = true,
rotatable = true,
clickOnTap = false,
tapDelegateFactory = null,
onFront = null,
onBack = null,
onClose = null,
@ -4755,7 +4640,7 @@
this.translatable = translatable;
this.scalable = scalable;
this.rotatable = rotatable;
this.clickOnTap = clickOnTap;
this.tapDelegateFactory = tapDelegateFactory;
this.onFrontFlipped = onFront;
this.onBackFlipped = onBack;
this.onClose = onClose;
@ -4811,7 +4696,7 @@
scalable: this.scalable,
rotatable: this.rotatable,
overdoScaling: this.overdoScaling,
clickOnTap: this.clickOnTap
tapDelegate: (this.tapDelegateFactory) ? this.tapDelegateFactory(this.cardWrapper) : null
}
);
@ -7634,6 +7519,190 @@
}
}
/* 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 => {
console.log('handleClicks', event.isTrusted);
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.isTrusted) {
Events.stop(event);
if (this.triggerSVGClicks && this.isSVGNode(event.target)) {
this.tap(event, "triggerSVGClicks");
}
}
}, true);
}
handleClicksAsTaps() {
this.domNode.addEventListener('click', event => {
console.log('handleClicksAsTaps', event.isTrusted);
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.isTrusted) {
Events.stop(event);
}
this.tap(event);
}, true);
}
isClickable(node) {
if (node == null)
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()) {
console.log("selector", this.domNode.querySelectorAll(selector));
for (let obj of this.domNode.querySelectorAll(selector)) {
console.log("selector2", node, obj);
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.fromPageToNode(element, globalClick);
let clickRects = activeNodes.map(link => {
let rect = link.getBoundingClientRect();
let topLeft = Points.fromPageToNode(element, rect);
let center = Points.fromPageToNode(element, { 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 = [];
clickRects.forEach(rect => {
let distance = Points.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). */
console.log("simulateClick", node, node.ownerSVGElement);
if (this.isSVGNode(node)) {
if (this.triggerSVGClicks) {
let click = new Event('click');
node.dispatchEvent(click);
}
return
}
node.click();
}
nodeTapped(node, event) {
if (this.isClickable(node)) {
this.simulateClick(node, event);
return true
}
if (this.tapNodes.has(node)) {
handler = this.tapNodes.get(node);
handler(event);
return true
}
for (let [selector, handler] of this.tapHandler.entries()) {
for (let obj of this.domNode.querySelectorAll(selector)) {
if (node == obj) {
handler(event);
return true
}
}
}
return false
}
tap(event, calledBy='unknown') {
console.log("tap", calledBy, event.alreadyTapped, event);
if (event.isTrusted) {
let node = this.nearestActive(event);
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);
}
}
}
window.CardWrapper = CardWrapper;
/* Needed to ensure that rollup.js includes class definitions and the classes
are visible inside doctests.
*/
@ -7697,4 +7766,6 @@
window.randomInt = randomInt;
window.randomFloat = randomFloat;
window.CardWrapper = CardWrapper;
}());

139
dist/iwmlib.pixi.js vendored
View File

@ -7003,6 +7003,7 @@
}
class DOMScatter extends AbstractScatter {
constructor(
element,
@ -7028,7 +7029,7 @@
width = null, // required
height = null, // required
resizable = false,
clickOnTap = false,
tapDelegate = null,
triggerSVGClicks = false,
allowClickDistance = 44,
verbose = true,
@ -7085,8 +7086,7 @@
this.height = height;
this.throwVisibility = Math.min(width, height, throwVisibility);
this.container = container;
this.clickOnTap = clickOnTap;
this.triggerSVGClicks = triggerSVGClicks;
this.tapDelegate = tapDelegate;
this.scale = startScale;
this.rotationDegrees = this.startRotationDegrees;
this.transformOrigin = transformOrigin;
@ -7100,8 +7100,7 @@
transformOrigin: transformOrigin
};
this.tapNodes = new Map();
this.allowClickDistance = allowClickDistance;
// For tweenlite we need initial values in _gsTransform
TweenLite.set(element, this.initialValues);
this.onResize = onResize;
@ -7133,24 +7132,8 @@
});
this.resizeButton = button;
}
if (clickOnTap) {
/* Since the tap triggers a synthetic click event
we must prevent the original trusted click event which
is also dispatched by the system.
*/
element.addEventListener('click', event => {
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.target.ownerSVGElement) {
if (this.triggerSVGClicks) {
return
}
}
else if (event.isTrusted) {
Events$1.stop(event);
}
}, true);
if (tapDelegate) {
tapDelegate.handleClicks();
}
container.add(this);
}
@ -7312,110 +7295,12 @@
}
onTap(event, interaction, point) {
if (this.clickOnTap) {
let directNode = document.elementFromPoint(event.clientX, event.clientY);
console.log("onTap", event);
if (this.isClickable(directNode)) {
directNode.click();
}
else {
let nearestNode = this.nearestClickable(event);
if (this.isClickable(nearestNode)) {
/* 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 (nearestNode.tagName == 'svg') {
let handler = this.tapNodes.get(nearestNode);
console.log("Clicking near SVG: to be done", handler);
if (this.triggerSVGClicks)
nearestNode.dispatchEvent(new Event('click'));
return
}
console.log("nearestNode clicked");
nearestNode.click();
}
}
if (this.tapDelegate) {
Events$1.stop(event);
this.tapDelegate.tap(event, "scatter");
}
}
/**
* Adds a click or tap behavior to the node. Uses
* either the scatter clickOnTap version which requires click handlers
* or uses the hammer.js driven tap handler.
*
* @param {*} node
* @param {*} handler
* @memberof DOMScatter
*/
addTapListener(node, handler) {
if (this.clickOnTap) {
node.addEventListener('click', handler);
this.tapNodes.set(node, handler);
}
else {
InteractionMapper$1.on('tap', node, handler);
}
}
isClickable(node) {
if (node == null)
return false
if (node.tagName == 'A')
return true
if (node.hasAttribute("onclick"))
return true
if (this.tapNodes.has(node))
return true
return false
}
/**
* Returns an array of all clickable 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 clickables
* II. Objects that have been attached a click handler by the scatter itself via
*/
clickableNodes() {
let result = [];
for (let node of this.element.querySelectorAll("*")) {
if (this.isClickable(node))
result.push(node);
}
return result
}
nearestClickable(event) {
let element = this.element;
let clickables = this.clickableNodes();
let globalClick = (event.center) ? event.center : { x: event.x, y: event.y };
let localClick = Points.fromPageToNode(element, globalClick);
let clickRects = clickables.map(link => {
let rect = link.getBoundingClientRect();
let topLeft = Points.fromPageToNode(element, rect);
let center = Points.fromPageToNode(element, { 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 = [];
clickRects.forEach(rect => {
let distance = Points.distanceToRect(localClick, rect);
distances.push(parseInt(distance));
});
let closestClickIndex = distances.indexOf(Math.min(...distances));
let closestClickable = clickables[closestClickIndex];
if (distances[closestClickIndex] < this.allowClickDistance) {
return closestClickable
}
return null
}
isDescendant(parent, child) {
let node = child.parentNode;
while (node != null) {
@ -7601,7 +7486,7 @@
translatable = true,
scalable = true,
rotatable = true,
clickOnTap = false,
tapDelegateFactory = null,
onFront = null,
onBack = null,
onClose = null,
@ -7621,7 +7506,7 @@
this.translatable = translatable;
this.scalable = scalable;
this.rotatable = rotatable;
this.clickOnTap = clickOnTap;
this.tapDelegateFactory = tapDelegateFactory;
this.onFrontFlipped = onFront;
this.onBackFlipped = onBack;
this.onClose = onClose;
@ -7677,7 +7562,7 @@
scalable: this.scalable,
rotatable: this.rotatable,
overdoScaling: this.overdoScaling,
clickOnTap: this.clickOnTap
tapDelegate: (this.tapDelegateFactory) ? this.tapDelegateFactory(this.cardWrapper) : null
}
);

View File

@ -19,6 +19,7 @@ import {ResizeEvent, DOMScatterContainer, AbstractScatter, DOMScatter, ScatterEv
import {Cycle, Colors, Elements, Angle, Dates, Points, Polygon, Rect, Sets, Strings, isEmpty, getId, lerp, debounce, randomInt, randomFloat, LowPassFilter} from './utils.js'
import UITest from './uitest.js'
import {CardWrapper} from './card/wrapper.js'
/* Needed to ensure that rollup.js includes class definitions and the classes
are visible inside doctests.
*/
@ -81,3 +82,5 @@ window.lerp = lerp
window.debounce = debounce
window.randomInt = randomInt
window.randomFloat = randomFloat
window.CardWrapper = CardWrapper

400
lib/card/highlight.js Normal file
View File

@ -0,0 +1,400 @@
/* eslint-disable no-console */
/* global TweenLite */
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 represnted in percent.
*
* @class Highlight
* @extends {Object}
*/
class Highlight extends Object {
static disableAnimations() {
_HighlightEnabled = false
let expanded = document.querySelectorAll('.expanded')
for (let obj of expanded) {
this.shrink(obj)
}
}
static enableAnimations() {
_HighlightEnabled = true
}
static removeAnimations(svgRoot) {
let expanded = svgRoot.querySelectorAll('.expanded')
for (let obj of expanded) {
TweenLite.set(obj, { scale: 1 })
obj.classList.remove('zooming')
obj.classList.remove('expanded')
}
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 = 2, duration = 3, stroke = 2, onComplete = null } = {}) {
if (obj == null)
return
//console.log("expand")
obj.classList.add('zooming')
TweenLite.to(obj, duration, {
scale: 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('expanded')
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('expanded')
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('expanded')) {
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 })
}
Highlight._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 = 2,
onExpanded = null
} = {}) {
console.log('Open Highlight!')
if (Highlight._isExpanded(target)) {
console.log('Target is already expanded!')
return
} else {
let targetId = target.getAttribute('id')
console.log(targetId)
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')
let image = svgRoot.querySelector('image')
// eslint-disable-next-line no-unused-vars
let [mask, maskImage] = Highlight._getSVGMask(target, { svgRoot, image })
let center = Highlight._calculateCenterRelativeTo(target, image)
TweenLite.set(maskImage, { transformOrigin: `${center.x}% ${center.y}%` })
TweenLite.set(target, { transformOrigin: '50% 50%' })
TweenLite.to(target, 2, {
})
TweenLite.to([target, maskImage], animation, {
scale,
onComplete: onExpanded
})
target.classList.add('expanded')
}
return
}
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] = Highlight._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')
svgGroup.insertBefore(maskImage, element) // image.nextSibling)
TweenLite.set(maskImage, { scale: 1 })
maskImage.style.mask = 'url(#' + maskId + ')'
}
svgGroup.appendChild(maskImage)
// 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')
return { x: (cx / width) * 100, y: (cy / height) * 100 }
}
static _isExpanded(target) {
return target.classList.contains(Highlight.expandedClass)
}
static _setExpanded(target) {
target.classList.add(Highlight.expandedClass)
}
static _notExpanded(target) {
target.classList.remove(Highlight.expandedClass)
}
static closeHighlight(target, { animation = 0.5 } = {}) {
console.log('Close Highlight')
Highlight._notExpanded(target)
// eslint-disable-next-line no-unused-vars
let [mask, maskImage] = Highlight._getSVGMask(target)
TweenLite.to([target, maskImage], animation, {
scale: 1
})
}
static animate(event) {
if (!_HighlightEnabled)
return
event.stopPropagation()
Highlight.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'
window.Highlight = Highlight

107
lib/card/index.html Normal file
View File

@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Doctests Cards</title>
<link rel="stylesheet" href="../3rdparty/highlight/styles/default.css">
<link rel="stylesheet" href="../../css/doctest.css">
<script src="../3rdparty/highlight/highlight.pack.js"></script>
<script src="../../dist/iwmlib.js"></script>
<script src="../../dist/iwmlib.3rdparty.js"></script>
</head>
<body onload="Doctest.run();">
<h1>
Cards
</h1>
<p>
Cards implement a central UI metaphor for multiuser applications. They allow users to explore information spaces
independently from each other. Most of the time a card lives within a scatter and can be moved, rotated, and
scaled.
The scatter and it's interaction mapper are also responsible to detect and distinguish tap events form other
interactions.
But in preview programs and editors this is not necessary. Therefore we provide a central CardWrapper class that
turns any DOM node into a card.
</p>
<p>
The wrapper's main task is to handle tap events and trigger the corresponding actions. There are three main
ways to define these actions.
<ol>
<li>Define onclick handlers in HTML and SVG, e.g. <code>&lt;a onclick="alert(1)"&gt;link&lt;a&gt;</code>
</li>
<li>Use onTap with a DOM node as event target, e.g. <code>wrapper.onTap(node, event => {})</code></li>
<li>Use onTap with a CSS selector, e.g. <code>wrapper.onTap('.link', event => {})</code></li>
</li>
</ol>
The order in which these possibilities are testet is 1, 2, 3.
</p>
<p>
Note that the objects can also be actived by clicking nearby and not directly on the DOM node.
This solves a major problem on large tabletops with a parallaxis in the display.
</p>
<div class="interactive grayBorder" style="position: relative;">
<article id="demoCardWithOnClick"
style="position: relative; left: 0%; padding: 16px; margin: 16px; border: 1px solid gray; width: 320px; height: 240px;">
<h1 style="color: gray;">A Demo Card with onclick</h1>
<figure style="position: relative;">
<img width="75%" src="../examples/women.jpeg">
<svg id="overlayBase" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<circle cx="35" cy="50" r="35" class="highlight" onclick="alert('Highlight clicked')" stroke="white"
fill="transparent" stroke-width="1" />
</svg>
</figure>
<p>Lorem ipsum <a class="link" style="color:blue;" onclick="alert('Link clicked')">dolor</a> sit amet,
consetetur sadipscing elitr.</p>
</article>
<article id="demoCardWithSelector"
style="position: absolute; left: 50%; top: 0%; padding: 16px; margin: 16px; border: 1px solid gray; width: 320px; height: 240px;">
<h1 style="color: gray;">A Demo Card with selectors</h1>
<figure style="position: relative;">
<img width="75%" src="../examples/king.jpeg">
<svg id="overlayBase" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"
viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<circle cx="35" cy="50" r="35" class="highlight" stroke="white" fill="transparent"
stroke-width="1" />
</svg>
</figure>
<p>Lorem ipsum <a class="link" href="javascript:alert('Link clicked via href')" style="color:blue;">dolor</a> sit
amet,
consetetur sadipscing elitr.</p>
</article>
</div>
<script class="doctest">
const wrapper1 = new CardWrapper(demoCardWithOnClick)
wrapper1.handleClicksAsTaps()
const wrapper2 = new CardWrapper(demoCardWithSelector)
wrapper2.handleClicksAsTaps()
wrapper2.onTap('.highlight', event => {
alert('.highlight clicked')
})
wrapper2.onTap('.link', event => {
alert('.link clicked')
})
</script>
<h2>
Using Cards within Scatters
</h2>
<p>Cards can be used within scatters. Since the CardWrapper implements the TapDelegate protocol they can simply
be attached to a DOMScatter object.
</p>
<div class="interactive grayBorder" style="position: relative;">
</div>
<h2>
References
</h2>
<ul>
<li><a href="https://uicookies.com/css-card-design/">30 Visually Stunning CSS Cards Inspirations For Every Type Of Websites 2019</a></li>
</ul>
</body>

187
lib/card/wrapper.js Normal file
View File

@ -0,0 +1,187 @@
/* eslint-disable no-console */
/* global TweenLite */
import Events from '../events.js'
import { Points } from '../utils.js'
export 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 => {
console.log('handleClicks', event.isTrusted)
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.isTrusted) {
Events.stop(event)
if (this.triggerSVGClicks && this.isSVGNode(event.target)) {
this.tap(event, "triggerSVGClicks")
}
}
}, true)
}
handleClicksAsTaps() {
this.domNode.addEventListener('click', event => {
console.log('handleClicksAsTaps', event.isTrusted)
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.isTrusted) {
Events.stop(event)
}
this.tap(event)
}, true)
}
isClickable(node) {
if (node == null)
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()) {
console.log("selector", this.domNode.querySelectorAll(selector))
for (let obj of this.domNode.querySelectorAll(selector)) {
console.log("selector2", node, obj)
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.fromPageToNode(element, globalClick)
let clickRects = activeNodes.map(link => {
let rect = link.getBoundingClientRect()
let topLeft = Points.fromPageToNode(element, rect)
let center = Points.fromPageToNode(element, { 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 = []
clickRects.forEach(rect => {
let distance = Points.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). */
console.log("simulateClick", node, node.ownerSVGElement)
if (this.isSVGNode(node)) {
if (this.triggerSVGClicks) {
let click = new Event('click')
node.dispatchEvent(click)
}
return
}
node.click()
}
nodeTapped(node, event) {
if (this.isClickable(node)) {
this.simulateClick(node, event)
return true
}
if (this.tapNodes.has(node)) {
handler = this.tapNodes.get(node)
handler(event)
return true
}
for (let [selector, handler] of this.tapHandler.entries()) {
for (let obj of this.domNode.querySelectorAll(selector)) {
if (node == obj) {
handler(event)
return true
}
}
}
return false
}
tap(event, calledBy='unknown') {
console.log("tap", calledBy, event.alreadyTapped, event)
if (event.isTrusted) {
let node = this.nearestActive(event)
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)
}
}
}
window.CardWrapper = CardWrapper

View File

@ -68,11 +68,12 @@ templates.
<script class="doctest">
let scatterContainer = new DOMScatterContainer(main, {stopEvents: false})
if (Capabilities.supportsTemplate()) {
let flip = new DOMFlip(scatterContainer,
flipTemplate,
new ImageLoader('./examples/king.jpeg'),
new ImageLoader('./examples/women.jpeg'),
{ clickOnTap: true})
{ tapDelegateFactory: CardWrapper})
flip.load().then((flip) => {
flip.centerAt({ x: 150, y: 120})
})

View File

@ -199,7 +199,7 @@ export class DOMFlip {
translatable = true,
scalable = true,
rotatable = true,
clickOnTap = false,
tapDelegateFactory = null,
onFront = null,
onBack = null,
onClose = null,
@ -219,7 +219,7 @@ export class DOMFlip {
this.translatable = translatable
this.scalable = scalable
this.rotatable = rotatable
this.clickOnTap = clickOnTap
this.tapDelegateFactory = tapDelegateFactory
this.onFrontFlipped = onFront
this.onBackFlipped = onBack
this.onClose = onClose
@ -275,7 +275,7 @@ export class DOMFlip {
scalable: this.scalable,
rotatable: this.rotatable,
overdoScaling: this.overdoScaling,
clickOnTap: this.clickOnTap
tapDelegate: (this.tapDelegateFactory) ? this.tapDelegateFactory(this.cardWrapper) : null
}
)

View File

@ -89,8 +89,8 @@ to simulate click events by using taps. If the scatter detects a tap it looks fo
elements under or nearby the event position and calls the click handler. Thus gestures
can be disambiguated as moves, zooms. or taps.
Note that on touch devices you can tap beside the object if you use clickOnTap. The allowed distance
can be configured by allowClickDistance. The default value is 44px.
Note that on touch devices you can tap beside the object if you use an object that implements the ITapDelegate interface.
An ITapDelegate allowes a distance that can be configured by allowClickDistance. The default value is 44px.
</p>
<div id="contentExample" class="grayBorder interactive" style="position: relative; width: 100%; height: 280px;">
@ -110,14 +110,15 @@ can be configured by allowClickDistance. The default value is 44px.
<script class="doctest">
let contentContainer = new DOMScatterContainer(contentExample)
let tapDelegate = new CardWrapper(interactiveContent)
new DOMScatter(interactiveContent, contentContainer, {
x: 44,
y: 44,
width: 274,
height: 184,
clickOnTap: true,
tapDelegate,
throwVisibility: 88,
triggerSVGClicks: true,
minScale: 0.5,
maxScale: 1.5})

View File

@ -1,11 +1,26 @@
/* eslint-disable no-unused-vars */
/* eslint-disable no-console */
/* globals TweenLite debugCanvas */
import Interface from './interface.js'
import { Points, Polygon, Angle, Elements, LowPassFilter } from './utils.js'
import Events from './events.js'
import { InteractionMapper } from './interaction.js'
import { Capabilities } from './capabilities.js'
/** This interface allows scatters to delegate tap events to other objects. */
export 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.
*
@ -1035,6 +1050,7 @@ export class DOMScatterContainer {
}
export class DOMScatter extends AbstractScatter {
constructor(
element,
@ -1060,7 +1076,7 @@ export class DOMScatter extends AbstractScatter {
width = null, // required
height = null, // required
resizable = false,
clickOnTap = false,
tapDelegate = null,
triggerSVGClicks = false,
allowClickDistance = 44,
verbose = true,
@ -1117,8 +1133,7 @@ export class DOMScatter extends AbstractScatter {
this.height = height
this.throwVisibility = Math.min(width, height, throwVisibility)
this.container = container
this.clickOnTap = clickOnTap
this.triggerSVGClicks = triggerSVGClicks
this.tapDelegate = tapDelegate
this.scale = startScale
this.rotationDegrees = this.startRotationDegrees
this.transformOrigin = transformOrigin
@ -1132,8 +1147,7 @@ export class DOMScatter extends AbstractScatter {
transformOrigin: transformOrigin
}
this.tapNodes = new Map()
this.allowClickDistance = allowClickDistance
// For tweenlite we need initial values in _gsTransform
TweenLite.set(element, this.initialValues)
this.onResize = onResize
@ -1165,24 +1179,8 @@ export class DOMScatter extends AbstractScatter {
})
this.resizeButton = button
}
if (clickOnTap) {
/* Since the tap triggers a synthetic click event
we must prevent the original trusted click event which
is also dispatched by the system.
*/
element.addEventListener('click', event => {
/* Currently we cannot send synthesized click events to SVG elements without unwanted side effects.
Therefore we make an exception and let the original click event through.
*/
if (event.target.ownerSVGElement) {
if (this.triggerSVGClicks) {
return
}
}
else if (event.isTrusted) {
Events.stop(event)
}
}, true)
if (tapDelegate) {
tapDelegate.handleClicks()
}
container.add(this)
}
@ -1344,110 +1342,12 @@ export class DOMScatter extends AbstractScatter {
}
onTap(event, interaction, point) {
if (this.clickOnTap) {
let directNode = document.elementFromPoint(event.clientX, event.clientY)
console.log("onTap", event)
if (this.isClickable(directNode)) {
directNode.click()
}
else {
let nearestNode = this.nearestClickable(event)
if (this.isClickable(nearestNode)) {
/* 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 (nearestNode.tagName == 'svg') {
let handler = this.tapNodes.get(nearestNode)
console.log("Clicking near SVG: to be done", handler)
if (this.triggerSVGClicks)
nearestNode.dispatchEvent(new Event('click'))
return
}
console.log("nearestNode clicked")
nearestNode.click()
}
}
if (this.tapDelegate) {
Events.stop(event)
this.tapDelegate.tap(event, "scatter")
}
}
/**
* Adds a click or tap behavior to the node. Uses
* either the scatter clickOnTap version which requires click handlers
* or uses the hammer.js driven tap handler.
*
* @param {*} node
* @param {*} handler
* @memberof DOMScatter
*/
addTapListener(node, handler) {
if (this.clickOnTap) {
node.addEventListener('click', handler)
this.tapNodes.set(node, handler)
}
else {
InteractionMapper.on('tap', node, handler)
}
}
isClickable(node) {
if (node == null)
return false
if (node.tagName == 'A')
return true
if (node.hasAttribute("onclick"))
return true
if (this.tapNodes.has(node))
return true
return false
}
/**
* Returns an array of all clickable 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 clickables
* II. Objects that have been attached a click handler by the scatter itself via
*/
clickableNodes() {
let result = []
for (let node of this.element.querySelectorAll("*")) {
if (this.isClickable(node))
result.push(node)
}
return result
}
nearestClickable(event) {
let element = this.element
let clickables = this.clickableNodes()
let globalClick = (event.center) ? event.center : { x: event.x, y: event.y }
let localClick = Points.fromPageToNode(element, globalClick)
let clickRects = clickables.map(link => {
let rect = link.getBoundingClientRect()
let topLeft = Points.fromPageToNode(element, rect)
let center = Points.fromPageToNode(element, { 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 = []
clickRects.forEach(rect => {
let distance = Points.distanceToRect(localClick, rect)
distances.push(parseInt(distance))
})
let closestClickIndex = distances.indexOf(Math.min(...distances))
let closestClickable = clickables[closestClickIndex]
if (distances[closestClickIndex] < this.allowClickDistance) {
return closestClickable
}
return null
}
isDescendant(parent, child) {
let node = child.parentNode
while (node != null) {