custom zoom pan pinch implementation

This commit is contained in:
Grant 2024-02-13 12:44:13 -07:00
parent 2e469e39a9
commit d2c9d6eed9
15 changed files with 669 additions and 42 deletions

10
package-lock.json generated
View File

@ -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",

View File

@ -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"));
})
);

View File

@ -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;
}

View File

@ -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 (
<main>
<TransformWrapper
centerOnInit
limitToBounds={false}
centerZoomedOut={false}
minScale={0.5}
maxScale={50}
wheel={{
step: 0.05,
smoothStep: 0.05,
}}
initialScale={5}
panning={{
velocityDisabled: true,
}}
doubleClick={{
disabled: true,
}}
>
<TransformComponent wrapperStyle={{ width: "100%", height: "100%" }}>
<PanZoomWrapper>
<CanvasInner />
</TransformComponent>
</TransformWrapper>
</PanZoomWrapper>
</main>
);
};
@ -42,28 +24,28 @@ export const CanvasWrapper = () => {
const CanvasInner = () => {
const canvasRef = createRef<HTMLCanvasElement>();
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 (
<canvas

View File

@ -1,5 +0,0 @@
import React from "react";
export const PanZoomWrapper = ({ children }: { children: React.ReactNode }) => {
return <div className="test-wrapper">{children}</div>;
};

View File

@ -116,3 +116,4 @@ main {
}
@import "./components/Pallete.scss";
@import "./board.scss";

View File

@ -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"
}
}

View File

@ -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<PanZoomEvents> {
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]
);
}
}

View File

@ -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<HTMLDivElement>(null);
const zoom = useRef<HTMLDivElement>(null);
const move = useRef<HTMLDivElement>(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 (
<RendererContext.Provider value={instance}>
<div
ref={wrapper}
className="board-wrapper"
style={{ touchAction: "none" }}
>
<div ref={zoom} className="board-zoom">
<div ref={move} className="board-move">
{children}
</div>
</div>
</div>
</RendererContext.Provider>
);
};

View File

@ -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

View File

@ -0,0 +1,4 @@
import { createContext } from "react";
import { PanZoom } from "./PanZoom";
export const RendererContext = createContext<PanZoom>(null as any);

View File

@ -0,0 +1 @@
export { PanZoomWrapper } from "./PanZoomWrapper";

View File

@ -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();
}
}

View File

@ -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,
};
};

View File

@ -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;
}