diff --git a/dist/iwmlib.js b/dist/iwmlib.js index 847c842..71bf8fa 100644 --- a/dist/iwmlib.js +++ b/dist/iwmlib.js @@ -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; + }()); diff --git a/dist/iwmlib.pixi.js b/dist/iwmlib.pixi.js index 30375bd..ec6b2ba 100644 --- a/dist/iwmlib.pixi.js +++ b/dist/iwmlib.pixi.js @@ -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 } ); diff --git a/lib/bundle.js b/lib/bundle.js index da94acf..dbbdc35 100755 --- a/lib/bundle.js +++ b/lib/bundle.js @@ -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 \ No newline at end of file diff --git a/lib/card/highlight.js b/lib/card/highlight.js new file mode 100644 index 0000000..27f8c8f --- /dev/null +++ b/lib/card/highlight.js @@ -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: + * + * + * + * + * + * + * + * + * + * + * 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 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 diff --git a/lib/card/index.html b/lib/card/index.html new file mode 100644 index 0000000..b2b2734 --- /dev/null +++ b/lib/card/index.html @@ -0,0 +1,107 @@ + + + + + + Doctests Cards + + + + + + + + +

+ Cards +

+

+ 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. +

+

+ The wrapper's main task is to handle tap events and trigger the corresponding actions. There are three main + ways to define these actions. +

    +
  1. Define onclick handlers in HTML and SVG, e.g. <a onclick="alert(1)">link<a> +
  2. +
  3. Use onTap with a DOM node as event target, e.g. wrapper.onTap(node, event => {})
  4. +
  5. Use onTap with a CSS selector, e.g. wrapper.onTap('.link', event => {})
  6. + +
+ The order in which these possibilities are testet is 1, 2, 3. +

+

+ 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. +

+
+
+

A Demo Card with onclick

+
+ + + + +
+

Lorem ipsum dolor sit amet, + consetetur sadipscing elitr.

+
+ +
+

A Demo Card with selectors

+
+ + + + +
+

Lorem ipsum dolor sit + amet, + consetetur sadipscing elitr.

+
+ +
+ + + +

+ Using Cards within Scatters +

+

Cards can be used within scatters. Since the CardWrapper implements the TapDelegate protocol they can simply + be attached to a DOMScatter object. +

+
+
+ +

+ References +

+ + \ No newline at end of file diff --git a/lib/card/wrapper.js b/lib/card/wrapper.js new file mode 100644 index 0000000..c515ad3 --- /dev/null +++ b/lib/card/wrapper.js @@ -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 diff --git a/lib/flippable.html b/lib/flippable.html index 83cb984..7966494 100644 --- a/lib/flippable.html +++ b/lib/flippable.html @@ -68,11 +68,12 @@ templates.