Fix lag while panning + translating at the same time (#3186)

Before

![Kapture 2024-03-18 at 09 42
33](https://github.com/tldraw/tldraw/assets/1242537/d27c5852-9514-4e44-8b75-d2cb2571362a)


After

![Kapture 2024-03-18 at 09 41
27](https://github.com/tldraw/tldraw/assets/1242537/f5cbebfd-a45c-48d9-915b-18823f4555ff)

The probelm was manifesting because our camera updates were not
throttled and our render tick was on a different tick timeline to our
tick manager. Fixing the latter gets rid of the lag without requiring us
to throttle the camera updates.

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix



### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
pull/3166/head^2
David Sheldrick 2024-03-18 16:03:44 +00:00 zatwierdzone przez GitHub
rodzic 9f90fa230b
commit 1951fc0e47
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
6 zmienionych plików z 190 dodań i 29 usunięć

Wyświetl plik

@ -207,6 +207,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this.getContainer = getContainer ?? (() => document.body) this.getContainer = getContainer ?? (() => document.body)
this.textMeasure = new TextManager(this) this.textMeasure = new TextManager(this)
this._tickManager = new TickManager(this)
class NewRoot extends RootState { class NewRoot extends RootState {
static override initial = initialState ?? '' static override initial = initialState ?? ''
@ -666,7 +667,7 @@ export class Editor extends EventEmitter<TLEventMap> {
readonly disposables = new Set<() => void>() readonly disposables = new Set<() => void>()
/** @internal */ /** @internal */
private _tickManager = new TickManager(this) private readonly _tickManager
/** /**
* A manager for the app's snapping feature. * A manager for the app's snapping feature.

Wyświetl plik

@ -1,21 +1,31 @@
import { throttleToNextFrame as _throttleToNextFrame } from '@tldraw/utils'
import { Vec } from '../../primitives/Vec' import { Vec } from '../../primitives/Vec'
import { Editor } from '../Editor' import { Editor } from '../Editor'
const throttleToNextFrame =
typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
? // At test time we should use actual raf and not throttle, because throttle was set up to evaluate immediately during tests, which causes stack overflow
// for the tick manager since it sets up a raf loop.
function mockThrottle(cb: any) {
const frame = requestAnimationFrame(cb)
return () => cancelAnimationFrame(frame)
}
: _throttleToNextFrame
export class TickManager { export class TickManager {
constructor(public editor: Editor) { constructor(public editor: Editor) {
this.editor.disposables.add(this.dispose) this.editor.disposables.add(this.dispose)
this.start() this.start()
} }
raf: any cancelRaf?: null | (() => void)
isPaused = true isPaused = true
last = 0 last = 0
t = 0
start = () => { start = () => {
this.isPaused = false this.isPaused = false
cancelAnimationFrame(this.raf) this.cancelRaf?.()
this.raf = requestAnimationFrame(this.tick) this.cancelRaf = throttleToNextFrame(this.tick)
this.last = Date.now() this.last = Date.now()
} }
@ -27,25 +37,18 @@ export class TickManager {
const now = Date.now() const now = Date.now()
const elapsed = now - this.last const elapsed = now - this.last
this.last = now this.last = now
this.t += elapsed
this.editor.emit('frame', elapsed)
if (this.t < 16) {
this.raf = requestAnimationFrame(this.tick)
return
}
this.t -= 16
this.updatePointerVelocity(elapsed) this.updatePointerVelocity(elapsed)
this.editor.emit('frame', elapsed)
this.editor.emit('tick', elapsed) this.editor.emit('tick', elapsed)
this.raf = requestAnimationFrame(this.tick) this.cancelRaf = throttleToNextFrame(this.tick)
} }
// Clear the listener // Clear the listener
dispose = () => { dispose = () => {
this.isPaused = true this.isPaused = true
cancelAnimationFrame(this.raf)
this.cancelRaf?.()
} }
private prevPoint = new Vec() private prevPoint = new Vec()

Wyświetl plik

@ -3,6 +3,7 @@ import {
BoxModel, BoxModel,
Editor, Editor,
HALF_PI, HALF_PI,
IdOf,
Mat, Mat,
PageRecordType, PageRecordType,
ROTATE_CORNER_TO_SELECTION_CORNER, ROTATE_CORNER_TO_SELECTION_CORNER,
@ -205,6 +206,8 @@ export class TestEditor extends Editor {
y: +camera.y.toFixed(2), y: +camera.y.toFixed(2),
z: +camera.z.toFixed(2), z: +camera.z.toFixed(2),
}).toCloselyMatchObject({ x, y, z }) }).toCloselyMatchObject({ x, y, z })
return this
} }
expectShapeToMatch = <T extends TLShape = TLShape>( expectShapeToMatch = <T extends TLShape = TLShape>(
@ -218,6 +221,25 @@ export class TestEditor extends Editor {
return this return this
} }
expectPageBoundsToBe = <T extends TLShape = TLShape>(id: IdOf<T>, bounds: Partial<BoxModel>) => {
const observedBounds = this.getShapePageBounds(id)!
expect(observedBounds).toCloselyMatchObject(bounds)
return this
}
expectScreenBoundsToBe = <T extends TLShape = TLShape>(
id: IdOf<T>,
bounds: Partial<BoxModel>
) => {
const pageBounds = this.getShapePageBounds(id)!
const screenPoint = this.pageToScreen(pageBounds.point)
const observedBounds = pageBounds.clone()
observedBounds.x = screenPoint.x
observedBounds.y = screenPoint.y
expect(observedBounds).toCloselyMatchObject(bounds)
return this
}
/* --------------------- Inputs --------------------- */ /* --------------------- Inputs --------------------- */
protected getInfo = <T extends TLEventInfo>(info: string | T): T => { protected getInfo = <T extends TLEventInfo>(info: string | T): T => {
@ -294,8 +316,9 @@ export class TestEditor extends Editor {
You can use this helper to force the tick, which will then process all the updates. You can use this helper to force the tick, which will then process all the updates.
*/ */
forceTick = (count = 1) => { forceTick = (count = 1) => {
const tick = (this as any)._tickManager as { tick(): void }
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
this.emit('tick', 16) tick.tick()
} }
return this return this
} }

Wyświetl plik

@ -1876,3 +1876,129 @@ it('clones a single shape simply', () => {
expect(editor.getEditingShape()).toBe(undefined) expect(editor.getEditingShape()).toBe(undefined)
expect(editor.getHoveredShape()).toBe(sticky2) expect(editor.getHoveredShape()).toBe(sticky2)
}) })
describe('Moving the camera while panning', () => {
it('moves things while dragging', () => {
editor.createShape({
type: 'geo',
id: ids.box1,
x: 0,
y: 0,
props: { geo: 'rectangle', w: 100, h: 100, fill: 'solid' },
})
editor
.expectShapeToMatch({ id: ids.box1, x: 0, y: 0 })
.expectToBeIn('select.idle')
.pointerMove(40, 40)
.pointerDown()
.expectToBeIn('select.pointing_shape')
.pointerMove(50, 50) // move by 10,10
.expectToBeIn('select.translating')
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 })
.wheel(-10, -10) // wheel by -10,-10
.forceTick() // needed
.expectShapeToMatch({ id: ids.box1, x: 20, y: 20 })
.wheel(-10, -10) // wheel by -10,-10
.forceTick() // needed
.expectShapeToMatch({ id: ids.box1, x: 30, y: 30 })
})
it('FAILING EXAMPLE: preserves screen point while dragging', () => {
editor.createShape({
type: 'geo',
id: ids.box1,
x: 0,
y: 0,
props: { geo: 'rectangle', w: 100, h: 100, fill: 'solid' },
})
editor
.expectCameraToBe(0, 0, 1)
.expectShapeToMatch({ id: ids.box1, x: 0, y: 0 })
.expectPageBoundsToBe(ids.box1, { x: 0, y: 0 })
.expectScreenBoundsToBe(ids.box1, { x: 0, y: 0 })
.expectToBeIn('select.idle')
.pointerMove(40, 40)
.pointerDown()
.expectToBeIn('select.pointing_shape')
.pointerMove(50, 50) // move by 10,10
.expectToBeIn('select.translating')
// we haven't moved the camera from origin yet, so the
// point / page / screen points should all be identical
.expectCameraToBe(0, 0, 1)
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 })
.expectPageBoundsToBe(ids.box1, { x: 10, y: 10 })
.expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 })
// now we move the camera by -10,-10
// since we're dragging, they should still all move together
.wheel(-10, -10)
// ! This is the problem here—the screen point has changed
// ! because the camera moved but the resulting pointer move
// ! isn't processed until after the tick
.expectCameraToBe(-10, -10, 1)
.expectScreenBoundsToBe(ids.box1, { x: 0, y: 0 }) // should be 10,10
// nothing else has changed yet... until the tick
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 })
.expectPageBoundsToBe(ids.box1, { x: 10, y: 10 })
.forceTick() // needed
// The camera is still the same...
.expectCameraToBe(-10, -10, 1)
// But we've processed a pointer move, which has changed the shapes
.expectShapeToMatch({ id: ids.box1, x: 20, y: 20 })
.expectPageBoundsToBe(ids.box1, { x: 20, y: 20 })
// ! And this has fixed the screen point
.expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 })
})
it('Correctly preserves screen point while dragging', async () => {
editor.createShape({
type: 'geo',
id: ids.box1,
x: 0,
y: 0,
props: { geo: 'rectangle', w: 100, h: 100, fill: 'solid' },
})
editor
.expectCameraToBe(0, 0, 1)
.expectShapeToMatch({ id: ids.box1, x: 0, y: 0 })
.expectPageBoundsToBe(ids.box1, { x: 0, y: 0 })
.expectScreenBoundsToBe(ids.box1, { x: 0, y: 0 })
.expectToBeIn('select.idle')
.pointerMove(40, 40)
.pointerDown()
.expectToBeIn('select.pointing_shape')
.pointerMove(50, 50) // move by 10,10
.expectToBeIn('select.translating')
// we haven't moved the camera from origin yet, so the
// point / page / screen points should all be identical
.expectCameraToBe(0, 0, 1)
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 })
.expectPageBoundsToBe(ids.box1, { x: 10, y: 10 })
.expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 })
// now we move the camera by -10,-10
// since we're dragging, they should still all move together
.wheel(-10, -10)
.forceTick()
// wait for a tick to allow the tick manager to dispatch to the translating tool
// The camera has moved
.expectCameraToBe(-10, -10, 1)
.expectShapeToMatch({ id: ids.box1, x: 20, y: 20 })
.expectPageBoundsToBe(ids.box1, { x: 20, y: 20 })
// Screen bounds / point is still the same as it was before
.expectScreenBoundsToBe(ids.box1, { x: 10, y: 10 })
})
})

