Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/components/CanvasArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
98 changes: 38 additions & 60 deletions src/hooks/usePinchZoom.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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<HTMLElement | null>,
enabled: boolean,
Expand All @@ -33,6 +35,8 @@ export function usePinchZoom(
onCommit: (viewport: Viewport) => void,
minScale: number,
maxScale: number,
onGestureStart?: () => void,
onGestureEnd?: () => void,
) {
const pointersRef = useRef(
new Map<number, { clientX: number; clientY: number }>(),
Expand All @@ -43,41 +47,20 @@ 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
const container = containerRef.current
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, {
Expand All @@ -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, {
Expand All @@ -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)
}
Expand All @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions src/utils/viewport.pinch.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
26 changes: 26 additions & 0 deletions src/utils/viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Loading