From 444e59c69c2bdb1ddcf1e10a5deb7dc1f9e38615 Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Fri, 4 May 2018 20:03:57 +0100 Subject: [PATCH] Merging pinch-zoom (#27) * Merging pinch-zoom * Pixelated output --- .../output/custom-els/PinchZoom/index.ts | 298 ++++++++++++++++++ .../custom-els/PinchZoom/missing-types.d.ts | 16 + .../output/custom-els/PinchZoom/styles.css | 14 + src/components/output/index.tsx | 7 +- src/components/output/style.scss | 4 +- src/lib/PointerTracker/index.ts | 237 ++++++++++++++ src/lib/PointerTracker/missing-types.d.ts | 6 + 7 files changed, 577 insertions(+), 5 deletions(-) create mode 100644 src/components/output/custom-els/PinchZoom/index.ts create mode 100644 src/components/output/custom-els/PinchZoom/missing-types.d.ts create mode 100644 src/components/output/custom-els/PinchZoom/styles.css create mode 100644 src/lib/PointerTracker/index.ts create mode 100644 src/lib/PointerTracker/missing-types.d.ts diff --git a/src/components/output/custom-els/PinchZoom/index.ts b/src/components/output/custom-els/PinchZoom/index.ts new file mode 100644 index 00000000..f9259b19 --- /dev/null +++ b/src/components/output/custom-els/PinchZoom/index.ts @@ -0,0 +1,298 @@ +import './styles.css'; +import { PointerTracker, Pointer } from '../../../../lib/PointerTracker'; + +interface Point { + clientX: number; + clientY: number; +} + +interface ApplyChangeOpts { + panX?: number; + panY?: number; + scaleDiff?: number; + originX?: number; + originY?: number; +} + +interface SetTransformOpts { + scale?: number; + x?: number; + y?: number; + /** + * Fire a 'change' event if values are different to current values + */ + allowChangeEvent?: boolean; +} + +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 + }; +} + +// 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(); +} + +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(); + + 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; + event.preventDefault(); + return true; + }, + move: previousPointers => { + this._onPointerMove(previousPointers, pointerTracker.currentPointers); + } + }); + + this.addEventListener('wheel', event => this._onWheel(event)); + } + + connectedCallback () { + this._stageElChange(); + } + + get x () { + return this._transform.e; + } + + get y () { + return this._transform.f; + } + + get scale () { + return this._transform.a; + } + + /** + * 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. + let 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. + */ + _updateTransform (scale: number, x: number, y: number, allowChangeEvent: boolean) { + // 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) { + console.warn('There should be at least one child in .'); + 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(); + } + + private _onWheel (event: WheelEvent) { + event.preventDefault(); + + const thisRect = this.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; + + this._applyChange({ + scaleDiff, + originX: event.clientX - thisRect.left, + originY: event.clientY - thisRect.top + }); + } + + private _onPointerMove (previousPointers: Pointer[], currentPointers: Pointer[]) { + // Combine next points with previous points + const thisRect = this.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 - thisRect.left; + const originY = prevMidpoint.clientY - thisRect.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 + }); + } + + /** Transform the view & fire a change event */ + private _applyChange (opts: ApplyChangeOpts = {}) { + const { + panX = 0, panY = 0, + originX = 0, originY = 0, + scaleDiff = 1 + } = opts; + + const matrix = createMatrix() + // Translate according to panning. + .translate(panX, panY) + // Scale about the origin. + .translate(originX, originY) + .scale(scaleDiff) + .translate(-originX, -originY) + // Apply current transform. + .multiply(this._transform); + + // Convert the transform into basic translate & scale. + this.setTransform({ + scale: matrix.a, + x: matrix.e, + y: matrix.f, + allowChangeEvent: true + }); + } +} + +customElements.define('pinch-zoom', PinchZoom); diff --git a/src/components/output/custom-els/PinchZoom/missing-types.d.ts b/src/components/output/custom-els/PinchZoom/missing-types.d.ts new file mode 100644 index 00000000..3fe15aba --- /dev/null +++ b/src/components/output/custom-els/PinchZoom/missing-types.d.ts @@ -0,0 +1,16 @@ +declare interface CSSStyleDeclaration { + willChange: string | null; +} + +// TypeScript, you make me sad. +// https://github.com/Microsoft/TypeScript/issues/18756 +interface Window { + PointerEvent: typeof PointerEvent; + Touch: typeof Touch; +} + +declare namespace JSX { + interface IntrinsicElements { + "pinch-zoom": any + } +} diff --git a/src/components/output/custom-els/PinchZoom/styles.css b/src/components/output/custom-els/PinchZoom/styles.css new file mode 100644 index 00000000..83668f17 --- /dev/null +++ b/src/components/output/custom-els/PinchZoom/styles.css @@ -0,0 +1,14 @@ +pinch-zoom { + display: block; + overflow: hidden; + touch-action: none; + --scale: 1; + --x: 0; + --y: 0; +} + +pinch-zoom > * { + transform: translate(var(--x), var(--y)) scale(var(--scale)); + transform-origin: 0 0; + will-change: transform; +} diff --git a/src/components/output/index.tsx b/src/components/output/index.tsx index 2603e103..9b5bfee0 100644 --- a/src/components/output/index.tsx +++ b/src/components/output/index.tsx @@ -1,6 +1,5 @@ import { h, Component } from 'preact'; -// This isn't working. -// https://github.com/GoogleChromeLabs/squoosh/issues/14 +import './custom-els/PinchZoom'; import * as style from './style.scss'; type Props = { @@ -36,7 +35,9 @@ export default class App extends Component { render({ img }: Props, { }: State) { return (
- this.canvas = c as HTMLCanvasElement} width={img.width} height={img.height} /> + + this.canvas = c as HTMLCanvasElement} width={img.width} height={img.height} /> +