Wyświetl plik

@ -315,7 +315,7 @@ export { structuredClone_2 as structuredClone }
export function throttle<T extends (...args: any) => any>(func: T, limit: number): (...args: Parameters<T>) => ReturnType<T>; export function throttle<T extends (...args: any) => any>(func: T, limit: number): (...args: Parameters<T>) => ReturnType<T>;
// @internal // @internal
export function throttleToNextFrame(fn: () => void): void; export function throttleToNextFrame(fn: () => void): () => void;
// @internal (undocumented) // @internal (undocumented)
export function validateIndexKey(key: string): asserts key is IndexKey; export function validateIndexKey(key: string): asserts key is IndexKey;

Wyświetl plik

@ -74,23 +74,31 @@ export function fpsThrottle(fn: () => void) {
* Calls the function on the next frame. The target frame rate is 60fps. * Calls the function on the next frame. The target frame rate is 60fps.
* If the same fn is passed again before the next frame, it will still be called only once. * If the same fn is passed again before the next frame, it will still be called only once.
* @param fn - the fun to call on the next frame * @param fn - the fun to call on the next frame
* @returns * @returns a function that will cancel the call if called before the next frame
* @internal * @internal
*/ */
export function throttleToNextFrame(fn: () => void) { export function throttleToNextFrame(fn: () => void): () => void {
if (isTest()) { if (isTest()) {
return fn() fn()
return () => {
// noop
}
} }
if (fpsQueue.includes(fn)) { if (!fpsQueue.includes(fn)) {
return fpsQueue.push(fn)
if (!started) {
started = true
// We set last to Date.now() - targetTimePerFrame - 1 so that the first run will happen immediately
last = Date.now() - targetTimePerFrame - 1
}
tick()
} }
fpsQueue.push(fn) return () => {
if (!started) { const index = fpsQueue.indexOf(fn)
started = true if (index > -1) {
// We set last to Date.now() - targetTimePerFrame - 1 so that the first run will happen immediately fpsQueue.splice(index, 1)
last = Date.now() - targetTimePerFrame - 1 }
} }
tick()
} }