@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.
- package/dist-cjs/index.d.ts +49 -0
- package/dist-cjs/index.js +2 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/throttle.js +113 -69
- package/dist-cjs/lib/throttle.js.map +2 -2
- package/dist-esm/index.d.mts +49 -0
- package/dist-esm/index.mjs +3 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/throttle.mjs +113 -69
- package/dist-esm/lib/throttle.mjs.map +2 -2
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/lib/throttle.test.ts +443 -0
- package/src/lib/throttle.ts +156 -86
package/src/lib/throttle.ts
CHANGED
|
@@ -4,69 +4,179 @@ const isTest = () =>
|
|
|
4
4
|
// @ts-expect-error
|
|
5
5
|
!globalThis.__FORCE_RAF_IN_TESTS__
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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 (~
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|