Skip to content

Commit 5884580

Browse files
fix(editor): keep focal point fixed when clamping zoom to min/max (tldraw#8957)
Thinks this works better than preserving the center, wdyt @steveruizok? In order to stop the viewport from shifting when a zoom gesture overshoots the zoom limits, this PR makes `getConstrainedCamera` preserve the caller's focal point (e.g. the cursor) when it clamps the zoom, instead of snapping back to the viewport center. Closes tldraw#8942. When you wheel/pinch zoom out past the minimum (or in past the maximum), the gesture computes a camera that keeps the point under the cursor fixed at the *requested* zoom. The old code then clamped the zoom and re-derived `x`/`y` to keep the viewport *center* fixed, discarding the cursor anchor — so the canvas visibly jumped. Now the clamped camera keeps the same focal point fixed. When that focal point happens to be the viewport center (e.g. the zoom-out button), the math reduces to the previous center-preserving behaviour, so nothing else changes. `zoomIn`/`zoomOut` are unaffected because they snap exactly onto zoom steps and never overshoot. ### Before https://github.com/user-attachments/assets/0cc13c03-2ea5-4642-bcf4-2c5f62e57e4d ### After https://github.com/user-attachments/assets/b800a301-b101-4e98-8303-3e5801b7bbeb ### Change type - [x] `bugfix` ### Test plan 1. Open the editor at any zoom level with the pointer away from the screen center. 2. Wheel- or pinch-zoom out until the zoom clamps at the minimum. 3. The point under the cursor should stay put as the zoom hits the limit; further zoom-out gestures should not move the canvas. 4. Repeat zooming in past the maximum. - [x] Unit tests ### Release notes - Fix the viewport shifting slightly when zooming past the minimum or maximum zoom level. ### Code changes | Section | LOC change | | --------- | ---------- | | Core code | +20 / -14 | | Tests | +97 / -1 |
1 parent 5974f76 commit 5884580

2 files changed

Lines changed: 117 additions & 15 deletions

File tree

packages/editor/src/lib/editor/Editor.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3336,6 +3336,15 @@ export class Editor extends EventEmitter<TLEventMap> {
33363336

33373337
let { x, y, z = currentCamera.z } = point
33383338

3339+
// `requested` kept the caller's focal point (e.g. the cursor) fixed at
3340+
// zoom `rz`. When `rz` gets clamped, keep that same focal point fixed at
3341+
// the clamped zoom `z` rather than snapping to the viewport center.
3342+
const preserveFocalPoint = (current: number, requested: number, rz: number, z: number) => {
3343+
const cz = currentCamera.z
3344+
if (rz === cz) return current
3345+
return current + ((requested - current) * (1 / z - 1 / cz)) / (1 / rz - 1 / cz)
3346+
}
3347+
33393348
// If force is true, then we'll set the camera to the point regardless of
33403349
// the camera options, so that we can handle gestures that permit elasticity
33413350
// or decay, or animations that occur while the camera is locked.
@@ -3378,17 +3387,14 @@ export class Editor extends EventEmitter<TLEventMap> {
33783387
}
33793388

33803389
if (z < minZ || z > maxZ) {
3381-
// We're trying to zoom out past the minimum zoom level,
3382-
// or in past the maximum zoom level, so stop the camera
3383-
// but keep the current center
3384-
const { x: cx, y: cy, z: cz } = currentCamera
3385-
const cxA = -cx + vsb.w / cz / 2
3386-
const cyA = -cy + vsb.h / cz / 2
3390+
// We're trying to zoom out past the minimum zoom level, or in
3391+
// past the maximum zoom level, so clamp the zoom while keeping
3392+
// the caller's focal point fixed. Axis constraints below still
3393+
// apply on top of this.
3394+
const rz = z
33873395
z = clamp(z, minZ, maxZ)
3388-
const cxB = -cx + vsb.w / z / 2
3389-
const cyB = -cy + vsb.h / z / 2
3390-
x = cx + cxB - cxA
3391-
y = cy + cyB - cyA
3396+
x = preserveFocalPoint(currentCamera.x, x, rz, z)
3397+
y = preserveFocalPoint(currentCamera.y, y, rz, z)
33923398
}
33933399

33943400
// Calculate available space
@@ -3477,12 +3483,12 @@ export class Editor extends EventEmitter<TLEventMap> {
34773483
}
34783484
}
34793485
} else {
3480-
// constrain the zoom, preserving the center
3486+
// constrain the zoom, keeping the caller's focal point fixed
34813487
if (z > zoomMax || z < zoomMin) {
3482-
const { x: cx, y: cy, z: cz } = currentCamera
3488+
const rz = z
34833489
z = clamp(z, zoomMin, zoomMax)
3484-
x = cx + (-cx + vsb.w / z / 2) - (-cx + vsb.w / cz / 2)
3485-
y = cy + (-cy + vsb.h / z / 2) - (-cy + vsb.h / cz / 2)
3490+
x = preserveFocalPoint(currentCamera.x, x, rz, z)
3491+
y = preserveFocalPoint(currentCamera.y, y, rz, z)
34863492
}
34873493
}
34883494
}

