From 05e7e17c8805bad46c3c58195ec9c2abeab99957 Mon Sep 17 00:00:00 2001 From: Anthony Riley Date: Wed, 14 Oct 2026 11:16:26 +0200 Subject: [PATCH] fix: keep the point under the fingers during pinch zoom Two things made the zoom point drift away from the fingers on touch screens: - A one-finger pan (Konva stage drag, on the Pan tool) could still be running when a second finger landed. The drag and the pinch then both moved the stage. The stage drag now stops when a pinch starts and is restored after. - The pinch anchor added the finger movement a second time, and the fallback path mixed layer and map coordinates for rotated maps. The step is now a single pure function (pinchViewport) that keeps the stage point under the previous centre under the new centre, starting from what is on screen. Co-Authored-By: Claude Haiku 5.5 --- src/components/CanvasArea.tsx | 27 ++++++++- src/hooks/usePinchZoom.ts | 98 +++++++++++++------------------- src/utils/viewport.pinch.test.ts | 50 ++++++++++++++++ src/utils/viewport.ts | 26 +++++++++ 4 files changed, 140 insertions(+), 61 deletions(-) create mode 100644 src/utils/viewport.pinch.test.ts diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index 9fedb9a..f323512 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -144,14 +144,39 @@ export default function CanvasArea({ activeTool === 'pan' || (!isTouch && (spaceHeld || middleMouseHeld)) + const isPanningRef = useRef(isPanning) + isPanningRef.current = isPanning + + // Pinch starts from what is on screen, which can differ from the stored viewport mid-drag + const readStageViewport = useCallback((): Viewport => { + const stage = stageRef.current + const base = viewportRef.current + if (!stage) return base + return { scale: stage.scaleX(), x: stage.x(), y: stage.y(), rotation: base.rotation } + }, []) + + // A one-finger pan (Konva stage drag) must not run alongside a pinch + const pinchStart = useCallback(() => { + const stage = stageRef.current + if (!stage) return + stage.stopDrag() + stage.draggable(false) + }, []) + + const pinchEnd = useCallback(() => { + stageRef.current?.draggable(isPanningRef.current) + }, []) + usePinchZoom( containerRef, isTouch, - () => viewportRef.current, + readStageViewport, previewZoom, commitViewport, MIN_SCALE, MAX_SCALE, + pinchStart, + pinchEnd, ) useEffect(() => { diff --git a/src/hooks/usePinchZoom.ts b/src/hooks/usePinchZoom.ts index ffa6f6b..9455cd1 100644 --- a/src/hooks/usePinchZoom.ts +++ b/src/hooks/usePinchZoom.ts @@ -1,10 +1,6 @@ import { useEffect, useRef, type RefObject } from 'react' -import { contentGroupRef } from '../contentGroupRef' -import { stageRef } from '../stageRef' -import { useAppStore } from '../store' -import { getMapPointer } from '../utils/mapPointer' -import { mapToLayerLocal } from '../utils/viewport' import type { Viewport } from '../types' +import { pinchViewport } from '../utils/viewport' const MIN_DISTANCE = 10 @@ -25,6 +21,12 @@ function getCenter( } } +/** + * Two-finger pinch-zoom and pan on the canvas. The viewport is the stage's + * transform: the point between the fingers stays under them while the scale + * and position change. onGestureStart/onGestureEnd let the canvas stop any + * one-finger drag that is still running, so the two do not fight over the stage. + */ export function usePinchZoom( containerRef: RefObject, enabled: boolean, @@ -33,6 +35,8 @@ export function usePinchZoom( onCommit: (viewport: Viewport) => void, minScale: number, maxScale: number, + onGestureStart?: () => void, + onGestureEnd?: () => void, ) { const pointersRef = useRef( new Map(), @@ -43,9 +47,13 @@ export function usePinchZoom( const getViewportRef = useRef(getViewport) const onPreviewRef = useRef(onPreview) const onCommitRef = useRef(onCommit) + const onGestureStartRef = useRef(onGestureStart) + const onGestureEndRef = useRef(onGestureEnd) getViewportRef.current = getViewport onPreviewRef.current = onPreview onCommitRef.current = onCommit + onGestureStartRef.current = onGestureStart + onGestureEndRef.current = onGestureEnd useEffect(() => { if (!enabled) return @@ -53,31 +61,6 @@ export function usePinchZoom( if (!container) return const pointers = pointersRef.current - const getStagePoint = (clientX: number, clientY: number) => { - const rect = container.getBoundingClientRect() - return { x: clientX - rect.left, y: clientY - rect.top } - } - - const mapPointAtClient = (clientX: number, clientY: number) => { - const stage = stageRef.current - const vp = viewportRef.current - const stagePoint = getStagePoint(clientX, clientY) - - if (stage && contentGroupRef.current) { - stage.setPointersPositions({ clientX, clientY }) - const mapPoint = getMapPointer(stage) - if (mapPoint) return { mapPoint, stagePoint } - } - - return { - mapPoint: { - x: (stagePoint.x - vp.x) / vp.scale, - y: (stagePoint.y - vp.y) / vp.scale, - }, - stagePoint, - } - } - const onPointerDown = (e: PointerEvent) => { if (e.pointerType !== 'touch') return pointers.set(e.pointerId, { @@ -88,11 +71,18 @@ export function usePinchZoom( const pts = [...pointers.values()] viewportRef.current = getViewportRef.current() lastPinchDistRef.current = getDistance(pts[0], pts[1]) - lastPanCenterRef.current = getCenter(pts[0], pts[1]) + lastPanCenterRef.current = toCanvas(getCenter(pts[0], pts[1])) + onGestureStartRef.current?.() e.preventDefault() } } + /** Finger centre in canvas (container) coordinates, matching the viewport's x/y. */ + const toCanvas = (point: { x: number; y: number }) => { + const rect = container.getBoundingClientRect() + return { x: point.x - rect.left, y: point.y - rect.top } + } + const onPointerMove = (e: PointerEvent) => { if (!pointers.has(e.pointerId)) return pointers.set(e.pointerId, { @@ -105,38 +95,23 @@ export function usePinchZoom( const pts = [...pointers.values()] const dist = getDistance(pts[0], pts[1]) - const center = getCenter(pts[0], pts[1]) + const center = toCanvas(getCenter(pts[0], pts[1])) const vp = viewportRef.current - if (lastPinchDistRef.current !== null && lastPinchDistRef.current > 0) { - const scaleFactor = dist / lastPinchDistRef.current - const oldScale = vp.scale - const newScale = Math.min( + const lastCenter = lastPanCenterRef.current + if ( + lastPinchDistRef.current !== null && + lastPinchDistRef.current > 0 && + lastCenter + ) { + const next = pinchViewport( + vp, + lastCenter, + center, + dist / lastPinchDistRef.current, + minScale, maxScale, - Math.max(minScale, oldScale * scaleFactor), ) - - const { mapPoint, stagePoint } = mapPointAtClient(center.x, center.y) - const mapImage = useAppStore.getState().mapImage - const mapCenter = mapImage - ? { x: mapImage.width / 2, y: mapImage.height / 2 } - : { x: 0, y: 0 } - const layerPoint = mapToLayerLocal(mapPoint, mapCenter, vp.rotation) - - let nextX = stagePoint.x - layerPoint.x * newScale - let nextY = stagePoint.y - layerPoint.y * newScale - - if (lastPanCenterRef.current) { - nextX += center.x - lastPanCenterRef.current.x - nextY += center.y - lastPanCenterRef.current.y - } - - const next = { - scale: newScale, - x: nextX, - y: nextY, - rotation: vp.rotation, - } viewportRef.current = next onPreviewRef.current(next) } @@ -149,7 +124,10 @@ export function usePinchZoom( const wasPinching = lastPinchDistRef.current !== null pointers.delete(e.pointerId) if (pointers.size < 2) { - if (wasPinching) onCommitRef.current(viewportRef.current) + if (wasPinching) { + onCommitRef.current(viewportRef.current) + onGestureEndRef.current?.() + } lastPinchDistRef.current = null lastPanCenterRef.current = null } diff --git a/src/utils/viewport.pinch.test.ts b/src/utils/viewport.pinch.test.ts new file mode 100644 index 0000000..f30e0aa --- /dev/null +++ b/src/utils/viewport.pinch.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { pinchViewport } from './viewport' +import type { Viewport } from '../types' + +/** Where a stage point lands on screen under a viewport. */ +const toScreen = (vp: Viewport, p: { x: number; y: number }) => ({ + x: vp.x + p.x * vp.scale, + y: vp.y + p.y * vp.scale, +}) + +describe('pinchViewport', () => { + it('keeps the point under the fingers while zooming in', () => { + const vp: Viewport = { scale: 0.5, x: 20, y: -10, rotation: 0 } + const centre = { x: 200, y: 300 } + const anchor = { x: (centre.x - vp.x) / vp.scale, y: (centre.y - vp.y) / vp.scale } + + const next = pinchViewport(vp, centre, centre, 1.5, 0.05, 8) + + expect(next.scale).toBeCloseTo(0.75) + const landed = toScreen(next, anchor) + expect(landed.x).toBeCloseTo(centre.x) + expect(landed.y).toBeCloseTo(centre.y) + }) + + it('moves the content with the fingers when they pan during a pinch', () => { + const vp: Viewport = { scale: 1, x: 0, y: 0, rotation: 0 } + const before = { x: 100, y: 100 } + const anchor = { x: before.x / vp.scale, y: before.y / vp.scale } + const after = { x: 130, y: 90 } + + const next = pinchViewport(vp, before, after, 1, 0.05, 8) + + const landed = toScreen(next, anchor) + expect(landed.x).toBeCloseTo(after.x) + expect(landed.y).toBeCloseTo(after.y) + }) + + it('clamps the scale to the allowed range', () => { + const vp: Viewport = { scale: 7, x: 0, y: 0, rotation: 0 } + expect(pinchViewport(vp, { x: 0, y: 0 }, { x: 0, y: 0 }, 2, 0.05, 8).scale).toBe(8) + expect( + pinchViewport({ ...vp, scale: 0.06 }, { x: 0, y: 0 }, { x: 0, y: 0 }, 0.5, 0.05, 8).scale, + ).toBe(0.05) + }) + + it('leaves rotation unchanged', () => { + const vp: Viewport = { scale: 1, x: 0, y: 0, rotation: 37 } + expect(pinchViewport(vp, { x: 0, y: 0 }, { x: 5, y: 5 }, 1.2, 0.05, 8).rotation).toBe(37) + }) +}) diff --git a/src/utils/viewport.ts b/src/utils/viewport.ts index 7d79d51..d455c34 100644 --- a/src/utils/viewport.ts +++ b/src/utils/viewport.ts @@ -130,3 +130,29 @@ export const DEFAULT_VIEWPORT: Viewport = { export function canUseShadow(mapSize: number, viewportScale: number): boolean { return mapSize * viewportScale >= MIN_SHADOW_SCREEN_PX } + +/** + * Viewport after one pinch step. The stage point that was under the previous + * finger centre stays under the new centre, so zooming and panning happen + * together. Rotation is part of the stage content, so it is unchanged here. + */ +export function pinchViewport( + viewport: Viewport, + previousCenter: Point, + center: Point, + scaleFactor: number, + minScale: number, + maxScale: number, +): Viewport { + const scale = Math.min(maxScale, Math.max(minScale, viewport.scale * scaleFactor)) + const anchor = { + x: (previousCenter.x - viewport.x) / viewport.scale, + y: (previousCenter.y - viewport.y) / viewport.scale, + } + return { + scale, + x: center.x - anchor.x * scale, + y: center.y - anchor.y * scale, + rotation: viewport.rotation, + } +}