tldraw/packages/editor/src/lib/editor/managers/ScribbleManager.ts
Steve Ruiz b9b5bd5b81
[fix] Batch tick events (#3181)
This PR fixes an issue where events happening on tick were not batched. 

![Kapture 2024-03-17 at 22 49
52](https://github.com/tldraw/tldraw/assets/23072548/2bcfa335-a38f-46c4-a3f3-434cac61b6ce)

We were listening to the `tick` event directly from the state node,
rather than passing the event into the state chart at the top. This
meant that it was bypassing the regular state chart rules, which was
what got me looking at this; but then I noticed that we also weren't
batching the changes, either. This causes computed stuff to re-compute
after each atom is updated within the `onTick` handler, which can be a
LOT.

Before:
<img width="1557" alt="image"
src="https://github.com/tldraw/tldraw/assets/23072548/ba8791f2-faec-463d-945a-9f5920826aab">

After:
<img width="1204" alt="image"
src="https://github.com/tldraw/tldraw/assets/23072548/a00f8e4a-caca-406a-89a2-8cff0e01b642">

It's not game breaking but it's important enough to hotfix at least in
the dot com.

### 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
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Select many shapes.
2. Resize them.

### Release Notes

- Fix a performance issue effecting resizing multiple shapes.
2024-03-18 14:33:36 +00:00

181 lines
4.4 KiB
TypeScript

import { TLScribble, VecModel } from '@tldraw/tlschema'
import { Vec } from '../../primitives/Vec'
import { uniqueId } from '../../utils/uniqueId'
import { Editor } from '../Editor'
type ScribbleItem = {
id: string
scribble: TLScribble
timeoutMs: number
delayRemaining: number
prev: null | VecModel
next: null | VecModel
}
/** @public */
export class ScribbleManager {
scribbleItems = new Map<string, ScribbleItem>()
state = 'paused' as 'paused' | 'running'
constructor(private editor: Editor) {}
addScribble = (scribble: Partial<TLScribble>, id = uniqueId()) => {
const item: ScribbleItem = {
id,
scribble: {
id,
size: 20,
color: 'accent',
opacity: 0.8,
delay: 0,
points: [],
shrink: 0.1,
taper: true,
...scribble,
state: 'starting',
},
timeoutMs: 0,
delayRemaining: scribble.delay ?? 0,
prev: null,
next: null,
}
this.scribbleItems.set(id, item)
return item
}
reset() {
this.editor.updateInstanceState({ scribbles: [] })
this.scribbleItems.clear()
}
/**
* Start stopping the scribble. The scribble won't be removed until its last point is cleared.
*
* @public
*/
stop = (id: ScribbleItem['id']) => {
const item = this.scribbleItems.get(id)
if (!item) throw Error(`Scribble with id ${id} not found`)
item.delayRemaining = Math.min(item.delayRemaining, 200)
item.scribble.state = 'stopping'
return item
}
/**
* Set the scribble's next point.
*
* @param point - The point to add.
* @public
*/
addPoint = (id: ScribbleItem['id'], x: number, y: number) => {
const item = this.scribbleItems.get(id)
if (!item) throw Error(`Scribble with id ${id} not found`)
const { prev } = item
const point = { x, y, z: 0.5 }
if (!prev || Vec.Dist(prev, point) >= 1) {
item.next = point
}
return item
}
/**
* Update on each animation frame.
*
* @param elapsed - The number of milliseconds since the last tick.
* @public
*/
tick = (elapsed: number) => {
if (this.scribbleItems.size === 0) return
this.editor.batch(() => {
this.scribbleItems.forEach((item) => {
// let the item get at least eight points before
// switching from starting to active
if (item.scribble.state === 'starting') {
const { next, prev } = item
if (next && next !== prev) {
item.prev = next
item.scribble.points.push(next)
}
if (item.scribble.points.length > 8) {
item.scribble.state = 'active'
}
return
}
if (item.delayRemaining > 0) {
item.delayRemaining = Math.max(0, item.delayRemaining - elapsed)
}
item.timeoutMs += elapsed
if (item.timeoutMs >= 16) {
item.timeoutMs = 0
}
const { delayRemaining, timeoutMs, prev, next, scribble } = item
switch (scribble.state) {
case 'active': {
if (next && next !== prev) {
item.prev = next
scribble.points.push(next)
// If we've run out of delay, then shrink the scribble from the start
if (delayRemaining === 0) {
if (scribble.points.length > 8) {
scribble.points.shift()
}
}
} else {
// While not moving, shrink the scribble from the start
if (timeoutMs === 0) {
if (scribble.points.length > 1) {
scribble.points.shift()
} else {
// Reset the item's delay
item.delayRemaining = scribble.delay
}
}
}
break
}
case 'stopping': {
if (item.delayRemaining === 0) {
if (timeoutMs === 0) {
// If the scribble is down to one point, we're done!
if (scribble.points.length === 1) {
this.scribbleItems.delete(item.id) // Remove the scribble
return
}
if (scribble.shrink) {
// Drop the scribble's size as it shrinks
scribble.size = Math.max(1, scribble.size * (1 - scribble.shrink))
}
// Drop the scribble's first point (its tail)
scribble.points.shift()
}
}
break
}
case 'paused': {
// Nothing to do while paused.
break
}
}
})
// The object here will get frozen into the record, so we need to
// create a copies of the parts that what we'll be mutating later.
this.editor.updateInstanceState({
scribbles: Array.from(this.scribbleItems.values())
.map(({ scribble }) => ({
...scribble,
points: [...scribble.points],
}))
.slice(-5), // limit to three as a minor sanity check
})
})
}
}