import PointerTracker, { Pointer } from 'pointer-tracker'; import './styles.css'; interface Point { clientX: number; clientY: number; } interface ChangeOptions { /** * Fire a 'change' event if values are different to current values */ allowChangeEvent?: boolean; } interface ApplyChangeOpts extends ChangeOptions { panX?: number; panY?: number; scaleDiff?: number; originX?: number; originY?: number; } interface SetTransformOpts extends ChangeOptions { scale?: number; x?: number; y?: number; } type ScaleRelativeToValues = 'container' | 'content'; const minScaleAttr = 'min-scale'; const allowPanMinScaleAttr = 'allow-pan-min-scale'; const resetToMinScaleLimitAttr = 'reset-to-min-scale-limit'; const reachMinScaleStrategyAttr = 'reach-min-scale-strategy'; const stopPropagateHandledAttr = 'stop-propagate-handled'; type ReachMinScaleStrategy = 'reset' | 'none'; const reachMinScaleStrategyDefault: ReachMinScaleStrategy = 'none'; export interface ScaleToOpts extends ChangeOptions { /** Transform origin. Can be a number, or string percent, eg "50%" */ originX?: number | string; /** Transform origin. Can be a number, or string percent, eg "50%" */ originY?: number | string; /** Should the transform origin be relative to the container, or content? */ relativeTo?: ScaleRelativeToValues; } function getDistance(a: Point, b?: Point): number { if (!b) return 0; return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2); } function getMidpoint(a: Point, b?: Point): Point { if (!b) return a; return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2, }; } function getAbsoluteValue(value: string | number, max: number): number { if (typeof value === 'number') return value; if (value.trimRight().endsWith('%')) { return max * parseFloat(value) / 100; } return parseFloat(value); } // I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough. // Given that, better to use something everything supports. let cachedSvg: SVGSVGElement; function getSVG(): SVGSVGElement { return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')); } function createMatrix(): SVGMatrix { return getSVG().createSVGMatrix(); } function createPoint(): SVGPoint { return getSVG().createSVGPoint(); } const MIN_SCALE = 0.01; const ALLOW_PAN_MIN_SCALE = -1; const RESET_TO_MIN_SCALE_LIMIT = -1; const BUTTON_LEFT = 0; const roundedCmp = (a: number, b: number) => { return Math.round(a * 100) - Math.round(b * 100) } export default class PinchZoom extends HTMLElement { // The element that we'll transform. // Ideally this would be shadow DOM, but we don't have the browser // support yet. private _positioningEl?: Element; // Current transform. private _transform: SVGMatrix = createMatrix(); static get observedAttributes() { return [minScaleAttr]; } constructor() { super(); // Watch for children changes. // Note this won't fire for initial contents, // so _stageElChange is also called in connectedCallback. new MutationObserver(() => this._stageElChange()) .observe(this, { childList: true }); // Watch for pointers const pointerTracker: PointerTracker = new PointerTracker(this, { start: (pointer, event) => { // We only want to track 2 pointers at most if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false; const isPan = pointerTracker.currentPointers.length + 1 === 1; const handled = !(isPan && !this._allowPan()); if (handled) { this._maybeStopPropagate(event); if (!isPan) { // only cancel if something was propagated this._maybeEmitCancel([pointer, ...pointerTracker.currentPointers]); } } event.preventDefault(); return true; }, move: (previousPointers, _, event) => { this._onPointerMove(previousPointers, pointerTracker.currentPointers, event); }, end: (pointer, event) => { this._onPointerEnd(pointer, pointerTracker.currentPointers, event); }, }); this.addEventListener('wheel', event => this._onWheel(event)); this.addEventListener('click', event => this._onClick(event, pointerTracker)); } attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (name === minScaleAttr) { if (this.scale < this.minScale) { this.setTransform({scale: this.minScale}); } } } get minScale(): number { const attrValue = this.getAttribute(minScaleAttr); if (!attrValue) return MIN_SCALE; const value = parseFloat(attrValue); if (Number.isFinite(value)) return Math.max(MIN_SCALE, value); return MIN_SCALE; } set minScale(value: number) { this.setAttribute(minScaleAttr, String(value)); } get reachMinScaleStrategy(): ReachMinScaleStrategy { const attrValue = this.getAttribute(reachMinScaleStrategyAttr); const v = attrValue as ReachMinScaleStrategy; return v || reachMinScaleStrategyDefault; } set reachMinScaleStrategy(value: ReachMinScaleStrategy) { this.setAttribute(reachMinScaleStrategyAttr, value as string); } get allowPanMinScale(): number { const attrValue = this.getAttribute(allowPanMinScaleAttr); if (!attrValue) return ALLOW_PAN_MIN_SCALE; const value = parseFloat(attrValue); if (Number.isFinite(value)) return Math.max(ALLOW_PAN_MIN_SCALE, value); return ALLOW_PAN_MIN_SCALE; } set allowPanMinScale(value: number) { this.setAttribute(allowPanMinScaleAttr, String(value)); } get resetToMinScaleLimit(): number { const attrValue = this.getAttribute(resetToMinScaleLimitAttr); if (!attrValue) return RESET_TO_MIN_SCALE_LIMIT; const value = parseFloat(attrValue); if (Number.isFinite(value)) return Math.max(RESET_TO_MIN_SCALE_LIMIT, value); return RESET_TO_MIN_SCALE_LIMIT; } set resetToMinScaleLimit(value: number) { this.setAttribute(resetToMinScaleLimitAttr, String(value)); } get stopPropagateHandled() { return this.hasAttribute(stopPropagateHandledAttr); } set stopPropagateHandled(value: boolean) { if (value) { this.setAttribute(stopPropagateHandledAttr, ''); } else { this.removeAttribute(stopPropagateHandledAttr); } } connectedCallback() { this._stageElChange(); } get x() { return this._transform.e; } get y() { return this._transform.f; } get scale() { return this._transform.a; } /** * Change the scale, adjusting x/y by a given transform origin. */ scaleTo(scale: number, opts: ScaleToOpts = {}) { let { originX = 0, originY = 0, } = opts; const { relativeTo = 'content', allowChangeEvent = false, } = opts; const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this); // No content element? Fall back to just setting scale if (!relativeToEl || !this._positioningEl) { this.setTransform({ scale, allowChangeEvent }); return; } const rect = relativeToEl.getBoundingClientRect(); originX = getAbsoluteValue(originX, rect.width); originY = getAbsoluteValue(originY, rect.height); if (relativeTo === 'content') { originX += this.x; originY += this.y; } else { const currentRect = this._positioningEl.getBoundingClientRect(); originX -= currentRect.left; originY -= currentRect.top; } this._applyChange({ allowChangeEvent, originX, originY, scaleDiff: scale / this.scale, }); } /** * Update the stage with a given scale/x/y. */ setTransform(opts: SetTransformOpts = {}) { const { scale = this.scale, allowChangeEvent = false, } = opts; let { x = this.x, y = this.y, } = opts; // If we don't have an element to position, just set the value as given. // We'll check bounds later. if (!this._positioningEl) { this._updateTransform(scale, x, y, allowChangeEvent); return; } // Get current layout const thisBounds = this.getBoundingClientRect(); const positioningElBounds = this._positioningEl.getBoundingClientRect(); // Not displayed. May be disconnected or display:none. // Just take the values, and we'll check bounds later. if (!thisBounds.width || !thisBounds.height) { this._updateTransform(scale, x, y, allowChangeEvent); return; } // Create points for _positioningEl. let topLeft = createPoint(); topLeft.x = positioningElBounds.left - thisBounds.left; topLeft.y = positioningElBounds.top - thisBounds.top; let bottomRight = createPoint(); bottomRight.x = positioningElBounds.width + topLeft.x; bottomRight.y = positioningElBounds.height + topLeft.y; // Calculate the intended position of _positioningEl. const matrix = createMatrix() .translate(x, y) .scale(scale) // Undo current transform .multiply(this._transform.inverse()); topLeft = topLeft.matrixTransform(matrix); bottomRight = bottomRight.matrixTransform(matrix); // Ensure _positioningEl can't move beyond out-of-bounds. // Correct for x if (topLeft.x > thisBounds.width) { x += thisBounds.width - topLeft.x; } else if (bottomRight.x < 0) { x += -bottomRight.x; } // Correct for y if (topLeft.y > thisBounds.height) { y += thisBounds.height - topLeft.y; } else if (bottomRight.y < 0) { y += -bottomRight.y; } this._updateTransform(scale, x, y, allowChangeEvent); } /** * Update transform values without checking bounds. This is only called in setTransform. */ private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { // Avoid scaling to zero if (scale < this.minScale) { scale = this.minScale; } // Return if there's no change if ( scale === this.scale && x === this.x && y === this.y ) return; this._transform.e = x; this._transform.f = y; this._transform.d = this._transform.a = scale; this.style.setProperty('--x', this.x + 'px'); this.style.setProperty('--y', this.y + 'px'); this.style.setProperty('--scale', this.scale + ''); if (allowChangeEvent) { const event = new Event('change', { bubbles: true }); this.dispatchEvent(event); } } /** * Called when the direct children of this element change. * Until we have have shadow dom support across the board, we * require a single element to be the child of , and * that's the element we pan/scale. */ private _stageElChange() { this._positioningEl = undefined; if (this.children.length === 0) return; this._positioningEl = this.children[0]; if (this.children.length > 1) { console.warn(' must not have more than one child.'); } // Do a bounds check this.setTransform({ allowChangeEvent: true }); } private _onWheel(event: WheelEvent) { if (!this._positioningEl) return; event.preventDefault(); const currentRect = this._positioningEl.getBoundingClientRect(); let { deltaY } = event; const { ctrlKey, deltaMode } = event; if (deltaMode === 1) { // 1 is "lines", 0 is "pixels" // Firefox uses "lines" for some types of mouse deltaY *= 15; } // ctrlKey is true when pinch-zooming on a trackpad. const divisor = ctrlKey ? 100 : 300; const scaleDiff = 1 - deltaY / divisor; const isZoomOut = scaleDiff < 1; this._applyChange({ scaleDiff, originX: event.clientX - currentRect.left, originY: event.clientY - currentRect.top, allowChangeEvent: true, }); if (isZoomOut) { this._maybeResetScale(); } } private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[], event: Event) { if (!this._positioningEl) return; const isPan = previousPointers.length < 2; if (isPan && !this._allowPan()) { return; } // Combine next points with previous points const currentRect = this._positioningEl.getBoundingClientRect(); // For calculating panning movement const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]); const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]); // Midpoint within the element const originX = prevMidpoint.clientX - currentRect.left; const originY = prevMidpoint.clientY - currentRect.top; // Calculate the desired change in scale const prevDistance = getDistance(previousPointers[0], previousPointers[1]); const newDistance = getDistance(currentPointers[0], currentPointers[1]); const scaleDiff = prevDistance ? newDistance / prevDistance : 1; this._applyChange({ originX, originY, scaleDiff, panX: newMidpoint.clientX - prevMidpoint.clientX, panY: newMidpoint.clientY - prevMidpoint.clientY, allowChangeEvent: true, }); this._maybeStopPropagate(event); } private _maybeResetScale() { if (roundedCmp(this.scale, this.resetToMinScaleLimit) <= 0) { this._resetToMinScale(); } } private _onPointerEnd(pointer: Pointer, currentPointers: Pointer[], event: Event) { if (!this._positioningEl) return; const totalPointers = 1 + currentPointers.length; const isPinch = totalPointers >= 2; const isPan = totalPointers == 1; if (isPinch) { this._maybeResetScale(); } if (isPan && !this._allowPan()) { return; } this._maybeStopPropagate(event); } private _resetToMinScale() { if (this.reachMinScaleStrategy === 'reset') { this.setTransform({ scale: this.minScale, x: 0, y: 0 }); } else { this.setTransform({ scale: this.minScale }); } } /** Transform the view & fire a change event */ private _applyChange(opts: ApplyChangeOpts = {}) { const { panX = 0, panY = 0, originX = 0, originY = 0, scaleDiff = 1, allowChangeEvent = false, } = opts; const matrix = createMatrix() // Translate according to panning. .translate(panX, panY) // Scale about the origin. .translate(originX, originY) // Apply current translate .translate(this.x, this.y) .scale(scaleDiff) .translate(-originX, -originY) // Apply current scale. .scale(this.scale); // Convert the transform into basic translate & scale. this.setTransform({ allowChangeEvent, scale: matrix.a, x: matrix.e, y: matrix.f, }); } private _maybeStopPropagate(event: Event) { if (this.stopPropagateHandled) { event.stopPropagation(); } } private _allowPan() { return ( this.allowPanMinScale > 0 && roundedCmp(this.scale, this.allowPanMinScale) > 0 ); } private _maybeEmitCancel(pointers: Pointer[]) { const makeCancelEvent = (pointer: Pointer) => ( new PointerEvent( 'pointercancel', { pointerId: pointer.id, clientX: pointer.clientX, clientY: pointer.clientY, } )); if (this.stopPropagateHandled) { pointers.forEach(p => { if (this.parentElement && typeof this.parentElement.dispatchEvent === 'function') { this.parentElement.dispatchEvent(makeCancelEvent(p)); } }); } } private _onClick(event: MouseEvent, pointerTracker: PointerTracker) { // We never handle non-left-clicks if (event.button !== BUTTON_LEFT) { return; } const wasPanning = pointerTracker.currentPointers.length === 0; const handled = !(wasPanning && !this._allowPan()); if (handled) { this._maybeStopPropagate(event); } } }