Performance improvements (#2977)
This PR does a few things to help with performance:
1. Instead of doing changes on raf we now do them 60 times per second.
This limits the number of updates on high refresh rate screens like the
iPad. With the current code this only applied to the history updates (so
when you subscribed to the updates), but the next point takes this a bit
futher.
2. We now trigger react updates 60 times per second. This is a change in
`useValue` and `useStateTracking` hooks.
3. We now throttle the inputs (like the `pointerMove`) in state nodes.
This means we batch multiple inputs and only apply them at most 60 times
per second.
We had to adjust our own tests to pass after this change so I marked
this as major as it might require the users of the library to do the
same.
Few observations:
- The browser calls the raf callbacks when it can. If it gets
overwhelmed it will call them further and further apart. As things call
down it will start calling them more frequently again. You can clearly
see this in the drawing example. When fps gets to a certain level we
start to get fewer updates, then fps can recover a bit. This makes the
experience quite janky. The updates can be kinda ok one second (dropping
frames, but consistently) and then they can completely stop and you have
to let go of the mouse to make them happen again. With the new logic it
seems everything is a lot more consistent.
- We might look into variable refresh rates to prevent this overtaxing
of the browser. Like when we see that the times between our updates are
getting higher we could make the updates less frequent. If we then see
that they are happening more often we could ramp them back up. I had an
[experiment for this
here](4834863966 (diff-318e71563d7c47173f89ec084ca44417cf70fc72faac85b96f48b856a8aec466L30-L35)
).
Few tests below. Used 6x slowdown for these.
# Resizing
### Before
https://github.com/tldraw/tldraw/assets/2523721/798a033f-5dfa-419e-9a2d-fd8908272ba0
### After
https://github.com/tldraw/tldraw/assets/2523721/45870a0c-c310-4be0-b63c-6c92c20ca037
# Drawing
Comparison is not 100% fair, we don't store the intermediate inputs
right now. That said, tick should still only produce once update so I do
think we can get a sense of the differences.
### Before
https://github.com/tldraw/tldraw/assets/2523721/2e8ac8c5-bbdf-484b-bb0c-70c967f4541c
### After
https://github.com/tldraw/tldraw/assets/2523721/8f54b7a8-9a0e-4a39-b168-482caceb0149
### Change Type
- [ ] `patch` — Bug fix
- [ ] `minor` — New feature
- [x] `major` — Breaking change
- [ ] `dependencies` — Changes to package dependencies[^1]
- [ ] `documentation` — Changes to the documentation only[^2]
- [ ] `tests` — Changes to any test code only[^2]
- [ ] `internal` — Any other changes that don't affect the published
package[^2]
- [ ] I don't know
[^1]: publishes a `patch` release, for devDependencies use `internal`
[^2]: will not publish a new version
### Release Notes
- Improves the performance of rendering.
---------
Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
47420d7476
commit
b5aff00c89
23 changed files with 290 additions and 140 deletions
|
@ -29,8 +29,8 @@
|
|||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"lint": "yarn run -T tsx ../../scripts/lint.ts",
|
||||
"e2e": "playwright test -c ./e2e/playwright.config.ts",
|
||||
"e2e-ui": "playwright test --ui -c ./e2e/playwright.config.ts"
|
||||
"e2e": "NODE_ENV=test && playwright test -c ./e2e/playwright.config.ts",
|
||||
"e2e-ui": "NODE_ENV=test && playwright test --ui -c ./e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": "^1.38.1",
|
||||
|
|
|
@ -52,6 +52,9 @@
|
|||
"node_modules/(?!(nanoid)/)"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.188",
|
||||
"@types/react": "^18.2.47",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { fpsThrottle } from '@tldraw/utils'
|
||||
import React from 'react'
|
||||
import { EffectScheduler } from '../core'
|
||||
|
||||
|
@ -26,9 +27,9 @@ export function useStateTracking<T>(name: string, render: () => T): T {
|
|||
() => renderRef.current?.(),
|
||||
// this is what will be invoked when @tldraw/state detects a change in an upstream reactive value
|
||||
{
|
||||
scheduleEffect() {
|
||||
scheduleEffect: fpsThrottle(() => {
|
||||
scheduleUpdate?.()
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable prefer-rest-params */
|
||||
import { throttleToNextFrame } from '@tldraw/utils'
|
||||
import { useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
import { Signal, computed, react } from '../core'
|
||||
|
||||
|
@ -81,10 +82,16 @@ export function useValue() {
|
|||
const { subscribe, getSnapshot } = useMemo(() => {
|
||||
return {
|
||||
subscribe: (listen: () => void) => {
|
||||
return react(`useValue(${name})`, () => {
|
||||
$val.get()
|
||||
listen()
|
||||
})
|
||||
return react(
|
||||
`useValue(${name})`,
|
||||
() => {
|
||||
$val.get()
|
||||
listen()
|
||||
},
|
||||
{
|
||||
scheduleEffect: throttleToNextFrame,
|
||||
}
|
||||
)
|
||||
},
|
||||
getSnapshot: () => $val.get(),
|
||||
}
|
||||
|
|
|
@ -5,5 +5,10 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./.tsbuild",
|
||||
"rootDir": "src"
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../utils"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
objectMapFromEntries,
|
||||
objectMapKeys,
|
||||
objectMapValues,
|
||||
throttledRaf,
|
||||
throttleToNextFrame,
|
||||
} from '@tldraw/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
|
||||
|
@ -205,7 +205,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
|||
// If we have accumulated history, flush it and update listeners
|
||||
this._flushHistory()
|
||||
},
|
||||
{ scheduleEffect: (cb) => throttledRaf(cb) }
|
||||
{ scheduleEffect: (cb) => throttleToNextFrame(cb) }
|
||||
)
|
||||
this.scopedTypes = {
|
||||
document: new Set(
|
||||
|
|
|
@ -51,11 +51,18 @@ export class Drawing extends StateNode {
|
|||
|
||||
markId = null as null | string
|
||||
|
||||
// Used to track whether we have changes that have not yet been pushed to the Editor.
|
||||
isDirty = false
|
||||
// The changes that have not yet been pushed to the Editor.
|
||||
shapePartial: TLShapePartial<DrawableShape> | null = null
|
||||
|
||||
override onEnter = (info: TLPointerEventInfo) => {
|
||||
this.markId = null
|
||||
this.info = info
|
||||
this.canDraw = !this.editor.getIsMenuOpen()
|
||||
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
|
||||
this.shapePartial = null
|
||||
this.isDirty = false
|
||||
if (this.canDraw) {
|
||||
this.startShape()
|
||||
}
|
||||
|
@ -84,8 +91,8 @@ export class Drawing extends StateNode {
|
|||
}
|
||||
|
||||
if (this.canDraw) {
|
||||
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
|
||||
if (inputs.isPen) {
|
||||
// Don't update the shape if we haven't moved far enough from the last time we recorded a point
|
||||
if (
|
||||
Vec.Dist(inputs.currentPagePoint, this.lastRecordedPoint) >=
|
||||
1 / this.editor.getZoomLevel()
|
||||
|
@ -99,10 +106,18 @@ export class Drawing extends StateNode {
|
|||
this.mergeNextPoint = false
|
||||
}
|
||||
|
||||
this.updateShapes()
|
||||
this.processUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
override onTick = () => {
|
||||
if (!this.isDirty) return
|
||||
this.isDirty = false
|
||||
if (!this.shapePartial) return
|
||||
|
||||
this.editor.updateShapes([this.shapePartial], { squashing: true })
|
||||
}
|
||||
|
||||
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
|
||||
if (info.key === 'Shift') {
|
||||
switch (this.segmentMode) {
|
||||
|
@ -117,7 +132,7 @@ export class Drawing extends StateNode {
|
|||
}
|
||||
}
|
||||
}
|
||||
this.updateShapes()
|
||||
this.processUpdates()
|
||||
}
|
||||
|
||||
override onKeyUp: TLEventHandlers['onKeyUp'] = (info) => {
|
||||
|
@ -139,7 +154,7 @@ export class Drawing extends StateNode {
|
|||
}
|
||||
}
|
||||
|
||||
this.updateShapes()
|
||||
this.processUpdates()
|
||||
}
|
||||
|
||||
override onExit? = () => {
|
||||
|
@ -281,7 +296,12 @@ export class Drawing extends StateNode {
|
|||
this.initialShape = this.editor.getShape<DrawableShape>(id)
|
||||
}
|
||||
|
||||
private updateShapes() {
|
||||
/**
|
||||
* This function is called to process user actions like moving the mouse or pressing a key.
|
||||
* The updates are not directly propagated to the Editor. Instead they are stored in the `shapePartial`
|
||||
* and only sent to the Editor on the next tick.
|
||||
*/
|
||||
private processUpdates() {
|
||||
const { inputs } = this.editor
|
||||
const { initialShape } = this
|
||||
|
||||
|
@ -296,12 +316,16 @@ export class Drawing extends StateNode {
|
|||
|
||||
if (!shape) return
|
||||
|
||||
const { segments } = shape.props
|
||||
// We default to the partial, as it might have some segments / points that the editor
|
||||
// does not know about yet.
|
||||
const segments = this.shapePartial?.props?.segments || shape.props.segments
|
||||
|
||||
const { x, y, z } = this.editor.getPointInShapeSpace(shape, inputs.currentPagePoint).toFixed()
|
||||
|
||||
const newPoint = { x, y, z: this.isPen ? +(z! * 1.25).toFixed(2) : 0.5 }
|
||||
|
||||
this.isDirty = true
|
||||
|
||||
switch (this.segmentMode) {
|
||||
case 'starting_straight': {
|
||||
const { pagePointWhereNextSegmentChanged } = this
|
||||
|
@ -370,9 +394,7 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes<TLDrawShape | TLHighlightShape>([shapePartial], {
|
||||
squashing: true,
|
||||
})
|
||||
this.shapePartial = shapePartial
|
||||
}
|
||||
break
|
||||
}
|
||||
|
@ -430,7 +452,7 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
||||
this.shapePartial = shapePartial
|
||||
}
|
||||
|
||||
break
|
||||
|
@ -572,7 +594,7 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
||||
this.shapePartial = shapePartial
|
||||
|
||||
break
|
||||
}
|
||||
|
@ -617,13 +639,19 @@ export class Drawing extends StateNode {
|
|||
)
|
||||
}
|
||||
|
||||
this.editor.updateShapes([shapePartial], { squashing: true })
|
||||
|
||||
// Set a maximum length for the lines array; after 200 points, complete the line.
|
||||
if (newPoints.length > 500) {
|
||||
this.editor.updateShapes([{ id, type: this.shapeType, props: { isComplete: true } }])
|
||||
// It's easier to just apply this change directly, so we will mark that the shape is no longer dirty.
|
||||
this.isDirty = false
|
||||
// Also clear the changes as they were flushed.
|
||||
// The next pointerMove will establish a new partial from the new shape created below.
|
||||
this.shapePartial = null
|
||||
|
||||
const { currentPagePoint } = this.editor.inputs
|
||||
if (shapePartial?.props) {
|
||||
shapePartial.props.isComplete = true
|
||||
this.editor.updateShapes([shapePartial])
|
||||
}
|
||||
|
||||
const { currentPagePoint } = inputs
|
||||
|
||||
const newShapeId = createShapeId()
|
||||
|
||||
|
@ -647,8 +675,10 @@ export class Drawing extends StateNode {
|
|||
|
||||
this.initialShape = structuredClone(this.editor.getShape<DrawableShape>(newShapeId)!)
|
||||
this.mergeNextPoint = false
|
||||
this.lastRecordedPoint = this.editor.inputs.currentPagePoint.clone()
|
||||
this.lastRecordedPoint = inputs.currentPagePoint.clone()
|
||||
this.currentLineLength = 0
|
||||
} else {
|
||||
this.shapePartial = shapePartial
|
||||
}
|
||||
|
||||
break
|
||||
|
|
|
@ -28,6 +28,7 @@ export class Brushing extends StateNode {
|
|||
brush = new Box()
|
||||
initialSelectedShapeIds: TLShapeId[] = []
|
||||
excludedShapeIds = new Set<TLShapeId>()
|
||||
isDirty = false
|
||||
isWrapMode = false
|
||||
|
||||
// The shape that the brush started on
|
||||
|
@ -55,9 +56,10 @@ export class Brushing extends StateNode {
|
|||
)
|
||||
|
||||
this.info = info
|
||||
this.isDirty = false
|
||||
this.initialSelectedShapeIds = this.editor.getSelectedShapeIds().slice()
|
||||
this.initialStartShape = this.editor.getShapesAtPoint(currentPagePoint)[0]
|
||||
this.onPointerMove()
|
||||
this.hitTestShapes()
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
|
@ -67,10 +69,14 @@ export class Brushing extends StateNode {
|
|||
|
||||
override onTick: TLTickEventHandler = () => {
|
||||
moveCameraWhenCloseToEdge(this.editor)
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.hitTestShapes()
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
this.hitTestShapes()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
|
||||
|
@ -99,6 +105,8 @@ export class Brushing extends StateNode {
|
|||
}
|
||||
|
||||
private complete() {
|
||||
this.hitTestShapes()
|
||||
this.isDirty = false
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ export class Cropping extends StateNode {
|
|||
}
|
||||
|
||||
markId = ''
|
||||
isDirty = false
|
||||
|
||||
private snapshot = {} as any as Snapshot
|
||||
|
||||
|
@ -40,11 +41,19 @@ export class Cropping extends StateNode {
|
|||
this.markId = 'cropping'
|
||||
this.editor.mark(this.markId)
|
||||
this.snapshot = this.createSnapshot()
|
||||
this.isDirty = false
|
||||
this.updateShapes()
|
||||
}
|
||||
|
||||
override onTick = () => {
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.updateShapes()
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
this.updateShapes()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onPointerUp: TLEventHandlers['onPointerUp'] = () => {
|
||||
|
@ -205,6 +214,8 @@ export class Cropping extends StateNode {
|
|||
}
|
||||
|
||||
private complete() {
|
||||
this.updateShapes()
|
||||
this.isDirty = false
|
||||
if (this.info.onInteractionEnd) {
|
||||
this.editor.setCurrentTool(this.info.onInteractionEnd, this.info)
|
||||
} else {
|
||||
|
|
|
@ -39,6 +39,7 @@ export class DraggingHandle extends StateNode {
|
|||
isPrecise = false
|
||||
isPreciseId = null as TLShapeId | null
|
||||
pointingId = null as TLShapeId | null
|
||||
isDirty = false
|
||||
|
||||
override onEnter: TLEnterEventHandler = (
|
||||
info: TLPointerEventInfo & {
|
||||
|
@ -50,6 +51,7 @@ export class DraggingHandle extends StateNode {
|
|||
) => {
|
||||
const { shape, isCreating, handle } = info
|
||||
this.info = info
|
||||
this.isDirty = false
|
||||
this.parent.setCurrentToolIdMask(info.onInteractionEnd)
|
||||
this.shapeId = shape.id
|
||||
this.markId = isCreating ? `creating:${shape.id}` : 'dragging handle'
|
||||
|
@ -165,8 +167,15 @@ export class DraggingHandle extends StateNode {
|
|||
}
|
||||
}
|
||||
|
||||
override onTick = () => {
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.update()
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
this.update()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onKeyDown: TLKeyboardEvent | undefined = () => {
|
||||
|
@ -182,6 +191,8 @@ export class DraggingHandle extends StateNode {
|
|||
}
|
||||
|
||||
override onComplete: TLEventHandlers['onComplete'] = () => {
|
||||
this.update()
|
||||
this.isDirty = false
|
||||
this.complete()
|
||||
}
|
||||
|
||||
|
|
|
@ -76,10 +76,15 @@ export class Resizing extends StateNode {
|
|||
|
||||
override onTick: TLTickEventHandler = () => {
|
||||
moveCameraWhenCloseToEdge(this.editor)
|
||||
if (!this.isDirty) return
|
||||
this.isDirty = false
|
||||
this.updateShapes()
|
||||
}
|
||||
|
||||
isDirty = false
|
||||
|
||||
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
|
||||
this.updateShapes()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onKeyDown: TLEventHandlers['onKeyDown'] = () => {
|
||||
|
|
|
@ -22,6 +22,7 @@ export class Rotating extends StateNode {
|
|||
info = {} as Extract<TLPointerEventInfo, { target: 'selection' }> & { onInteractionEnd?: string }
|
||||
|
||||
markId = ''
|
||||
isDirty = false
|
||||
|
||||
override onEnter = (
|
||||
info: TLPointerEventInfo & { target: 'selection'; onInteractionEnd?: string }
|
||||
|
@ -38,7 +39,24 @@ export class Rotating extends StateNode {
|
|||
this.snapshot = snapshot
|
||||
|
||||
// Trigger a pointer move
|
||||
this.handleStart()
|
||||
const newSelectionRotation = this._getRotationFromPointerPosition({
|
||||
snapToNearestDegree: false,
|
||||
})
|
||||
|
||||
applyRotationToSnapshotShapes({
|
||||
editor: this.editor,
|
||||
delta: this._getRotationFromPointerPosition({ snapToNearestDegree: false }),
|
||||
snapshot: this.snapshot,
|
||||
stage: 'start',
|
||||
})
|
||||
|
||||
// Update cursor
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
override onExit = () => {
|
||||
|
@ -48,8 +66,15 @@ export class Rotating extends StateNode {
|
|||
this.snapshot = {} as TLRotationSnapshot
|
||||
}
|
||||
|
||||
override onTick = () => {
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.update()
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
this.update()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onKeyDown = () => {
|
||||
|
@ -118,27 +143,6 @@ export class Rotating extends StateNode {
|
|||
}
|
||||
}
|
||||
|
||||
protected handleStart() {
|
||||
const newSelectionRotation = this._getRotationFromPointerPosition({
|
||||
snapToNearestDegree: false,
|
||||
})
|
||||
|
||||
applyRotationToSnapshotShapes({
|
||||
editor: this.editor,
|
||||
delta: this._getRotationFromPointerPosition({ snapToNearestDegree: false }),
|
||||
snapshot: this.snapshot,
|
||||
stage: 'start',
|
||||
})
|
||||
|
||||
// Update cursor
|
||||
this.editor.updateInstanceState({
|
||||
cursor: {
|
||||
type: CursorTypeMap[this.info.handle as RotateCorner],
|
||||
rotation: newSelectionRotation + this.snapshot.initialSelectionRotation,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
_getRotationFromPointerPosition({ snapToNearestDegree }: { snapToNearestDegree: boolean }) {
|
||||
const selectionRotation = this.editor.getSelectionRotation()
|
||||
const selectionBounds = this.editor.getSelectionRotatedPageBounds()
|
||||
|
|
|
@ -24,6 +24,8 @@ export class ScribbleBrushing extends StateNode {
|
|||
initialSelectedShapeIds = new Set<TLShapeId>()
|
||||
newlySelectedShapeIds = new Set<TLShapeId>()
|
||||
|
||||
isDirty = false
|
||||
|
||||
override onEnter = () => {
|
||||
this.initialSelectedShapeIds = new Set<TLShapeId>(
|
||||
this.editor.inputs.shiftKey ? this.editor.getSelectedShapeIds() : []
|
||||
|
@ -31,6 +33,7 @@ export class ScribbleBrushing extends StateNode {
|
|||
this.newlySelectedShapeIds = new Set<TLShapeId>()
|
||||
this.size = 0
|
||||
this.hits.clear()
|
||||
this.isDirty = false
|
||||
|
||||
const scribbleItem = this.editor.scribbles.addScribble({
|
||||
color: 'selection-stroke',
|
||||
|
@ -51,8 +54,15 @@ export class ScribbleBrushing extends StateNode {
|
|||
this.editor.scribbles.stop(this.scribbleId)
|
||||
}
|
||||
|
||||
override onTick = () => {
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.updateScribbleSelection(true)
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
this.updateScribbleSelection(true)
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onPointerUp = () => {
|
||||
|
@ -157,6 +167,8 @@ export class ScribbleBrushing extends StateNode {
|
|||
}
|
||||
|
||||
private complete() {
|
||||
this.updateScribbleSelection(true)
|
||||
this.isDirty = false
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ export class Translating extends StateNode {
|
|||
|
||||
isCloning = false
|
||||
isCreating = false
|
||||
isDirty = false
|
||||
onCreate: (shape: TLShape | null) => void = () => void null
|
||||
|
||||
dragAndDropManager = new DragAndDropManager(this.editor)
|
||||
|
@ -51,6 +52,7 @@ export class Translating extends StateNode {
|
|||
const { isCreating = false, onCreate = () => void null } = info
|
||||
|
||||
this.info = info
|
||||
this.isDirty = false
|
||||
this.parent.setCurrentToolIdMask(info.onInteractionEnd)
|
||||
this.isCreating = isCreating
|
||||
this.onCreate = onCreate
|
||||
|
@ -99,10 +101,14 @@ export class Translating extends StateNode {
|
|||
this.updateParentTransforms
|
||||
)
|
||||
moveCameraWhenCloseToEdge(this.editor)
|
||||
if (this.isDirty) {
|
||||
this.isDirty = false
|
||||
this.updateShapes()
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove = () => {
|
||||
this.updateShapes()
|
||||
this.isDirty = true
|
||||
}
|
||||
|
||||
override onKeyDown = () => {
|
||||
|
@ -167,6 +173,7 @@ export class Translating extends StateNode {
|
|||
|
||||
protected complete() {
|
||||
this.updateShapes()
|
||||
this.isDirty = false
|
||||
this.dragAndDropManager.dropShapes(this.snapshot.movingShapes)
|
||||
this.handleEnd()
|
||||
|
||||
|
|
|
@ -289,6 +289,17 @@ export class TestEditor extends Editor {
|
|||
|
||||
/* ------------------ Input Events ------------------ */
|
||||
|
||||
/**
|
||||
Some of our updates are not synchronous any longer. For example, drawing happens on tick instead of on pointer move.
|
||||
You can use this helper to force the tick, which will then process all the updates.
|
||||
*/
|
||||
forceTick = (count = 1) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.emit('tick', 16)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
pointerMove = (
|
||||
x = this.inputs.currentScreenPoint.x,
|
||||
y = this.inputs.currentScreenPoint.y,
|
||||
|
@ -298,7 +309,7 @@ export class TestEditor extends Editor {
|
|||
this.dispatch({
|
||||
...this.getPointerEventInfo(x, y, options, modifiers),
|
||||
name: 'pointer_move',
|
||||
})
|
||||
}).forceTick()
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
|
@ -203,7 +203,7 @@ for (const toolType of ['draw', 'highlight'] as const) {
|
|||
.pointerDown(10, 0)
|
||||
.pointerUp()
|
||||
.pointerDown(10, 0)
|
||||
.pointerMove(1, 0)
|
||||
.pointerMove(1, 0) // very close to first point
|
||||
|
||||
const shape1 = editor.getCurrentPageShapes()[0] as DrawableShape
|
||||
const segment1 = last(shape1.props.segments)!
|
||||
|
@ -211,6 +211,7 @@ for (const toolType of ['draw', 'highlight'] as const) {
|
|||
expect(point1.x).toBe(1)
|
||||
|
||||
editor.keyDown('Meta')
|
||||
editor.forceTick()
|
||||
const shape2 = editor.getCurrentPageShapes()[0] as DrawableShape
|
||||
const segment2 = last(shape2.props.segments)!
|
||||
const point2 = last(segment2.points)!
|
||||
|
@ -236,6 +237,7 @@ for (const toolType of ['draw', 'highlight'] as const) {
|
|||
expect(point1.x).toBe(1)
|
||||
|
||||
editor.keyDown('Meta')
|
||||
editor.forceTick()
|
||||
const shape2 = editor.getCurrentPageShapes()[0] as DrawableShape
|
||||
const segment2 = last(shape2.props.segments)!
|
||||
const point2 = last(segment2.points)!
|
||||
|
|
|
@ -119,12 +119,15 @@ describe('When translating...', () => {
|
|||
|
||||
it('translates a single shape', () => {
|
||||
editor
|
||||
.pointerDown(50, 50, ids.box1)
|
||||
.pointerDown(50, 50, ids.box1) // point = [10, 10]
|
||||
.pointerMove(50, 40) // [0, -10]
|
||||
.expectToBeIn('select.translating')
|
||||
.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 })
|
||||
.pointerMove(100, 100) // [50, 50]
|
||||
.expectToBeIn('select.translating')
|
||||
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 })
|
||||
.pointerUp()
|
||||
.expectToBeIn('select.idle')
|
||||
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 })
|
||||
})
|
||||
|
||||
|
@ -134,14 +137,14 @@ describe('When translating...', () => {
|
|||
|
||||
const before = editor.getShape<TLGeoShape>(ids.box1)!
|
||||
|
||||
jest.advanceTimersByTime(100)
|
||||
editor.forceTick(5)
|
||||
editor
|
||||
// The change is bigger than expected because the camera moves
|
||||
.expectShapeToMatch({ id: ids.box1, x: -160, y: 10 })
|
||||
// We'll continue moving in the x postion, but now we'll also move in the y position.
|
||||
// The speed in the y position is smaller since we are further away from the edge.
|
||||
.pointerMove(0, 25)
|
||||
jest.advanceTimersByTime(100)
|
||||
editor.forceTick(2)
|
||||
editor.pointerUp()
|
||||
|
||||
const after = editor.getShape<TLGeoShape>(ids.box1)!
|
||||
|
@ -156,12 +159,12 @@ describe('When translating...', () => {
|
|||
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
|
||||
editor.pointerDown(50, 50, ids.box1).pointerMove(1080, 50)
|
||||
|
||||
jest.advanceTimersByTime(100)
|
||||
editor
|
||||
.forceTick(4)
|
||||
// The change is bigger than expected because the camera moves
|
||||
.expectShapeToMatch({ id: ids.box1, x: 1140, y: 10 })
|
||||
.pointerMove(1080, 800)
|
||||
jest.advanceTimersByTime(100)
|
||||
.forceTick(6)
|
||||
editor
|
||||
.expectShapeToMatch({ id: ids.box1, x: 1280, y: 845.68 })
|
||||
.pointerUp()
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
reverseRecordsDiff,
|
||||
squashRecordDiffs,
|
||||
} from '@tldraw/store'
|
||||
import { exhaustiveSwitchError, objectMapEntries, rafThrottle } from '@tldraw/utils'
|
||||
import { exhaustiveSwitchError, fpsThrottle, objectMapEntries } from '@tldraw/utils'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { NetworkDiff, RecordOpType, applyObjectDiff, diffRecord, getNetworkDiff } from './diff'
|
||||
|
@ -461,7 +461,7 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
}
|
||||
|
||||
/** Send any unsent push requests to the server */
|
||||
private flushPendingPushRequests = rafThrottle(() => {
|
||||
private flushPendingPushRequests = fpsThrottle(() => {
|
||||
this.debug('flushing pending push requests', {
|
||||
isConnectedToRoom: this.isConnectedToRoom,
|
||||
pendingPushRequests: this.pendingPushRequests,
|
||||
|
@ -587,5 +587,5 @@ export class TLSyncClient<R extends UnknownRecord, S extends Store<R> = Store<R>
|
|||
}
|
||||
}
|
||||
|
||||
private scheduleRebase = rafThrottle(this.rebase)
|
||||
private scheduleRebase = fpsThrottle(this.rebase)
|
||||
}
|
||||
|
|
|
@ -74,6 +74,9 @@ export function filterEntries<Key extends string, Value>(object: {
|
|||
[K in Key]: Value;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function fpsThrottle(fn: () => void): () => void;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function getErrorAnnotations(error: Error): ErrorAnnotations;
|
||||
|
||||
|
@ -264,9 +267,6 @@ export function promiseWithResolve<T>(): Promise<T> & {
|
|||
reject: (reason?: any) => void;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function rafThrottle(fn: () => void): () => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
|
@ -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 throttledRaf(fn: () => void): void;
|
||||
export function throttleToNextFrame(fn: () => void): void;
|
||||
|
||||
// @internal (undocumented)
|
||||
export function validateIndexKey(key: string): asserts key is IndexKey;
|
||||
|
|
|
@ -38,7 +38,6 @@ export {
|
|||
objectMapValues,
|
||||
} from './lib/object'
|
||||
export { PngHelpers } from './lib/png'
|
||||
export { rafThrottle, throttledRaf } from './lib/raf'
|
||||
export { type IndexKey } from './lib/reordering/IndexKey'
|
||||
export {
|
||||
ZERO_INDEX_KEY,
|
||||
|
@ -63,6 +62,7 @@ export {
|
|||
setInLocalStorage,
|
||||
setInSessionStorage,
|
||||
} from './lib/storage'
|
||||
export { fpsThrottle, throttleToNextFrame } from './lib/throttle'
|
||||
export type { Expand, RecursivePartial, Required } from './lib/types'
|
||||
export { isDefined, isNonNull, isNonNullish, structuredClone } from './lib/value'
|
||||
export { warnDeprecatedGetter } from './lib/warnDeprecatedGetter'
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
const isTest = () =>
|
||||
typeof process !== 'undefined' &&
|
||||
process.env.NODE_ENV === 'test' &&
|
||||
// @ts-expect-error
|
||||
!globalThis.__FORCE_RAF_IN_TESTS__
|
||||
|
||||
const rafQueue: Array<() => void> = []
|
||||
|
||||
const tick = () => {
|
||||
const queue = rafQueue.splice(0, rafQueue.length)
|
||||
for (const fn of queue) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
let frame: number | undefined
|
||||
|
||||
function raf() {
|
||||
if (frame) {
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a throttled version of the function that will only be called max once per frame.
|
||||
* @param fn - the fun to return a throttled version of
|
||||
* @returns
|
||||
* @internal
|
||||
*/
|
||||
export function rafThrottle(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return fn
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafQueue.includes(fn)) {
|
||||
return
|
||||
}
|
||||
rafQueue.push(fn)
|
||||
raf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the function on the next frame.
|
||||
* 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 animation frame
|
||||
* @returns
|
||||
* @internal
|
||||
*/
|
||||
export function throttledRaf(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return fn()
|
||||
}
|
||||
|
||||
if (rafQueue.includes(fn)) {
|
||||
return
|
||||
}
|
||||
|
||||
rafQueue.push(fn)
|
||||
raf()
|
||||
}
|
96
packages/utils/src/lib/throttle.ts
Normal file
96
packages/utils/src/lib/throttle.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
const isTest = () =>
|
||||
typeof process !== 'undefined' &&
|
||||
process.env.NODE_ENV === 'test' &&
|
||||
// @ts-expect-error
|
||||
!globalThis.__FORCE_RAF_IN_TESTS__
|
||||
|
||||
const fpsQueue: Array<() => void> = []
|
||||
const targetFps = 60
|
||||
const targetTimePerFrame = 1000 / targetFps
|
||||
let frame: number | undefined
|
||||
let time = 0
|
||||
let last = 0
|
||||
|
||||
const flush = () => {
|
||||
const queue = fpsQueue.splice(0, fpsQueue.length)
|
||||
for (const fn of queue) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (frame) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const elapsed = now - last
|
||||
|
||||
if (time + elapsed < targetTimePerFrame) {
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
tick()
|
||||
})
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
last = now
|
||||
// If we fall behind more than 10 frames, we'll just reset the time so we don't try to update a number of times
|
||||
// This can happen if we don't interact with the page for a while
|
||||
time = Math.min(time + elapsed - targetTimePerFrame, targetTimePerFrame * 10)
|
||||
flush()
|
||||
})
|
||||
}
|
||||
|
||||
let started = false
|
||||
|
||||
/**
|
||||
* Returns a throttled version of the function that will only be called max once per frame.
|
||||
* The target frame rate is 60fps.
|
||||
* @param fn - the fun to return a throttled version of
|
||||
* @returns
|
||||
* @internal
|
||||
*/
|
||||
export function fpsThrottle(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return fn
|
||||
}
|
||||
|
||||
return () => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @internal
|
||||
*/
|
||||
export function throttleToNextFrame(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return 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()
|
||||
}
|
|
@ -7356,6 +7356,7 @@ __metadata:
|
|||
version: 0.0.0-use.local
|
||||
resolution: "@tldraw/state@workspace:packages/state"
|
||||
dependencies:
|
||||
"@tldraw/utils": "workspace:*"
|
||||
"@types/lodash": "npm:^4.14.188"
|
||||
"@types/react": "npm:^18.2.47"
|
||||
"@types/react-test-renderer": "npm:^18.0.0"
|
||||
|
|
Loading…
Reference in a new issue