And that's all the app does so far!

); diff --git a/src/components/output/style.scss b/src/components/output/style.scss index d21e3038..0f709a65 100644 --- a/src/components/output/style.scss +++ b/src/components/output/style.scss @@ -1,3 +1,3 @@ -.app h1 { - color: green; +.outputCanvas { + image-rendering: pixelated; } diff --git a/src/lib/PointerTracker/index.ts b/src/lib/PointerTracker/index.ts new file mode 100644 index 00000000..c6bbcf89 --- /dev/null +++ b/src/lib/PointerTracker/index.ts @@ -0,0 +1,237 @@ +const enum Button { Left } + +export class Pointer { + /** x offset from the top of the document */ + pageX: number; + /** y offset from the top of the document */ + pageY: number; + /** x offset from the top of the viewport */ + clientX: number; + /** y offset from the top of the viewport */ + clientY: number; + /** ID for this pointer */ + id: number = -1; + + constructor (nativePointer: Touch | PointerEvent | MouseEvent) { + this.pageX = nativePointer.pageX; + this.pageY = nativePointer.pageY; + this.clientX = nativePointer.clientX; + this.clientY = nativePointer.clientY; + + if (self.Touch && nativePointer instanceof Touch) { + this.id = nativePointer.identifier; + } else if (isPointerEvent(nativePointer)) { // is PointerEvent + this.id = nativePointer.pointerId; + } + } +} + +const isPointerEvent = (event: any): event is PointerEvent => + self.PointerEvent && event instanceof PointerEvent; + +const noop = () => {}; + +type StartCallback = ((pointer: Pointer, event: TouchEvent | PointerEvent | MouseEvent) => boolean); +type MoveCallback = ((previousPointers: Pointer[], event: TouchEvent | PointerEvent | MouseEvent) => void); +type EndCallback = ((pointer: Pointer, event: TouchEvent | PointerEvent | MouseEvent) => void); + +interface PointerTrackerCallbacks { + /** + * Called when a pointer is pressed/touched within the element. + * + * @param pointer The new pointer. + * This pointer isn't included in this.currentPointers or this.startPointers yet. + * @param event The event related to this pointer. + * + * @returns Whether you want to track this pointer as it moves. + */ + start?: StartCallback; + /** + * Called when pointers have moved. + * + * @param previousPointers The state of the pointers before this event. + * This contains the same number of pointers, in the same order, as + * this.currentPointers and this.startPointers. + * @param event The event related to the pointer changes. + */ + move?: MoveCallback; + /** + * Called when a pointer is released. + * + * @param pointer The final state of the pointer that ended. This + * pointer is now absent from this.currentPointers and + * this.startPointers. + * @param event The event related to this pointer. + */ + end?: EndCallback; +} + +/** + * Track pointers across a particular element + */ +export class PointerTracker { + /** + * State of the tracked pointers when they were pressed/touched. + */ + readonly startPointers: Pointer[] = []; + /** + * Latest state of the tracked pointers. Contains the same number + * of pointers, and in the same order as this.startPointers. + */ + readonly currentPointers: Pointer[] = []; + + private _startCallback: StartCallback; + private _moveCallback: MoveCallback; + private _endCallback: EndCallback; + + /** + * Track pointers across a particular element + * + * @param element Element to monitor. + * @param callbacks + */ + constructor (private _element: HTMLElement, callbacks: PointerTrackerCallbacks) { + const { + start = () => true, + move = noop, + end = noop + } = callbacks; + + this._startCallback = start; + this._moveCallback = move; + this._endCallback = end; + + // Bind listener methods + this._pointerStart = this._pointerStart.bind(this); + this._touchStart = this._touchStart.bind(this); + this._move = this._move.bind(this); + this._pointerEnd = this._pointerEnd.bind(this); + this._touchEnd = this._touchEnd.bind(this); + + // Add listeners + if (self.PointerEvent) { + this._element.addEventListener('pointerdown', this._pointerStart); + } else { + this._element.addEventListener('mousedown', this._pointerStart); + this._element.addEventListener('touchstart', this._touchStart); + this._element.addEventListener('touchmove', this._move); + this._element.addEventListener('touchend', this._touchEnd); + } + } + + /** + * Call the start callback for this pointer, and track it if the user wants. + * + * @param pointer Pointer + * @param event Related event + * @returns Whether the pointer is being tracked. + */ + private _triggerPointerStart (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean { + if (!this._startCallback(pointer, event)) return false; + this.currentPointers.push(pointer); + this.startPointers.push(pointer); + return true; + } + + /** + * Listener for mouse/pointer starts. Bound to the class in the constructor. + * + * @param event This will only be a MouseEvent if the browser doesn't support + * pointer events. + */ + private _pointerStart (event: PointerEvent | MouseEvent) { + if (event.button !== Button.Left) return; + if (!this._triggerPointerStart(new Pointer(event), event)) return; + + // Add listeners for additional events. + // The listeners may already exist, but no harm in adding them again. + if (isPointerEvent(event)) { + this._element.setPointerCapture(event.pointerId); + this._element.addEventListener('pointermove', this._move); + this._element.addEventListener('pointerup', this._pointerEnd); + } else { // MouseEvent + window.addEventListener('mousemove', this._move); + window.addEventListener('mouseup', this._pointerEnd); + } + } + + /** + * Listener for touchstart. Bound to the class in the constructor. + * Only used if the browser doesn't support pointer events. + */ + private _touchStart (event: TouchEvent) { + for (const touch of Array.from(event.changedTouches)) { + this._triggerPointerStart(new Pointer(touch), event); + } + } + + /** + * Listener for pointer/mouse/touch move events. + * Bound to the class in the constructor. + */ + private _move (event: PointerEvent | MouseEvent | TouchEvent) { + const previousPointers = this.currentPointers.slice(); + const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'. + Array.from(event.changedTouches).map(t => new Pointer(t)) : + [new Pointer(event)]; + + let shouldCallback = false; + + for (const pointer of changedPointers) { + const index = this.currentPointers.findIndex(p => p.id === pointer.id); + if (index === -1) continue; + shouldCallback = true; + this.currentPointers[index] = pointer; + } + + if (!shouldCallback) return; + + this._moveCallback(previousPointers, event); + } + + /** + * Call the end callback for this pointer. + * + * @param pointer Pointer + * @param event Related event + */ + private _triggerPointerEnd (pointer: Pointer, event: PointerEvent | MouseEvent | TouchEvent): boolean { + const index = this.currentPointers.findIndex(p => p.id === pointer.id); + // Not a pointer we're interested in? + if (index === -1) return false; + + this.currentPointers.splice(index, 1); + this.startPointers.splice(index, 1); + + this._endCallback(pointer, event); + return true; + } + + /** + * Listener for mouse/pointer ends. Bound to the class in the constructor. + * @param event This will only be a MouseEvent if the browser doesn't support + * pointer events. + */ + private _pointerEnd (event: PointerEvent | MouseEvent) { + if (!this._triggerPointerEnd(new Pointer(event), event)) return; + + if (isPointerEvent(event)) { + if (this.currentPointers.length) return; + this._element.removeEventListener('pointermove', this._move); + this._element.removeEventListener('pointerup', this._pointerEnd); + } else { // MouseEvent + window.removeEventListener('mousemove', this._move); + window.removeEventListener('mouseup', this._pointerEnd); + } + } + + /** + * Listener for touchend. Bound to the class in the constructor. + * Only used if the browser doesn't support pointer events. + */ + private _touchEnd (event: TouchEvent) { + for (const touch of Array.from(event.changedTouches)) { + this._triggerPointerEnd(new Pointer(touch), event); + } + } +} diff --git a/src/lib/PointerTracker/missing-types.d.ts b/src/lib/PointerTracker/missing-types.d.ts new file mode 100644 index 00000000..01844dd3 --- /dev/null +++ b/src/lib/PointerTracker/missing-types.d.ts @@ -0,0 +1,6 @@ +// TypeScript, you make me sad. +// https://github.com/Microsoft/TypeScript/issues/18756 +interface Window { + PointerEvent: typeof PointerEvent; + Touch: typeof Touch; +}