@tldraw/utils 4.3.0 → 4.4.0-canary.15cff7ea86f8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,69 +4,179 @@ const isTest = () =>
4
4
  // @ts-expect-error
5
5
  !globalThis.__FORCE_RAF_IN_TESTS__
6
6
 
7
- const fpsQueue: Array<() => void> = []
8
- const targetFps = 60
9
- const targetTimePerFrame = Math.floor(1000 / targetFps) * 0.9 // ~15ms - we allow for some variance as browsers aren't that precise.
10
- let frameRaf: undefined | number
11
- let flushRaf: undefined | number
12
- let lastFlushTime = -targetTimePerFrame
13
-
14
- const flush = () => {
15
- const queue = fpsQueue.splice(0, fpsQueue.length)
16
- for (const fn of queue) {
17
- fn()
7
+ // Browsers aren't precise with frame timing - this factor prevents skipping frames unnecessarily
8
+ // by aiming slightly below the theoretical frame duration (e.g., ~7.5ms instead of 8.33ms for 120fps)
9
+ const timingVarianceFactor = 0.9
10
+ const getTargetTimePerFrame = (targetFps: number) =>
11
+ Math.floor(1000 / targetFps) * timingVarianceFactor
12
+
13
+ /**
14
+ * A scheduler class that manages a queue of functions to be executed at a target frame rate.
15
+ * Each instance maintains its own queue and state, allowing for separate throttling contexts
16
+ * (e.g., UI operations vs network sync operations).
17
+ *
18
+ * @public
19
+ */
20
+ export class FpsScheduler {
21
+ private targetFps: number
22
+ private targetTimePerFrame: number
23
+ private fpsQueue: Array<() => void> = []
24
+ private frameRaf: undefined | number
25
+ private flushRaf: undefined | number
26
+ private lastFlushTime: number
27
+
28
+ constructor(targetFps: number = 120) {
29
+ this.targetFps = targetFps
30
+ this.targetTimePerFrame = getTargetTimePerFrame(targetFps)
31
+ this.lastFlushTime = -this.targetTimePerFrame
18
32
  }
19
- }
20
33
 
21
- function tick(isOnNextFrame = false) {
22
- if (frameRaf) return
34
+ updateTargetFps(targetFps: number) {
35
+ if (targetFps === this.targetFps) return
36
+ this.targetFps = targetFps
37
+ this.targetTimePerFrame = getTargetTimePerFrame(targetFps)
38
+ this.lastFlushTime = -this.targetTimePerFrame
39
+ }
23
40
 
24
- const now = Date.now()
25
- const elapsed = now - lastFlushTime
41
+ private flush() {
42
+ const queue = this.fpsQueue.splice(0, this.fpsQueue.length)
43
+ for (const fn of queue) {
44
+ fn()
45
+ }
46
+ }
47
+
48
+ private tick(isOnNextFrame = false) {
49
+ if (this.frameRaf) return
26
50
 
27
- if (elapsed < targetTimePerFrame) {
28
- // If we're too early to flush, we need to wait until the next frame to try and flush again.
29
- // eslint-disable-next-line no-restricted-globals
30
- frameRaf = requestAnimationFrame(() => {
31
- frameRaf = undefined
32
- tick(true)
33
- })
34
- return
51
+ const now = Date.now()
52
+ const elapsed = now - this.lastFlushTime
53
+
54
+ if (elapsed < this.targetTimePerFrame) {
55
+ // If we're too early to flush, we need to wait until the next frame to try and flush again.
56
+ // eslint-disable-next-line no-restricted-globals
57
+ this.frameRaf = requestAnimationFrame(() => {
58
+ this.frameRaf = undefined
59
+ this.tick(true)
60
+ })
61
+ return
62
+ }
63
+
64
+ if (isOnNextFrame) {
65
+ // If we've already waited for the next frame to run the tick, then we can flush immediately
66
+ if (this.flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on this frame already, so we can do nothing here.
67
+ this.lastFlushTime = now
68
+ this.flush()
69
+ } else {
70
+ // If we haven't already waited for the next frame to run the tick, we need to wait until the next frame to flush.
71
+ if (this.flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on the next frame already, so we can do nothing here.
72
+ // eslint-disable-next-line no-restricted-globals
73
+ this.flushRaf = requestAnimationFrame(() => {
74
+ this.flushRaf = undefined
75
+ this.lastFlushTime = Date.now()
76
+ this.flush()
77
+ })
78
+ }
35
79
  }
36
80
 
37
- if (isOnNextFrame) {
38
- // If we've already waited for the next frame to run the tick, then we can flush immediately
39
- if (flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on this frame already, so we can do nothing here.
40
- lastFlushTime = now
41
- flush()
42
- } else {
43
- // If we haven't already waited for the next frame to run the tick, we need to wait until the next frame to flush.
44
- if (flushRaf) return // ...though if there's a flush raf, that means we'll be flushing on the next frame already, so we can do nothing here.
45
- // eslint-disable-next-line no-restricted-globals
46
- flushRaf = requestAnimationFrame(() => {
47
- flushRaf = undefined
48
- lastFlushTime = now
49
- flush()
50
- })
81
+ /**
82
+ * Creates a throttled version of a function that executes at most once per frame.
83
+ * The default target frame rate is set by the FpsScheduler instance.
84
+ * Subsequent calls within the same frame are ignored, ensuring smooth performance
85
+ * for high-frequency events like mouse movements or scroll events.
86
+ *
87
+ * @param fn - The function to throttle, optionally with a cancel method
88
+ * @returns A throttled function with an optional cancel method to remove pending calls
89
+ *
90
+ * @public
91
+ */
92
+ fpsThrottle(fn: { (): void; cancel?(): void }): {
93
+ (): void
94
+ cancel?(): void
95
+ } {
96
+ if (isTest()) {
97
+ fn.cancel = () => {
98
+ if (this.frameRaf) {
99
+ cancelAnimationFrame(this.frameRaf)
100
+ this.frameRaf = undefined
101
+ }
102
+ if (this.flushRaf) {
103
+ cancelAnimationFrame(this.flushRaf)
104
+ this.flushRaf = undefined
105
+ }
106
+ }
107
+ return fn
108
+ }
109
+
110
+ const throttledFn = () => {
111
+ if (this.fpsQueue.includes(fn)) {
112
+ return
113
+ }
114
+ this.fpsQueue.push(fn)
115
+ this.tick()
116
+ }
117
+ throttledFn.cancel = () => {
118
+ const index = this.fpsQueue.indexOf(fn)
119
+ if (index > -1) {
120
+ this.fpsQueue.splice(index, 1)
121
+ }
122
+ }
123
+ return throttledFn
124
+ }
125
+
126
+ /**
127
+ * Schedules a function to execute on the next animation frame.
128
+ * If the same function is passed multiple times before the frame executes,
129
+ * it will only be called once, effectively batching multiple calls.
130
+ *
131
+ * @param fn - The function to execute on the next frame
132
+ * @returns A cancel function that can prevent execution if called before the next frame
133
+ *
134
+ * @public
135
+ */
136
+ throttleToNextFrame(fn: () => void): () => void {
137
+ if (isTest()) {
138
+ fn()
139
+ return () => void null // noop
140
+ }
141
+
142
+ if (!this.fpsQueue.includes(fn)) {
143
+ this.fpsQueue.push(fn)
144
+ this.tick()
145
+ }
146
+
147
+ return () => {
148
+ const index = this.fpsQueue.indexOf(fn)
149
+ if (index > -1) {
150
+ this.fpsQueue.splice(index, 1)
151
+ }
152
+ }
51
153
  }
52
154
  }
53
155
 
156
+ // Default instance for UI operations
157
+ const defaultScheduler = new FpsScheduler(120)
158
+
54
159
  /**
55
- * Creates a throttled version of a function that executes at most once per frame (60fps).
160
+ * Creates a throttled version of a function that executes at most once per frame.
161
+ * The default target frame rate is 120fps, but can be customized per function.
56
162
  * Subsequent calls within the same frame are ignored, ensuring smooth performance
57
163
  * for high-frequency events like mouse movements or scroll events.
58
164
  *
165
+ * Uses the default throttle instance for UI operations. If you need a separate
166
+ * throttling queue (e.g., for network operations), create your own Throttle instance.
167
+ *
59
168
  * @param fn - The function to throttle, optionally with a cancel method
60
169
  * @returns A throttled function with an optional cancel method to remove pending calls
61
170
  *
62
171
  * @example
63
172
  * ```ts
173
+ * // Default 120fps throttling
64
174
  * const updateCanvas = fpsThrottle(() => {
65
- * // This will run at most once per frame (~16.67ms)
175
+ * // This will run at most once per frame (~8.33ms)
66
176
  * redrawCanvas()
67
177
  * })
68
178
  *
69
- * // Call as often as you want - automatically throttled to 60fps
179
+ * // Call as often as you want - automatically throttled to 120fps
70
180
  * document.addEventListener('mousemove', updateCanvas)
71
181
  *
72
182
  * // Cancel pending calls if needed
@@ -79,41 +189,16 @@ export function fpsThrottle(fn: { (): void; cancel?(): void }): {
79
189
  (): void
80
190
  cancel?(): void
81
191
  } {
82
- if (isTest()) {
83
- fn.cancel = () => {
84
- if (frameRaf) {
85
- cancelAnimationFrame(frameRaf)
86
- frameRaf = undefined
87
- }
88
- if (flushRaf) {
89
- cancelAnimationFrame(flushRaf)
90
- flushRaf = undefined
91
- }
92
- }
93
- return fn
94
- }
95
-
96
- const throttledFn = () => {
97
- if (fpsQueue.includes(fn)) {
98
- return
99
- }
100
- fpsQueue.push(fn)
101
- tick()
102
- }
103
- throttledFn.cancel = () => {
104
- const index = fpsQueue.indexOf(fn)
105
- if (index > -1) {
106
- fpsQueue.splice(index, 1)
107
- }
108
- }
109
- return throttledFn
192
+ return defaultScheduler.fpsThrottle(fn)
110
193
  }
111
194
 
112
195
  /**
113
- * Schedules a function to execute on the next animation frame, targeting 60fps.
196
+ * Schedules a function to execute on the next animation frame, targeting 120fps.
114
197
  * If the same function is passed multiple times before the frame executes,
115
198
  * it will only be called once, effectively batching multiple calls.
116
199
  *
200
+ * Uses the default throttle instance for UI operations.
201
+ *
117
202
  * @param fn - The function to execute on the next frame
118
203
  * @returns A cancel function that can prevent execution if called before the next frame
119
204
  *
@@ -138,20 +223,5 @@ export function fpsThrottle(fn: { (): void; cancel?(): void }): {
138
223
  * @internal
139
224
  */
140
225
  export function throttleToNextFrame(fn: () => void): () => void {
141
- if (isTest()) {
142
- fn()
143
- return () => void null // noop
144
- }
145
-
146
- if (!fpsQueue.includes(fn)) {
147
- fpsQueue.push(fn)
148
- tick()
149
- }
150
-
151
- return () => {
152
- const index = fpsQueue.indexOf(fn)
153
- if (index > -1) {
154
- fpsQueue.splice(index, 1)
155
- }
156
- }
226
+ return defaultScheduler.throttleToNextFrame(fn)
157
227
  }