Skip to content

Commit 99c95b9

Browse files
authored
Merge pull request #7 from pythonicode/feat/responsive-layout
Fix pinch zoom drifting away from the fingers on touch screens
2 parents 6372ce1 + d527e75 commit 99c95b9

4 files changed

Lines changed: 140 additions & 61 deletions

File tree

‎src/components/CanvasArea.tsx‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,39 @@ export default function CanvasArea({
144144
activeTool === 'pan' ||
145145
(!isTouch && (spaceHeld || middleMouseHeld))
146146

147+
const isPanningRef = useRef(isPanning)
148+
isPanningRef.current = isPanning
149+
150+
// Pinch starts from what is on screen, which can differ from the stored viewport mid-drag
151+
const readStageViewport = useCallback((): Viewport => {
152+
const stage = stageRef.current
153+
const base = viewportRef.current
154+
if (!stage) return base
155+
return { scale: stage.scaleX(), x: stage.x(), y: stage.y(), rotation: base.rotation }
156+
}, [])
157+
158+
// A one-finger pan (Konva stage drag) must not run alongside a pinch
159+
const pinchStart = useCallback(() => {
160+
const stage = stageRef.current
161+
if (!stage) return
162+
stage.stopDrag()
163+
stage.draggable(false)
164+
}, [])
165+
166+
const pinchEnd = useCallback(() => {
167+
stageRef.current?.draggable(isPanningRef.current)
168+
}, [])
169+
147170
usePinchZoom(
148171
containerRef,
149172
isTouch,
150-
() => viewportRef.current,
173+
readStageViewport,
151174
previewZoom,
152175
commitViewport,
153176
MIN_SCALE,
154177
MAX_SCALE,
178+
pinchStart,
179+
pinchEnd,
155180
)
156181

157182
useEffect(() => {

‎src/hooks/usePinchZoom.ts‎

Lines changed: 38 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { useEffect, useRef, type RefObject } from 'react'
2-
import { contentGroupRef } from '../contentGroupRef'
3-
import { stageRef } from '../stageRef'
4-
import { useAppStore } from '../store'
5-
import { getMapPointer } from '../utils/mapPointer'
6-
import { mapToLayerLocal } from '../utils/viewport'
72
import type { Viewport } from '../types'
3+
import { pinchViewport } from '../utils/viewport'
84

95
const MIN_DISTANCE = 10
106

@@ -25,6 +21,12 @@ function getCenter(
2521
}
2622
}
2723

24+
/**
25+
* Two-finger pinch-zoom and pan on the canvas. The viewport is the stage's
26+
* transform: the point between the fingers stays under them while the scale
27+
* and position change. onGestureStart/onGestureEnd let the canvas stop any
28+
* one-finger drag that is still running, so the two do not fight over the stage.
29+
*/
2830
export function usePinchZoom(
2931
containerRef: RefObject<HTMLElement | null>,
3032
enabled: boolean,
@@ -33,6 +35,8 @@ export function usePinchZoom(
3335
onCommit: (viewport: Viewport) => void,
3436
minScale: number,
3537
maxScale: number,
38+
onGestureStart?: () => void,
39+
onGestureEnd?: () => void,
3640
) {
3741
const pointersRef = useRef(
3842
new Map<number, { clientX: number; clientY: number }>(),
@@ -43,41 +47,20 @@ export function usePinchZoom(
4347
const getViewportRef = useRef(getViewport)
4448
const onPreviewRef = useRef(onPreview)
4549
const onCommitRef = useRef(onCommit)
50+
const onGestureStartRef = useRef(onGestureStart)
51+
const onGestureEndRef = useRef(onGestureEnd)
4652
getViewportRef.current = getViewport
4753
onPreviewRef.current = onPreview
4854
onCommitRef.current = onCommit
55+
onGestureStartRef.current = onGestureStart
56+
onGestureEndRef.current = onGestureEnd
4957

5058
useEffect(() => {
5159
if (!enabled) return
5260
const container = containerRef.current
5361
if (!container) return
5462
const pointers = pointersRef.current
5563

56-
const getStagePoint = (clientX: number, clientY: number) => {
57-
const rect = container.getBoundingClientRect()
58-
return { x: clientX - rect.left, y: clientY - rect.top }
59-
}
60-
61-
const mapPointAtClient = (clientX: number, clientY: number) => {
62-
const stage = stageRef.current
63-
const vp = viewportRef.current
64-
const stagePoint = getStagePoint(clientX, clientY)
65-
66-
if (stage && contentGroupRef.current) {
67-
stage.setPointersPositions({ clientX, clientY })
68-
const mapPoint = getMapPointer(stage)
69-
if (mapPoint) return { mapPoint, stagePoint }
70-
}
71-
72-
return {
73-
mapPoint: {
74-
x: (stagePoint.x - vp.x) / vp.scale,
75-
y: (stagePoint.y - vp.y) / vp.scale,
76-
},
77-
stagePoint,
78-
}
79-
}
80-
8164
const onPointerDown = (e: PointerEvent) => {
8265
if (e.pointerType !== 'touch') return
8366
pointers.set(e.pointerId, {
@@ -88,11 +71,18 @@ export function usePinchZoom(
8871
const pts = [...pointers.values()]
8972
viewportRef.current = getViewportRef.current()
9073
lastPinchDistRef.current = getDistance(pts[0], pts[1])
91-
lastPanCenterRef.current = getCenter(pts[0], pts[1])
74+
lastPanCenterRef.current = toCanvas(getCenter(pts[0], pts[1]))
75+
onGestureStartRef.current?.()
9276
e.preventDefault()
9377
}
9478
}
9579

80+
/** Finger centre in canvas (container) coordinates, matching the viewport's x/y. */
81+
const toCanvas = (point: { x: number; y: number }) => {
82+
const rect = container.getBoundingClientRect()
83+
return { x: point.x - rect.left, y: point.y - rect.top }
84+
}
85+
9686
const onPointerMove = (e: PointerEvent) => {
9787
if (!pointers.has(e.pointerId)) return
9888
pointers.set(e.pointerId, {
@@ -105,38 +95,23 @@ export function usePinchZoom(
10595

10696
const pts = [...pointers.values()]
10797
const dist = getDistance(pts[0], pts[1])
108-
const center = getCenter(pts[0], pts[1])
98+
const center = toCanvas(getCenter(pts[0], pts[1]))
10999
const vp = viewportRef.current
110100

111-
if (lastPinchDistRef.current !== null && lastPinchDistRef.current > 0) {
112-
const scaleFactor = dist / lastPinchDistRef.current
113-
const oldScale = vp.scale
114-
const newScale = Math.min(
101+
const lastCenter = lastPanCenterRef.current
102+
if (
103+
lastPinchDistRef.current !== null &&
104+
lastPinchDistRef.current > 0 &&
105+
lastCenter
106+
) {
107+
const next = pinchViewport(
108+
vp,
109+
lastCenter,
110+
center,
111+
dist / lastPinchDistRef.current,
112+
minScale,
115113
maxScale,
116-
Math.max(minScale, oldScale * scaleFactor),
117114
)
118-
119-
const { mapPoint, stagePoint } = mapPointAtClient(center.x, center.y)
120-
const mapImage = useAppStore.getState().mapImage
121-
const mapCenter = mapImage
122-
? { x: mapImage.width / 2, y: mapImage.height / 2 }
123-
: { x: 0, y: 0 }
124-
const layerPoint = mapToLayerLocal(mapPoint, mapCenter, vp.rotation)
125-
126-
let nextX = stagePoint.x - layerPoint.x * newScale
127-
let nextY = stagePoint.y - layerPoint.y * newScale
128-
129-
if (lastPanCenterRef.current) {
130-
nextX += center.x - lastPanCenterRef.current.x
131-
nextY += center.y - lastPanCenterRef.current.y
132-
}
133-
134-
const next = {
135-
scale: newScale,
136-
x: nextX,
137-
y: nextY,
138-
rotation: vp.rotation,
139-
}
140115
viewportRef.current = next
141116
onPreviewRef.current(next)
142117
}
@@ -149,7 +124,10 @@ export function usePinchZoom(
149124
const wasPinching = lastPinchDistRef.current !== null
150125
pointers.delete(e.pointerId)
151126
if (pointers.size < 2) {
152-
if (wasPinching) onCommitRef.current(viewportRef.current)
127+
if (wasPinching) {
128+
onCommitRef.current(viewportRef.current)
129+
onGestureEndRef.current?.()
130+
}
153131
lastPinchDistRef.current = null
154132
lastPanCenterRef.current = null
155133
}

‎src/utils/viewport.pinch.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { pinchViewport } from './viewport'
3+
import type { Viewport } from '../types'
4+
5+
/** Where a stage point lands on screen under a viewport. */
6+
const toScreen = (vp: Viewport, p: { x: number; y: number }) => ({
7+
x: vp.x + p.x * vp.scale,
8+
y: vp.y + p.y * vp.scale,
9+
})
10+
11+
describe('pinchViewport', () => {
12+
it('keeps the point under the fingers while zooming in', () => {
13+
const vp: Viewport = { scale: 0.5, x: 20, y: -10, rotation: 0 }
14+
const centre = { x: 200, y: 300 }
15+
const anchor = { x: (centre.x - vp.x) / vp.scale, y: (centre.y - vp.y) / vp.scale }
16+
17+
const next = pinchViewport(vp, centre, centre, 1.5, 0.05, 8)
18+
19+
expect(next.scale).toBeCloseTo(0.75)
20+
const landed = toScreen(next, anchor)
21+
expect(landed.x).toBeCloseTo(centre.x)
22+
expect(landed.y).toBeCloseTo(centre.y)
23+
})
24+
25+
it('moves the content with the fingers when they pan during a pinch', () => {
26+
const vp: Viewport = { scale: 1, x: 0, y: 0, rotation: 0 }
27+
const before = { x: 100, y: 100 }
28+
const anchor = { x: before.x / vp.scale, y: before.y / vp.scale }
29+
const after = { x: 130, y: 90 }
30+
31+
const next = pinchViewport(vp, before, after, 1, 0.05, 8)
32+
33+
const landed = toScreen(next, anchor)
34+
expect(landed.x).toBeCloseTo(after.x)
35+
expect(landed.y).toBeCloseTo(after.y)
36+
})
37+
38+
it('clamps the scale to the allowed range', () => {
39+
const vp: Viewport = { scale: 7, x: 0, y: 0, rotation: 0 }
40+
expect(pinchViewport(vp, { x: 0, y: 0 }, { x: 0, y: 0 }, 2, 0.05, 8).scale).toBe(8)
41+
expect(
42+
pinchViewport({ ...vp, scale: 0.06 }, { x: 0, y: 0 }, { x: 0, y: 0 }, 0.5, 0.05, 8).scale,
43+
).toBe(0.05)
44+
})
45+
46+
it('leaves rotation unchanged', () => {
47+
const vp: Viewport = { scale: 1, x: 0, y: 0, rotation: 37 }
48+
expect(pinchViewport(vp, { x: 0, y: 0 }, { x: 5, y: 5 }, 1.2, 0.05, 8).rotation).toBe(37)
49+
})
50+
})

‎src/utils/viewport.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,29 @@ export const DEFAULT_VIEWPORT: Viewport = {
130130
export function canUseShadow(mapSize: number, viewportScale: number): boolean {
131131
return mapSize * viewportScale >= MIN_SHADOW_SCREEN_PX
132132
}
133+
134+
/**
135+
* Viewport after one pinch step. The stage point that was under the previous
136+
* finger centre stays under the new centre, so zooming and panning happen
137+
* together. Rotation is part of the stage content, so it is unchanged here.
138+
*/
139+
export function pinchViewport(
140+
viewport: Viewport,
141+
previousCenter: Point,
142+
center: Point,
143+
scaleFactor: number,
144+
minScale: number,
145+
maxScale: number,
146+
): Viewport {
147+
const scale = Math.min(maxScale, Math.max(minScale, viewport.scale * scaleFactor))
148+
const anchor = {
149+
x: (previousCenter.x - viewport.x) / viewport.scale,
150+
y: (previousCenter.y - viewport.y) / viewport.scale,
151+
}
152+
return {
153+
scale,
154+
x: center.x - anchor.x * scale,
155+
y: center.y - anchor.y * scale,
156+
rotation: viewport.rotation,
157+
}
158+
}

0 commit comments

Comments
 (0)