From 700b1f15cd45ffcd57bb496a0439cc1c08db82f2 Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Wed, 5 Sep 2018 09:39:26 +0100 Subject: [PATCH] Pinch-zoom: scale around given origin. (#139) Also setting a min scale. --- .../Output/custom-els/PinchZoom/index.ts | 86 +++++++++++++++++-- src/components/Output/index.tsx | 35 ++++---- 2 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/components/Output/custom-els/PinchZoom/index.ts b/src/components/Output/custom-els/PinchZoom/index.ts index a29e0caf..0ddec51a 100644 --- a/src/components/Output/custom-els/PinchZoom/index.ts +++ b/src/components/Output/custom-els/PinchZoom/index.ts @@ -6,7 +6,14 @@ interface Point { clientY: number; } -interface ApplyChangeOpts { +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; @@ -14,14 +21,21 @@ interface ApplyChangeOpts { originY?: number; } -interface SetTransformOpts { +interface SetTransformOpts extends ChangeOptions { scale?: number; x?: number; y?: number; - /** - * Fire a 'change' event if values are different to current values - */ - allowChangeEvent?: boolean; +} + +type ScaleRelativeToValues = 'container' | 'content'; + +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 { @@ -38,6 +52,15 @@ function getMidpoint(a: Point, b?: Point): Point { }; } +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; @@ -54,6 +77,8 @@ function createPoint(): SVGPoint { return getSVG().createSVGPoint(); } +const MIN_SCALE = 0.01; + 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 @@ -103,6 +128,45 @@ export default class PinchZoom extends HTMLElement { 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.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; + } + + this._applyChange({ + allowChangeEvent, + originX, + originY, + scaleDiff: scale / this.scale, + }); + } + /** * Update the stage with a given scale/x/y. */ @@ -175,6 +239,9 @@ export default class PinchZoom extends HTMLElement { * Update transform values without checking bounds. This is only called in setTransform. */ _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { + // Avoid scaling to zero + if (scale < MIN_SCALE) return; + // Return if there's no change if ( scale === this.scale && @@ -217,7 +284,7 @@ export default class PinchZoom extends HTMLElement { } // Do a bounds check - this.setTransform(); + this.setTransform({ allowChangeEvent: true }); } private _onWheel(event: WheelEvent) { @@ -240,6 +307,7 @@ export default class PinchZoom extends HTMLElement { scaleDiff, originX: event.clientX - thisRect.left, originY: event.clientY - thisRect.top, + allowChangeEvent: true, }); } @@ -264,6 +332,7 @@ export default class PinchZoom extends HTMLElement { originX, originY, scaleDiff, panX: newMidpoint.clientX - prevMidpoint.clientX, panY: newMidpoint.clientY - prevMidpoint.clientY, + allowChangeEvent: true, }); } @@ -273,6 +342,7 @@ export default class PinchZoom extends HTMLElement { panX = 0, panY = 0, originX = 0, originY = 0, scaleDiff = 1, + allowChangeEvent = false, } = opts; const matrix = createMatrix() @@ -287,10 +357,10 @@ export default class PinchZoom extends HTMLElement { // Convert the transform into basic translate & scale. this.setTransform({ + allowChangeEvent, scale: matrix.a, x: matrix.e, y: matrix.f, - allowChangeEvent: true, }); } } diff --git a/src/components/Output/index.tsx b/src/components/Output/index.tsx index 74c4ad4b..e0d94151 100644 --- a/src/components/Output/index.tsx +++ b/src/components/Output/index.tsx @@ -1,5 +1,5 @@ import { h, Component } from 'preact'; -import PinchZoom from './custom-els/PinchZoom'; +import PinchZoom, { ScaleToOpts } from './custom-els/PinchZoom'; import './custom-els/PinchZoom'; import './custom-els/TwoUp'; import * as style from './style.scss'; @@ -19,6 +19,13 @@ interface State { altBackground: boolean; } +const scaleToOpts: ScaleToOpts = { + originX: '50%', + originY: '50%', + relativeTo: 'container', + allowChangeEvent: true, +}; + export default class Output extends Component { state: State = { scale: 1, @@ -48,14 +55,6 @@ export default class Output extends Component { if (prevProps.rightImg !== this.props.rightImg && this.canvasRight) { drawBitmapToCanvas(this.canvasRight, this.props.rightImg); } - - const { scale } = this.state; - if (scale !== prevState.scale && this.pinchZoomLeft && this.pinchZoomRight) { - // @TODO it would be nice if PinchZoom exposed a variant of setTransform() that - // preserved translation. It currently only does this for mouse wheel events. - this.pinchZoomLeft.setTransform({ scale }); - this.pinchZoomRight.setTransform({ scale }); - } } shouldComponentUpdate(nextProps: Props, nextState: State) { @@ -71,16 +70,16 @@ export default class Output extends Component { @bind zoomIn() { - this.setState({ - scale: Math.min(this.state.scale * 1.25, 100), - }); + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + + this.pinchZoomLeft.scaleTo(this.state.scale * 1.25, scaleToOpts); } @bind zoomOut() { - this.setState({ - scale: Math.max(this.state.scale / 1.25, 0.0001), - }); + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + + this.pinchZoomLeft.scaleTo(this.state.scale / 1.25, scaleToOpts); } @bind @@ -100,9 +99,9 @@ export default class Output extends Component { const target = event.target as HTMLInputElement; const percent = parseFloat(target.value); if (isNaN(percent)) return; - this.setState({ - scale: percent / 100, - }); + if (!this.pinchZoomLeft) throw Error('Missing pinch-zoom element'); + + this.pinchZoomLeft.scaleTo(percent / 100, scaleToOpts); } @bind