packages/tldraw/src/test/commands/setCamera.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, DEFAULT_CAMERA_OPTIONS, Vec, createShapeId } from '@tldraw/editor'
1+
import { Box, DEFAULT_CAMERA_OPTIONS, Vec, createShapeId, last } from '@tldraw/editor'
22
import { vi } from 'vitest'
33
import { TestEditor } from '../TestEditor'
44

@@ -141,6 +141,102 @@ describe('With default options', () => {
141141
})
142142
})
143143

144+
describe('Zoom clamping preserves the focal point', () => {
145+
beforeEach(() => {
146+
editor.setCameraOptions({ ...DEFAULT_CAMERA_OPTIONS })
147+
})
148+
149+
// An off-center screen point, so a center-preserving clamp would shift it.
150+
const cursor = { x: 200, y: 150 }
151+
152+
it('keeps the point under the cursor fixed when wheel zooming out past the min', () => {
153+
editor.setCamera({ x: 0, y: 0, z: 0.06 }) // just above the 0.05 min
154+
editor.pointerMove(cursor.x, cursor.y)
155+
const before = editor.screenToPage(cursor)
156+
157+
// Each event halves the zoom, overshooting the min, then pins at it.
158+
for (let i = 0; i < 4; i++) {
159+
editor
160+
.dispatch({
161+
...wheelEvent,
162+
point: new Vec(cursor.x, cursor.y),
163+
delta: new Vec(0, 0, -0.5),
164+
ctrlKey: true,
165+
})
166+
.forceTick()
167+
}
168+
169+
expect(editor.getZoomLevel()).toBe(DEFAULT_CAMERA_OPTIONS.zoomSteps[0])
170+
const after = editor.screenToPage(cursor)
171+
expect(after.x).toBeCloseTo(before.x, 4)
172+
expect(after.y).toBeCloseTo(before.y, 4)
173+
})
174+
175+
it('keeps the point under the cursor fixed when wheel zooming in past the max', () => {
176+
const max = last(DEFAULT_CAMERA_OPTIONS.zoomSteps)!
177+
editor.setCamera({ x: 0, y: 0, z: max * 0.9 }) // just below the max
178+
editor.pointerMove(cursor.x, cursor.y)
179+
const before = editor.screenToPage(cursor)
180+
181+
for (let i = 0; i < 4; i++) {
182+
editor
183+
.dispatch({
184+
...wheelEvent,
185+
point: new Vec(cursor.x, cursor.y),
186+
delta: new Vec(0, 0, 0.5),
187+
ctrlKey: true,
188+
})
189+
.forceTick()
190+
}
191+
192+
expect(editor.getZoomLevel()).toBe(max)
193+
const after = editor.screenToPage(cursor)
194+
expect(after.x).toBeCloseTo(before.x, 4)
195+
expect(after.y).toBeCloseTo(before.y, 4)
196+
})
197+
198+
it('does not translate the camera once zoom is pinned at the min', () => {
199+
const min = DEFAULT_CAMERA_OPTIONS.zoomSteps[0]
200+
editor.setCamera({ x: 0, y: 0, z: min })
201+
editor.pointerMove(cursor.x, cursor.y)
202+
const camera = { ...editor.getCamera() }
203+
204+
for (let i = 0; i < 3; i++) {
205+
editor
206+
.dispatch({
207+
...wheelEvent,
208+
point: new Vec(cursor.x, cursor.y),
209+
delta: new Vec(0, 0, -0.5),
210+
ctrlKey: true,
211+
})
212+
.forceTick()
213+
}
214+
215+
expect(editor.getCamera()).toMatchObject({ x: camera.x, y: camera.y, z: min })
216+
})
217+
218+
it('keeps the focal point fixed when setCamera requests an out-of-range zoom', () => {
219+
// Mimic a cursor-anchored zoom request that overshoots the min: choose
220+
// x/y so screen point (200, 150) stays fixed at the requested zoom.
221+
const px = cursor.x
222+
const py = cursor.y
223+
editor.setCamera({ x: 0, y: 0, z: 0.06 })
224+
const { x: cx, y: cy, z: cz } = editor.getCamera()
225+
const before = editor.screenToPage(cursor)
226+
227+
const requestedZoom = 0.02 // below the 0.05 min
228+
editor.setCamera(
229+
new Vec(cx + px / requestedZoom - px / cz, cy + py / requestedZoom - py / cz, requestedZoom),
230+
{ immediate: true }
231+
)
232+
233+
expect(editor.getZoomLevel()).toBe(DEFAULT_CAMERA_OPTIONS.zoomSteps[0])
234+
const after = editor.screenToPage(cursor)
235+
expect(after.x).toBeCloseTo(before.x, 4)
236+
expect(after.y).toBeCloseTo(before.y, 4)
237+
})
238+
})
239+
144240
it('Sets the camera options', () => {
145241
const optionsA = { ...DEFAULT_CAMERA_OPTIONS, panSpeed: 2 }
146242
editor.setCameraOptions(optionsA)

0 commit comments

Comments
 (0)