From d2c9d6eed9a9c96990102331a66f37eaa770c35a Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 13 Feb 2024 12:44:13 -0700 Subject: [PATCH] custom zoom pan pinch implementation --- package-lock.json | 10 +- packages/client-next/gulpfile.js | 10 +- packages/client-next/src/board.scss | 25 ++ .../src/components/CanvasWrapper.tsx | 50 +-- .../components/renderer/PanZoomWrapper.tsx | 5 - packages/client-next/src/style.scss | 1 + packages/lib/package.json | 5 +- packages/lib/src/renderer/PanZoom.ts | 379 ++++++++++++++++++ packages/lib/src/renderer/PanZoomWrapper.tsx | 41 ++ packages/lib/src/renderer/README.md | 10 + packages/lib/src/renderer/RendererContext.tsx | 4 + packages/lib/src/renderer/index.ts | 1 + .../lib/src/renderer/lib/panning.utils.ts | 60 +++ packages/lib/src/renderer/lib/pinch.utils.ts | 61 +++ packages/lib/src/renderer/lib/zoom.utils.ts | 49 +++ 15 files changed, 669 insertions(+), 42 deletions(-) create mode 100644 packages/client-next/src/board.scss delete mode 100644 packages/client-next/src/components/renderer/PanZoomWrapper.tsx create mode 100644 packages/lib/src/renderer/PanZoom.ts create mode 100644 packages/lib/src/renderer/PanZoomWrapper.tsx create mode 100644 packages/lib/src/renderer/README.md create mode 100644 packages/lib/src/renderer/RendererContext.tsx create mode 100644 packages/lib/src/renderer/index.ts create mode 100644 packages/lib/src/renderer/lib/panning.utils.ts create mode 100644 packages/lib/src/renderer/lib/pinch.utils.ts create mode 100644 packages/lib/src/renderer/lib/zoom.utils.ts diff --git a/package-lock.json b/package-lock.json index b905b04..8c4a2cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15018,7 +15018,15 @@ }, "packages/lib": { "name": "@sc07-canvas/lib", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "packages/lib/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, "packages/server": { "name": "@sc07-canvas/server", diff --git a/packages/client-next/gulpfile.js b/packages/client-next/gulpfile.js index 63cd6e0..50344db 100644 --- a/packages/client-next/gulpfile.js +++ b/packages/client-next/gulpfile.js @@ -20,7 +20,15 @@ gulp.task("js", function () { gulp.task( "watch", gulp.series("js", "css", function () { - gulp.watch(["src/**/*.ts", "src/**/*.tsx"], gulp.series("js")); + gulp.watch( + [ + "src/**/*.ts", + "src/**/*.tsx", + "../lib/src/**/*.ts", + "../lib/src/**/*.tsx", + ], + gulp.series("js") + ); gulp.watch("src/**/*.scss", gulp.series("css")); }) ); diff --git a/packages/client-next/src/board.scss b/packages/client-next/src/board.scss new file mode 100644 index 0000000..1f17dd9 --- /dev/null +++ b/packages/client-next/src/board.scss @@ -0,0 +1,25 @@ +.board-wrapper { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + height: 100vh; + + display: flex; + justify-content: center; + align-items: center; +} + +.debug-point { + position: fixed; + z-index: 9999; + width: 10px; + height: 10px; + border-radius: 5px; + background-color: #ff0000; + opacity: 0.5; + pointer-events: none; + touch-action: none; +} diff --git a/packages/client-next/src/components/CanvasWrapper.tsx b/packages/client-next/src/components/CanvasWrapper.tsx index 389269e..56a6da9 100644 --- a/packages/client-next/src/components/CanvasWrapper.tsx +++ b/packages/client-next/src/components/CanvasWrapper.tsx @@ -8,33 +8,15 @@ import { import { Canvas } from "../lib/canvas"; import { useAppContext } from "../contexts/AppContext"; import throttle from "lodash.throttle"; +import { PanZoomWrapper } from "@sc07-canvas/lib/src/renderer"; export const CanvasWrapper = () => { // to prevent safari from blurring things, use the zoom css property return (
- - - - - + + +
); }; @@ -42,28 +24,28 @@ export const CanvasWrapper = () => { const CanvasInner = () => { const canvasRef = createRef(); const { config } = useAppContext(); - const { centerView } = useControls(); + // const { centerView } = useControls(); - useTransformEffect( - throttle(({ state, instance }) => { - const params = new URLSearchParams(); - params.set("x", state.positionX + ""); - params.set("y", state.positionY + ""); - params.set("zoom", state.scale + ""); - window.location.hash = params.toString(); - }, 1000) - ); + // useTransformEffect( + // throttle(({ state, instance }) => { + // const params = new URLSearchParams(); + // params.set("x", state.positionX + ""); + // params.set("y", state.positionY + ""); + // params.set("zoom", state.scale + ""); + // window.location.hash = params.toString(); + // }, 1000) + // ); useEffect(() => { if (!config.canvas || !canvasRef.current) return; const canvas = canvasRef.current!; const canvasInstance = new Canvas(config, canvas); - centerView(); + // centerView(); return () => { canvasInstance.destroy(); }; - }, [canvasRef, centerView, config]); + }, [canvasRef, config]); return ( { - return
{children}
; -}; diff --git a/packages/client-next/src/style.scss b/packages/client-next/src/style.scss index 75d2cd4..83c74a8 100644 --- a/packages/client-next/src/style.scss +++ b/packages/client-next/src/style.scss @@ -116,3 +116,4 @@ main { } @import "./components/Pallete.scss"; +@import "./board.scss"; diff --git a/packages/lib/package.json b/packages/lib/package.json index 4550c39..2132803 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -1,5 +1,8 @@ { "name": "@sc07-canvas/lib", "version": "1.0.0", - "main": "./src/index.ts" + "main": "./src/index.ts", + "dependencies": { + "eventemitter3": "^5.0.1" + } } diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts new file mode 100644 index 0000000..dd4980d --- /dev/null +++ b/packages/lib/src/renderer/PanZoom.ts @@ -0,0 +1,379 @@ +import EventEmitter from "eventemitter3"; +import { + calculatePinchZoom, + calculateTouchMidPoint, + getTouchDistance, +} from "./lib/pinch.utils"; +import { + checkZoomBounds, + handleCalculateZoomPositions, +} from "./lib/zoom.utils"; +import { Panning } from "./lib/panning.utils"; + +interface TransformState { + scale: number; + x: number; + y: number; +} + +interface Flags { + useZoom: boolean; +} + +interface TouchState { + lastTouch: number | null; + pinchStartDistance: number | null; + lastDistance: number | null; + pinchStartScale: number | null; + pinchMidpoint: { x: number; y: number } | null; +} + +interface MouseState {} + +interface ISetup { + scale: [number, number]; +} + +interface PanZoomEvents { + doubleTap: (e: TouchEvent) => void; +} + +export class PanZoom extends EventEmitter { + public $wrapper: HTMLDivElement = null as any; + public $zoom: HTMLDivElement = null as any; + public $move: HTMLDivElement = null as any; + + public transform: TransformState; + public touch: TouchState; + public mouse: MouseState; + public setup: ISetup; + public flags: Flags; + + public panning: Panning; + + constructor() { + super(); + + this.transform = { + scale: 1, + x: 0, + y: 0, + }; + + this.touch = { + lastTouch: null, + pinchStartDistance: null, + lastDistance: null, + pinchStartScale: null, + pinchMidpoint: null, + }; + + this.mouse = {}; + + this.panning = new Panning(this); + + this.setup = { + scale: [1, 50], + }; + + this.flags = { + useZoom: false, + }; + } + + initialize( + $wrapper: HTMLDivElement, + $zoom: HTMLDivElement, + $move: HTMLDivElement + ) { + this.$wrapper = $wrapper; + this.$zoom = $zoom; + this.$move = $move; + + this.detectFlags(); + this.registerMouseEvents(); + this.registerTouchEvents(); + } + + detectFlags() { + // Pxls/resources/public/include/helpers.js + let haveZoomRendering = false; + let haveImageRendering = false; + const webkitBased = navigator.userAgent.match(/AppleWebKit/i); + const iOSSafari = + navigator.userAgent.match(/(iPod|iPhone|iPad)/i) && webkitBased; + const desktopSafari = + navigator.userAgent.match(/safari/i) && + !navigator.userAgent.match(/chrome/i); + const msEdge = navigator.userAgent.indexOf("Edge") > -1; + const possiblyMobile = + window.innerWidth < 768 && navigator.userAgent.includes("Mobile"); + if (iOSSafari) { + const iOS = + parseFloat( + ( + "" + + (/CPU.*OS ([0-9_]{1,5})|(CPU like).*AppleWebKit.*Mobile/i.exec( + navigator.userAgent + ) || [0, ""])[1] + ) + .replace("undefined", "3_2") + .replace("_", ".") + .replace("_", "") + ) || false; + haveImageRendering = false; + if (iOS && iOS >= 11) { + haveZoomRendering = true; + } + } else if (desktopSafari) { + haveImageRendering = false; + haveZoomRendering = true; + } + if (msEdge) { + haveImageRendering = false; + } + + this.flags.useZoom = haveZoomRendering; + } + + registerTouchEvents() { + this.$wrapper.addEventListener( + "touchstart", + (event) => { + const isDoubleTap = + this.touch.lastTouch && +new Date() - this.touch.lastTouch < 200; + + if (isDoubleTap && event.touches.length === 1) { + this.emit("doubleTap", event); + } else { + this.touch.lastTouch = +new Date(); + + const { touches } = event; + + const isPanningAction = touches.length === 1; + const isPinchAction = touches.length === 2; + + if (isPanningAction) { + this.panning.start(touches[0].clientX, touches[0].clientY); + } + if (isPinchAction) { + this.onPinchStart(event); + } + } + }, + { passive: false } + ); + + this.$wrapper.addEventListener("touchmove", (event) => { + if (this.panning.enabled && event.touches.length === 1) { + event.preventDefault(); + event.stopPropagation(); + + const touch = event.touches[0]; + + this.panning.move(touch.clientX, touch.clientY); + } else if (event.touches.length > 1) { + this.onPinch(event); + } + }); + + this.$wrapper.addEventListener("touchend", (event) => { + if (this.panning.enabled) { + this.panning.enabled = false; + + const touch = event.changedTouches[0]; + + this.panning.end(touch.clientX, touch.clientY); + } + }); + } + + /// ///// + // pinch + /// ///// + + onPinchStart(event: TouchEvent) { + const distance = getTouchDistance(event); + + this.touch.pinchStartDistance = distance; + this.touch.lastDistance = distance; + this.touch.pinchStartScale = this.transform.scale; + this.panning.enabled = false; + } + + onPinch(event: TouchEvent) { + event.preventDefault(); + event.stopPropagation(); + + const { scale } = this.transform; + + // one finger started from outside the wrapper + if (this.touch.pinchStartDistance === null) return; + + let el: HTMLElement = document.body; + // switch ( + // (document.getElementById("test-flag")! as HTMLSelectElement).value + // ) { + // case "body": + // el = document.body; + // break; + // case "wrapper": + // el = this.$wrapper; + // break; + // case "move": + // el = this.$move; + // break; + // default: + // case "zoom": + // el = this.$zoom; + // break; + // } + + const midPoint = calculateTouchMidPoint(this, event, scale, el); + + if (!Number.isFinite(midPoint.x) || !Number.isFinite(midPoint.y)) return; + + const currentDistance = getTouchDistance(event); + const newScale = calculatePinchZoom(this, currentDistance); + + if (newScale === scale) return; + + const { x, y } = handleCalculateZoomPositions( + this, + midPoint.x, + midPoint.y, + newScale + ); + + this.touch.pinchMidpoint = midPoint; + this.touch.lastDistance = currentDistance; + + this.debug(midPoint.x, midPoint.y, "midpoint"); + + this.transform.x = midPoint.x / newScale - midPoint.x / scale; + this.transform.y = midPoint.y / newScale - midPoint.x / scale; + this.transform.scale = newScale; + this.update(); + } + + debug(x: number, y: number, id?: string) { + // if (document.getElementById("debug-" + id)) { + // document.getElementById("debug-" + id)!.style.top = y + "px"; + // document.getElementById("debug-" + id)!.style.left = x + "px"; + // return; + // } + // let el = document.createElement("div"); + // if (id) el.id = "debug-" + id; + // el.classList.add("debug-point"); + // el.style.setProperty("top", y + "px"); + // el.style.setProperty("left", x + "px"); + // document.body.appendChild(el); + } + + registerMouseEvents() { + // zoom + this.$wrapper.addEventListener( + "wheel", + (e) => { + // if (!self.allowDrag) return; + const oldScale = this.transform.scale; + + let delta = -e.deltaY; + + switch (e.deltaMode) { + case WheelEvent.DOM_DELTA_PIXEL: + // 53 pixels is the default chrome gives for a wheel scroll. + delta /= 53; + break; + case WheelEvent.DOM_DELTA_LINE: + // default case on Firefox, three lines is default number. + delta /= 3; + break; + case WheelEvent.DOM_DELTA_PAGE: + delta = Math.sign(delta); + break; + } + + this.nudgeScale(delta); + + const scale = this.transform.scale; + if (oldScale !== scale) { + const dx = e.clientX - this.$wrapper.clientWidth / 2; + const dy = e.clientY - this.$wrapper.clientHeight / 2; + this.transform.x -= dx / oldScale; + this.transform.x += dx / scale; + this.transform.y -= dy / oldScale; + this.transform.y += dy / scale; + this.update(); + // place.update(); + } + }, + { passive: true } + ); + + this.$wrapper.addEventListener( + "mousedown", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.panning.start(e.clientX, e.clientY); + }, + { passive: false } + ); + + this.$wrapper.addEventListener( + "mousemove", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (!this.panning.enabled) return; + + this.panning.move(e.clientX, e.clientY); + }, + { passive: false } + ); + + this.$wrapper.addEventListener( + "mouseup", + (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.panning.end(e.clientX, e.clientY); + }, + { passive: false } + ); + } + + update() { + if (this.flags.useZoom) { + this.$zoom.style.setProperty("zoom", this.transform.scale * 100 + "%"); + } else { + this.$zoom.style.setProperty( + "transform", + `scale(${this.transform.scale})` + ); + } + + this.$move.style.setProperty( + "transform", + `translate(${this.transform.x}px, ${this.transform.y}px)` + ); + } + + cleanup() { + // remove event handlers + } + + // utilities + + nudgeScale(adj: number) { + this.transform.scale = checkZoomBounds( + this.transform.scale * 1.5 ** adj, + this.setup.scale[0], + this.setup.scale[1] + ); + } +} diff --git a/packages/lib/src/renderer/PanZoomWrapper.tsx b/packages/lib/src/renderer/PanZoomWrapper.tsx new file mode 100644 index 0000000..f346769 --- /dev/null +++ b/packages/lib/src/renderer/PanZoomWrapper.tsx @@ -0,0 +1,41 @@ +import React, { useRef, useState, useEffect } from "react"; +import { RendererContext } from "./RendererContext"; +import { PanZoom } from "./PanZoom"; + +export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => { + const wrapper = useRef(null); + const zoom = useRef(null); + const move = useRef(null); + + const instance = useRef(new PanZoom()).current; + + useEffect(() => { + const $wrapper = wrapper.current; + const $zoom = zoom.current; + const $move = move.current; + + if ($wrapper && $zoom && $move) { + instance.initialize($wrapper, $zoom, $move); + } + + return () => { + instance.cleanup(); + }; + }, []); + + return ( + +
+
+
+ {children} +
+
+
+
+ ); +}; diff --git a/packages/lib/src/renderer/README.md b/packages/lib/src/renderer/README.md new file mode 100644 index 0000000..42ceaed --- /dev/null +++ b/packages/lib/src/renderer/README.md @@ -0,0 +1,10 @@ +This is a merge of two projects + +- https://github.com/BetterTyped/react-zoom-pan-pinch +- https://github.com/pxlsspace/Pxls + +react-zoom-pan-pinch handles most cases, but doesn't support css `zoom` property, which Pxls found out(?) that on some browsers, css `zoom` can be used to make canvas elements not blurry on some browsers + +react-zoom-pan-pinch would've required a lot of modifications to how it calculates touch points as css `zoom` changes coordinates returned by events + +Both projects are MIT licensed diff --git a/packages/lib/src/renderer/RendererContext.tsx b/packages/lib/src/renderer/RendererContext.tsx new file mode 100644 index 0000000..284bbea --- /dev/null +++ b/packages/lib/src/renderer/RendererContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import { PanZoom } from "./PanZoom"; + +export const RendererContext = createContext(null as any); diff --git a/packages/lib/src/renderer/index.ts b/packages/lib/src/renderer/index.ts new file mode 100644 index 0000000..640916c --- /dev/null +++ b/packages/lib/src/renderer/index.ts @@ -0,0 +1 @@ +export { PanZoomWrapper } from "./PanZoomWrapper"; diff --git a/packages/lib/src/renderer/lib/panning.utils.ts b/packages/lib/src/renderer/lib/panning.utils.ts new file mode 100644 index 0000000..9430bc9 --- /dev/null +++ b/packages/lib/src/renderer/lib/panning.utils.ts @@ -0,0 +1,60 @@ +import { PanZoom } from "../PanZoom"; + +export class Panning { + private instance: PanZoom; + + public enabled: boolean = false; + public x: number = 0; + public y: number = 0; + + constructor(instance: PanZoom) { + this.instance = instance; + } + + /** + * trigger panning start + * @param x clientX + * @param y clientY + */ + public start(x: number, y: number) { + this.enabled = true; + this.x = x; + this.y = y; + } + + /** + * handle moving + * @param x clientX + * @param y clientY + */ + public move(x: number, y: number) { + const deltaX = (x - this.x) / this.instance.transform.scale; + const deltaY = (y - this.y) / this.instance.transform.scale; + const newX = this.instance.transform.x + deltaX; + const newY = this.instance.transform.y + deltaY; + + this.instance.$move.style.setProperty( + "transform", + `translate(${newX}px, ${newY}px)` + ); + } + + /** + * save the final change + * @param x clientX + * @param y clientY + */ + public end(x: number, y: number) { + this.enabled = false; + + const deltaX = (x - this.x) / this.instance.transform.scale; + const deltaY = (y - this.y) / this.instance.transform.scale; + const newX = this.instance.transform.x + deltaX; + const newY = this.instance.transform.y + deltaY; + + this.instance.transform.x = newX; + this.instance.transform.y = newY; + + this.instance.update(); + } +} diff --git a/packages/lib/src/renderer/lib/pinch.utils.ts b/packages/lib/src/renderer/lib/pinch.utils.ts new file mode 100644 index 0000000..4a8b0f1 --- /dev/null +++ b/packages/lib/src/renderer/lib/pinch.utils.ts @@ -0,0 +1,61 @@ +import { PanZoom } from "../PanZoom"; +import { checkZoomBounds } from "./zoom.utils"; + +export const getTouchDistance = (event: TouchEvent): number => { + return Math.sqrt( + (event.touches[0].pageX - event.touches[1].pageX) ** 2 + + (event.touches[0].pageY - event.touches[1].pageY) ** 2 + ); +}; + +export const roundNumber = (num: number, decimal: number) => { + return Number(num.toFixed(decimal)); +}; + +export const calculatePinchZoom = ( + contextInstance: PanZoom, + currentDistance: number +): number => { + const { touch, setup } = contextInstance; + const { scale } = setup; + // const { maxScale, minScale, zoomAnimation, disablePadding } = setup; + // const { size, disabled } = zoomAnimation; + + const [minScale, maxScale] = scale; + + if ( + !touch.pinchStartScale || + touch.pinchStartDistance === null || + !currentDistance + ) { + throw new Error("Pinch touches distance was not provided"); + } + + if (currentDistance < 0) { + return contextInstance.transform.scale; + } + + const touchProportion = currentDistance / touch.pinchStartDistance; + const scaleDifference = touchProportion * touch.pinchStartScale; + + return checkZoomBounds(roundNumber(scaleDifference, 2), minScale, maxScale); +}; + +export const calculateTouchMidPoint = ( + instance: PanZoom, + event: TouchEvent, + scale: number, + contentComponent: HTMLElement +): { x: number; y: number } => { + const contentRect = contentComponent.getBoundingClientRect(); + const { touches } = event; + const firstPointX = roundNumber(touches[0].clientX - contentRect.left, 5); + const firstPointY = roundNumber(touches[0].clientY - contentRect.top, 5); + const secondPointX = roundNumber(touches[1].clientX - contentRect.left, 5); + const secondPointY = roundNumber(touches[1].clientY - contentRect.top, 5); + + return { + x: (firstPointX + secondPointX) / 2, + y: (firstPointY + secondPointY) / 2, + }; +}; diff --git a/packages/lib/src/renderer/lib/zoom.utils.ts b/packages/lib/src/renderer/lib/zoom.utils.ts new file mode 100644 index 0000000..3d2752a --- /dev/null +++ b/packages/lib/src/renderer/lib/zoom.utils.ts @@ -0,0 +1,49 @@ +import { PanZoom } from "../PanZoom"; + +export function handleCalculateZoomPositions( + contextInstance: PanZoom, + mouseX: number, + mouseY: number, + newScale: number +): { x: number; y: number } { + const { scale, x, y } = contextInstance.transform; + + const scaleDifference = newScale - scale; + + if (typeof mouseX !== "number" || typeof mouseY !== "number") { + console.error("Mouse X and Y position were not provided!"); + return { x, y }; + } + + const calculatedPositionX = x - mouseX * scaleDifference; + const calculatedPositionY = y - mouseY * scaleDifference; + contextInstance.debug(calculatedPositionX, calculatedPositionY, "zoom"); + + // do not limit to bounds when there is padding animation, + // it causes animation strange behaviour + + // const newPositions = getMouseBoundedPosition( + // calculatedPositionX, + // calculatedPositionY, + // bounds, + // limitToBounds, + // 0, + // 0, + // null, + // ); + + return { + x: calculatedPositionX, + y: calculatedPositionY, + }; +} + +export function checkZoomBounds( + zoom: number, + minScale: number, + maxScale: number +): number { + if (!Number.isNaN(maxScale) && zoom >= maxScale) return maxScale; + if (!Number.isNaN(minScale) && zoom <= minScale) return minScale; + return zoom; +}