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.textMeasure = new TextManager(this)
this._tickManager = new TickManager(this)
class NewRoot extends RootState {
static override initial = initialState ?? ''
@ -666,7 +667,7 @@ export class Editor extends EventEmitter<TLEventMap> {
readonly disposables = new Set<() => void>()
/** @internal */
private _tickManager = new TickManager(this)
private readonly _tickManager
/**
* 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 { 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 {
constructor(public editor: Editor) {
this.editor.disposables.add(this.dispose)
this.start()
}
raf: any
cancelRaf?: null | (() => void)
isPaused = true
last = 0
t = 0
start = () => {
this.isPaused = false
cancelAnimationFrame(this.raf)
this.raf = requestAnimationFrame(this.tick)
this.cancelRaf?.()
this.cancelRaf = throttleToNextFrame(this.tick)
this.last = Date.now()
}
@ -27,25 +37,18 @@ export class TickManager {
const now = Date.now()
const elapsed = now - this.last
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.editor.emit('frame', elapsed)
this.editor.emit('tick', elapsed)
this.raf = requestAnimationFrame(this.tick)
this.cancelRaf = throttleToNextFrame(this.tick)
}
// Clear the listener
dispose = () => {
this.isPaused = true
cancelAnimationFrame(this.raf)
this.cancelRaf?.()
}
private prevPoint = new Vec()

Wyświetl plik

@ -3,6 +3,7 @@ import {
BoxModel,
Editor,
HALF_PI,
IdOf,
Mat,
PageRecordType,
ROTATE_CORNER_TO_SELECTION_CORNER,
@ -205,6 +206,8 @@ export class TestEditor extends Editor {
y: +camera.y.toFixed(2),
z: +camera.z.toFixed(2),
}).toCloselyMatchObject({ x, y, z })
return this
}
expectShapeToMatch = <T extends TLShape = TLShape>(
@ -218,6 +221,25 @@ export class TestEditor extends Editor {
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 --------------------- */
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.
*/
forceTick = (count = 1) => {
const tick = (this as any)._tickManager as { tick(): void }
for (let i = 0; i < count; i++) {
this.emit('tick', 16)
tick.tick()
}
return this
}

Wyświetl plik

@ -1876,3 +1876,129 @@ it('clones a single shape simply', () => {
expect(editor.getEditingShape()).toBe(undefined)
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>;
// @internal
export function throttleToNextFrame(fn: () => void): void;
export function throttleToNextFrame(fn: () => void): () => void;
// @internal (undocumented)
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.
* 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
* @returns
* @returns a function that will cancel the call if called before the next frame
* @internal
*/
export function throttleToNextFrame(fn: () => void) {
export function throttleToNextFrame(fn: () => void): () => void {
if (isTest()) {
return fn()
fn()
return () => {
// noop
}
}
if (fpsQueue.includes(fn)) {
return
if (!fpsQueue.includes(fn)) {
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)
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
return () => {
const index = fpsQueue.indexOf(fn)
if (index > -1) {
fpsQueue.splice(index, 1)
}
}
tick()
}