kopia lustrzana https://github.com/Tldraw/Tldraw
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
rodzic
9f90fa230b
commit
1951fc0e47
|
@ -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.
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue