phase 0.0.1-alpha.1 → 0.0.2
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/LICENSE +21 -0
- package/README.md +917 -0
- package/dist/ease.d.ts +34 -0
- package/dist/ease.d.ts.map +1 -0
- package/dist/ease.js +56 -0
- package/dist/ease.js.map +1 -0
- package/dist/index-D3epuj2x.d.ts +249 -0
- package/dist/index-D3epuj2x.d.ts.map +1 -0
- package/dist/index.d.ts +99 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +702 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +1066 -0
- package/dist/react.js.map +1 -0
- package/dist/reduced-motion-CEJtegNG.js +948 -0
- package/dist/reduced-motion-CEJtegNG.js.map +1 -0
- package/package.json +78 -17
- package/index.js +0 -24
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
//#region src/core/_internal/abort/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Link an optional `AbortSignal` to a primitive's `stop`/cancel function.
|
|
4
|
+
*
|
|
5
|
+
* When the signal aborts, `stop` runs once. If the signal is already aborted,
|
|
6
|
+
* `stop` runs synchronously. Returns an unlink function that removes the abort
|
|
7
|
+
* listener. Call it from inside `stop` so a manual stop does not leave a
|
|
8
|
+
* dangling listener on a long-lived controller.
|
|
9
|
+
*
|
|
10
|
+
* The listener is registered with `{ once: true }`, so abort and manual stop
|
|
11
|
+
* are safe to interleave: whichever fires first wins, the other is a no-op.
|
|
12
|
+
*/
|
|
13
|
+
function linkAbortSignal(signal, stop) {
|
|
14
|
+
if (!signal) return unlinkNoop;
|
|
15
|
+
if (signal.aborted) {
|
|
16
|
+
stop();
|
|
17
|
+
return unlinkNoop;
|
|
18
|
+
}
|
|
19
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
20
|
+
return () => signal.removeEventListener("abort", stop);
|
|
21
|
+
}
|
|
22
|
+
/** Shared empty unlink for the no-signal and already-aborted paths. */
|
|
23
|
+
function unlinkNoop() {}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/core/_internal/errors/index.ts
|
|
26
|
+
/** Lightweight structured error for phase. */
|
|
27
|
+
var PhaseError = class extends Error {
|
|
28
|
+
code;
|
|
29
|
+
reason;
|
|
30
|
+
fix;
|
|
31
|
+
link;
|
|
32
|
+
constructor(message, options) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "PhaseError";
|
|
35
|
+
this.code = options.code;
|
|
36
|
+
this.reason = options.reason;
|
|
37
|
+
this.fix = options.fix;
|
|
38
|
+
this.link = options.link;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** Check if a value is a PhaseError instance. */
|
|
42
|
+
function isPhaseError(error) {
|
|
43
|
+
return error instanceof PhaseError;
|
|
44
|
+
}
|
|
45
|
+
function serverContextError(fn) {
|
|
46
|
+
throw new PhaseError(`${fn}() cannot be called on the server.`, {
|
|
47
|
+
code: "server_context",
|
|
48
|
+
reason: "Browser APIs are unavailable during SSR.",
|
|
49
|
+
fix: "Move into a useEffect or client-only module."
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function noElementError(fn) {
|
|
53
|
+
throw new PhaseError(`${fn}() requires a DOM element.`, {
|
|
54
|
+
code: "no_element",
|
|
55
|
+
reason: "The element was null or undefined.",
|
|
56
|
+
fix: "Pass a mounted Element, or use the React hook which manages the ref."
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function invalidDurationError(fn, value) {
|
|
60
|
+
throw new PhaseError(`${fn}() received an invalid duration: ${value}`, {
|
|
61
|
+
code: "invalid_duration",
|
|
62
|
+
reason: "Duration must be a finite positive number.",
|
|
63
|
+
fix: "Pass a positive number (e.g., 300 for 300ms)."
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function tickerStoppedError() {
|
|
67
|
+
throw new PhaseError("Cannot resume a stopped ticker.", {
|
|
68
|
+
code: "ticker_stopped",
|
|
69
|
+
reason: "stop() is terminal, so a stopped ticker cannot be resumed.",
|
|
70
|
+
fix: "Create a new ticker instance instead of resuming a stopped one."
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function missingContextError(child, parent) {
|
|
74
|
+
throw new PhaseError(`<${child}> must be used inside <${parent}>.`, {
|
|
75
|
+
code: "missing_context",
|
|
76
|
+
reason: `<${child}> reads from a context that <${parent}> provides.`,
|
|
77
|
+
fix: `Wrap <${child}> with <${parent}>.`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/core/tick/index.ts
|
|
82
|
+
/** Prevents teleportation on resume. Matches motion's maxElapsed. */
|
|
83
|
+
const MAX_DELTA_MS = 40;
|
|
84
|
+
/** Default first-frame delta when no previous tick exists. */
|
|
85
|
+
const DEFAULT_FIRST_DELTA_MS = 16.67;
|
|
86
|
+
let sharedTime = 0;
|
|
87
|
+
let sharedRafId = 0;
|
|
88
|
+
const sharedSubscribers = /* @__PURE__ */ new Set();
|
|
89
|
+
function sharedTick() {
|
|
90
|
+
sharedTime = performance.now();
|
|
91
|
+
for (const callback of sharedSubscribers) callback();
|
|
92
|
+
if (sharedSubscribers.size > 0) sharedRafId = requestAnimationFrame(sharedTick);
|
|
93
|
+
}
|
|
94
|
+
function joinSharedClock(callback) {
|
|
95
|
+
const wasEmpty = sharedSubscribers.size === 0;
|
|
96
|
+
sharedSubscribers.add(callback);
|
|
97
|
+
if (wasEmpty) {
|
|
98
|
+
sharedTime = performance.now();
|
|
99
|
+
sharedRafId = requestAnimationFrame(sharedTick);
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
sharedSubscribers.delete(callback);
|
|
103
|
+
if (sharedSubscribers.size === 0 && sharedRafId) {
|
|
104
|
+
cancelAnimationFrame(sharedRafId);
|
|
105
|
+
sharedRafId = 0;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function resetFrameState(state) {
|
|
110
|
+
state.time = 0;
|
|
111
|
+
state.delta = 0;
|
|
112
|
+
state.elapsed = 0;
|
|
113
|
+
state.frame = 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Core rAF loop primitive with FPS cap, delta clamping, and strong pause.
|
|
117
|
+
*
|
|
118
|
+
* @remarks
|
|
119
|
+
* `FrameState` is reused across frames. Do not store a reference to it.
|
|
120
|
+
* Read values immediately in your `onTick` callback.
|
|
121
|
+
*/
|
|
122
|
+
function createTicker(options) {
|
|
123
|
+
if (typeof requestAnimationFrame === "undefined") serverContextError("createTicker");
|
|
124
|
+
const { onTick, fps, signal } = options;
|
|
125
|
+
const minFrameTime = fps ? 1e3 / fps : 0;
|
|
126
|
+
let _phase = "idle";
|
|
127
|
+
let _reason = "initial";
|
|
128
|
+
let leaveSharedClock = null;
|
|
129
|
+
let lastTickTime = 0;
|
|
130
|
+
let pauseStartTime = 0;
|
|
131
|
+
let totalPausedTime = 0;
|
|
132
|
+
let startTime = 0;
|
|
133
|
+
const frame = {
|
|
134
|
+
time: 0,
|
|
135
|
+
delta: 0,
|
|
136
|
+
elapsed: 0,
|
|
137
|
+
frame: 0
|
|
138
|
+
};
|
|
139
|
+
function tick() {
|
|
140
|
+
const now = sharedTime;
|
|
141
|
+
if (minFrameTime > 0 && now - lastTickTime < minFrameTime) return;
|
|
142
|
+
const rawDelta = lastTickTime === 0 ? DEFAULT_FIRST_DELTA_MS : now - lastTickTime;
|
|
143
|
+
lastTickTime = now;
|
|
144
|
+
frame.time = now;
|
|
145
|
+
frame.delta = rawDelta > MAX_DELTA_MS ? MAX_DELTA_MS : rawDelta;
|
|
146
|
+
frame.elapsed = now - startTime - totalPausedTime;
|
|
147
|
+
frame.frame++;
|
|
148
|
+
onTick(frame);
|
|
149
|
+
}
|
|
150
|
+
function start() {
|
|
151
|
+
if (_phase === "running") return;
|
|
152
|
+
if (_phase === "stopped") tickerStoppedError();
|
|
153
|
+
if (_phase === "paused") {
|
|
154
|
+
resume();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
_phase = "running";
|
|
158
|
+
_reason = "started";
|
|
159
|
+
startTime = performance.now();
|
|
160
|
+
lastTickTime = 0;
|
|
161
|
+
totalPausedTime = 0;
|
|
162
|
+
resetFrameState(frame);
|
|
163
|
+
leaveSharedClock = joinSharedClock(tick);
|
|
164
|
+
}
|
|
165
|
+
function pause() {
|
|
166
|
+
if (_phase !== "running") return;
|
|
167
|
+
_phase = "paused";
|
|
168
|
+
_reason = "manual";
|
|
169
|
+
pauseStartTime = performance.now();
|
|
170
|
+
leaveSharedClock?.();
|
|
171
|
+
leaveSharedClock = null;
|
|
172
|
+
}
|
|
173
|
+
function resume() {
|
|
174
|
+
if (_phase === "stopped") tickerStoppedError();
|
|
175
|
+
if (_phase !== "paused") return;
|
|
176
|
+
totalPausedTime += performance.now() - pauseStartTime;
|
|
177
|
+
lastTickTime = 0;
|
|
178
|
+
_phase = "running";
|
|
179
|
+
_reason = "resumed";
|
|
180
|
+
leaveSharedClock = joinSharedClock(tick);
|
|
181
|
+
}
|
|
182
|
+
function stop() {
|
|
183
|
+
if (_phase === "stopped") return;
|
|
184
|
+
_phase = "stopped";
|
|
185
|
+
_reason = _reason === "initial" ? "disposed" : "manual";
|
|
186
|
+
unlinkAbort?.();
|
|
187
|
+
leaveSharedClock?.();
|
|
188
|
+
leaveSharedClock = null;
|
|
189
|
+
}
|
|
190
|
+
let unlinkAbort;
|
|
191
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
192
|
+
return {
|
|
193
|
+
start,
|
|
194
|
+
stop,
|
|
195
|
+
pause,
|
|
196
|
+
resume,
|
|
197
|
+
get phase() {
|
|
198
|
+
return _phase;
|
|
199
|
+
},
|
|
200
|
+
get phaseReason() {
|
|
201
|
+
return _reason;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/core/_internal/pool/io-pool.ts
|
|
207
|
+
const pool$1 = /* @__PURE__ */ new Map();
|
|
208
|
+
/**
|
|
209
|
+
* Observe an element via a shared IntersectionObserver pool.
|
|
210
|
+
* Elements with identical options share one IO instance.
|
|
211
|
+
*
|
|
212
|
+
* @returns Cleanup function that unobserves the element and removes the IO if empty.
|
|
213
|
+
*/
|
|
214
|
+
function observeIntersection(options) {
|
|
215
|
+
const { element, onIntersect, root, rootMargin, threshold } = options;
|
|
216
|
+
const ioInit = {
|
|
217
|
+
root,
|
|
218
|
+
rootMargin,
|
|
219
|
+
threshold
|
|
220
|
+
};
|
|
221
|
+
const key = getPoolKey(ioInit);
|
|
222
|
+
const entry = getOrCreatePoolEntry(key, ioInit);
|
|
223
|
+
entry.callbacks.set(element, onIntersect);
|
|
224
|
+
entry.observer.observe(element);
|
|
225
|
+
let disposed = false;
|
|
226
|
+
return () => {
|
|
227
|
+
if (disposed) return;
|
|
228
|
+
disposed = true;
|
|
229
|
+
const poolEntry = pool$1.get(key);
|
|
230
|
+
if (!poolEntry) return;
|
|
231
|
+
if (poolEntry.callbacks.get(element) === onIntersect) {
|
|
232
|
+
poolEntry.observer.unobserve(element);
|
|
233
|
+
poolEntry.callbacks.delete(element);
|
|
234
|
+
}
|
|
235
|
+
if (poolEntry.callbacks.size === 0) {
|
|
236
|
+
poolEntry.observer.disconnect();
|
|
237
|
+
pool$1.delete(key);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Produces a stable string key from IO options to group observers in the pool.
|
|
243
|
+
* IO options are immutable after construction, so identical options can share.
|
|
244
|
+
*/
|
|
245
|
+
function getPoolKey(opts) {
|
|
246
|
+
const root = opts.root ? "custom" : "null";
|
|
247
|
+
const margin = opts.rootMargin ?? "0px";
|
|
248
|
+
const threshold = Array.isArray(opts.threshold) ? opts.threshold.join(",") : String(opts.threshold ?? 0);
|
|
249
|
+
return root + "|" + margin + "|" + threshold;
|
|
250
|
+
}
|
|
251
|
+
/** Return an existing pool entry for this key, or create and register a new one. */
|
|
252
|
+
function getOrCreatePoolEntry(key, options) {
|
|
253
|
+
const existing = pool$1.get(key);
|
|
254
|
+
if (existing) return existing;
|
|
255
|
+
const entry = createPoolEntry$1(options);
|
|
256
|
+
pool$1.set(key, entry);
|
|
257
|
+
return entry;
|
|
258
|
+
}
|
|
259
|
+
/** Create a new pool entry for the given options. */
|
|
260
|
+
const createPoolEntry$1 = (options) => {
|
|
261
|
+
const callbacks = /* @__PURE__ */ new Map();
|
|
262
|
+
return {
|
|
263
|
+
observer: new IntersectionObserver((entries) => {
|
|
264
|
+
for (const ioEntry of entries) {
|
|
265
|
+
const cb = callbacks.get(ioEntry.target);
|
|
266
|
+
if (cb) cb(ioEntry);
|
|
267
|
+
}
|
|
268
|
+
}, options),
|
|
269
|
+
callbacks
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/core/sight/index.ts
|
|
274
|
+
/**
|
|
275
|
+
* Visibility observer combining document focus and viewport intersection.
|
|
276
|
+
*
|
|
277
|
+
* `phase` is `'visible'` only when both the document is visible (not backgrounded)
|
|
278
|
+
* and the element is within the viewport. Uses a shared IntersectionObserver
|
|
279
|
+
* pool. Multiple `createSight` calls with the same options share one observer.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* const sight = createSight({
|
|
283
|
+
* element: el,
|
|
284
|
+
* onPhaseChange: (phase) => phase === 'visible' ? loop.start() : loop.pause(),
|
|
285
|
+
* });
|
|
286
|
+
* // cleanup:
|
|
287
|
+
* sight.stop();
|
|
288
|
+
*
|
|
289
|
+
* @remarks
|
|
290
|
+
* `onPhaseChange` fires only on phase transitions, not on every IntersectionObserver callback.
|
|
291
|
+
*/
|
|
292
|
+
function createSight(options) {
|
|
293
|
+
if (typeof document === "undefined") serverContextError("createSight");
|
|
294
|
+
const { element, intersectionOptions, onPhaseChange, signal } = options;
|
|
295
|
+
if (!element) noElementError("createSight");
|
|
296
|
+
let _phase = "unknown";
|
|
297
|
+
let _reason = "initial";
|
|
298
|
+
let stopped = false;
|
|
299
|
+
let documentVisible = !document.hidden;
|
|
300
|
+
let elementInView = false;
|
|
301
|
+
function recompute(trigger) {
|
|
302
|
+
if (stopped) return;
|
|
303
|
+
const prev = _phase;
|
|
304
|
+
const next = documentVisible && elementInView ? "visible" : "hidden";
|
|
305
|
+
_reason = next === "hidden" && !documentVisible && !elementInView ? "all-hidden" : trigger;
|
|
306
|
+
if (next === prev) return;
|
|
307
|
+
_phase = next;
|
|
308
|
+
onPhaseChange?.(_phase, _reason);
|
|
309
|
+
}
|
|
310
|
+
function onVisibilityChange() {
|
|
311
|
+
documentVisible = !document.hidden;
|
|
312
|
+
recompute("document");
|
|
313
|
+
}
|
|
314
|
+
function onPageShow(event) {
|
|
315
|
+
if (!event.persisted) return;
|
|
316
|
+
documentVisible = true;
|
|
317
|
+
recompute("bfcache");
|
|
318
|
+
}
|
|
319
|
+
function onIntersection(entry) {
|
|
320
|
+
elementInView = entry.isIntersecting;
|
|
321
|
+
recompute("viewport");
|
|
322
|
+
}
|
|
323
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
324
|
+
window.addEventListener("pageshow", onPageShow);
|
|
325
|
+
const unobserveIO = observeIntersection({
|
|
326
|
+
element,
|
|
327
|
+
onIntersect: onIntersection,
|
|
328
|
+
...intersectionOptions
|
|
329
|
+
});
|
|
330
|
+
let unlinkAbort;
|
|
331
|
+
function stop() {
|
|
332
|
+
if (stopped) return;
|
|
333
|
+
stopped = true;
|
|
334
|
+
unlinkAbort?.();
|
|
335
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
336
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
337
|
+
unobserveIO();
|
|
338
|
+
_phase = "hidden";
|
|
339
|
+
_reason = "initial";
|
|
340
|
+
}
|
|
341
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
342
|
+
return {
|
|
343
|
+
get phase() {
|
|
344
|
+
return stopped ? "hidden" : _phase;
|
|
345
|
+
},
|
|
346
|
+
get phaseReason() {
|
|
347
|
+
return _reason;
|
|
348
|
+
},
|
|
349
|
+
stop
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/core/_internal/pool/mql-pool.ts
|
|
354
|
+
const pool = /* @__PURE__ */ new Map();
|
|
355
|
+
/**
|
|
356
|
+
* Subscribe to a media query via a shared MediaQueryList pool.
|
|
357
|
+
* Multiple subscribers to the same query share one MQL and one change listener.
|
|
358
|
+
*
|
|
359
|
+
* @returns Cleanup function that removes the subscriber.
|
|
360
|
+
*/
|
|
361
|
+
function subscribeMediaQuery(query, callback) {
|
|
362
|
+
getOrCreateEntry(query).listeners.add(callback);
|
|
363
|
+
let disposed = false;
|
|
364
|
+
return () => {
|
|
365
|
+
if (disposed) return;
|
|
366
|
+
disposed = true;
|
|
367
|
+
const poolEntry = pool.get(query);
|
|
368
|
+
if (!poolEntry) return;
|
|
369
|
+
poolEntry.listeners.delete(callback);
|
|
370
|
+
if (poolEntry.listeners.size === 0) {
|
|
371
|
+
poolEntry.mql.removeEventListener("change", poolEntry.handler);
|
|
372
|
+
pool.delete(query);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Synchronous read of a media query via the shared pool.
|
|
378
|
+
* Uses the existing pool entry if available, otherwise reads directly.
|
|
379
|
+
*/
|
|
380
|
+
function readMediaQuery(query) {
|
|
381
|
+
const entry = pool.get(query);
|
|
382
|
+
if (entry) return entry.mql.matches;
|
|
383
|
+
return matchMedia(query).matches;
|
|
384
|
+
}
|
|
385
|
+
/** Return an existing pool entry for this query, or create and register a new one. */
|
|
386
|
+
function getOrCreateEntry(query) {
|
|
387
|
+
const existing = pool.get(query);
|
|
388
|
+
if (existing) return existing;
|
|
389
|
+
const entry = createPoolEntry(query);
|
|
390
|
+
entry.mql.addEventListener("change", entry.handler);
|
|
391
|
+
pool.set(query, entry);
|
|
392
|
+
return entry;
|
|
393
|
+
}
|
|
394
|
+
const createPoolEntry = (query) => {
|
|
395
|
+
const mql = matchMedia(query);
|
|
396
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
397
|
+
const handler = (event) => {
|
|
398
|
+
for (const cb of listeners) cb(event.matches);
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
mql,
|
|
402
|
+
listeners,
|
|
403
|
+
handler
|
|
404
|
+
};
|
|
405
|
+
};
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/core/lifecycle/index.ts
|
|
408
|
+
const REDUCED_MOTION_QUERY$1 = "(prefers-reduced-motion: reduce)";
|
|
409
|
+
/**
|
|
410
|
+
* The activation decision for an animation, decoupled from who drives the frames.
|
|
411
|
+
*
|
|
412
|
+
* Composes visibility (`createSight`), reduced motion, and a manual pause into a
|
|
413
|
+
* single `active` / `paused` phase. Use when you own your render loop (WebGL,
|
|
414
|
+
* three.js, a Web Worker, or non-rAF work that should still pause off-screen or
|
|
415
|
+
* under reduced motion). For loops `phase` should drive, use `createLoop` instead.
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* const lifecycle = createLifecycle({
|
|
419
|
+
* element: canvas,
|
|
420
|
+
* onPhaseChange: (phase) => {
|
|
421
|
+
* if (phase === 'active') renderer.start();
|
|
422
|
+
* else renderer.stop();
|
|
423
|
+
* },
|
|
424
|
+
* });
|
|
425
|
+
* // cleanup:
|
|
426
|
+
* lifecycle.stop();
|
|
427
|
+
*/
|
|
428
|
+
function createLifecycle(options) {
|
|
429
|
+
if (typeof document === "undefined") serverContextError("createLifecycle");
|
|
430
|
+
const { element, reducedMotion = "pause", intersectionOptions, start: startMode = "auto", onPhaseChange, signal } = options;
|
|
431
|
+
if (!element) noElementError("createLifecycle");
|
|
432
|
+
let _phase = "idle";
|
|
433
|
+
let _reason = "initial";
|
|
434
|
+
let sightVisible = false;
|
|
435
|
+
let reducedMotionActive = false;
|
|
436
|
+
let manualPaused = false;
|
|
437
|
+
let intentStarted = false;
|
|
438
|
+
let hasBeenActive = false;
|
|
439
|
+
function setPhase(phase, reason) {
|
|
440
|
+
if (_phase === phase && _reason === reason) return;
|
|
441
|
+
_phase = phase;
|
|
442
|
+
_reason = reason;
|
|
443
|
+
onPhaseChange?.(phase, reason);
|
|
444
|
+
}
|
|
445
|
+
/** Highest-priority active pause signal, or null when nothing should pause. */
|
|
446
|
+
function pauseReason() {
|
|
447
|
+
if (reducedMotionActive && reducedMotion === "pause") return "reduced-motion";
|
|
448
|
+
if (!sightVisible) return "sight";
|
|
449
|
+
if (manualPaused) return "manual";
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
function reconcile() {
|
|
453
|
+
if (_phase === "stopped" || !intentStarted) return;
|
|
454
|
+
const reason = pauseReason();
|
|
455
|
+
if (reason) {
|
|
456
|
+
setPhase("paused", reason);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
setPhase("active", hasBeenActive ? "resumed" : "started");
|
|
460
|
+
hasBeenActive = true;
|
|
461
|
+
}
|
|
462
|
+
function onSightChange(phase) {
|
|
463
|
+
sightVisible = phase === "visible";
|
|
464
|
+
reconcile();
|
|
465
|
+
}
|
|
466
|
+
function onReducedMotionChange(matches) {
|
|
467
|
+
reducedMotionActive = matches;
|
|
468
|
+
reconcile();
|
|
469
|
+
}
|
|
470
|
+
const sight = createSight({
|
|
471
|
+
element,
|
|
472
|
+
intersectionOptions,
|
|
473
|
+
onPhaseChange: onSightChange
|
|
474
|
+
});
|
|
475
|
+
let unsubReducedMotion = null;
|
|
476
|
+
if (reducedMotion !== "ignore") {
|
|
477
|
+
reducedMotionActive = readMediaQuery(REDUCED_MOTION_QUERY$1);
|
|
478
|
+
unsubReducedMotion = subscribeMediaQuery(REDUCED_MOTION_QUERY$1, onReducedMotionChange);
|
|
479
|
+
}
|
|
480
|
+
function start() {
|
|
481
|
+
if (_phase === "stopped") return;
|
|
482
|
+
intentStarted = true;
|
|
483
|
+
reconcile();
|
|
484
|
+
}
|
|
485
|
+
function stop() {
|
|
486
|
+
if (_phase === "stopped") return;
|
|
487
|
+
unlinkAbort?.();
|
|
488
|
+
sight.stop();
|
|
489
|
+
unsubReducedMotion?.();
|
|
490
|
+
unsubReducedMotion = null;
|
|
491
|
+
setPhase("stopped", "disposed");
|
|
492
|
+
}
|
|
493
|
+
function pause() {
|
|
494
|
+
if (manualPaused) return;
|
|
495
|
+
manualPaused = true;
|
|
496
|
+
reconcile();
|
|
497
|
+
}
|
|
498
|
+
function resume() {
|
|
499
|
+
if (!manualPaused) return;
|
|
500
|
+
manualPaused = false;
|
|
501
|
+
reconcile();
|
|
502
|
+
}
|
|
503
|
+
let unlinkAbort;
|
|
504
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
505
|
+
if (startMode === "auto") start();
|
|
506
|
+
return {
|
|
507
|
+
start,
|
|
508
|
+
stop,
|
|
509
|
+
pause,
|
|
510
|
+
resume,
|
|
511
|
+
get phase() {
|
|
512
|
+
return _phase;
|
|
513
|
+
},
|
|
514
|
+
get phaseReason() {
|
|
515
|
+
return _reason;
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
//#endregion
|
|
520
|
+
//#region src/core/loop/index.ts
|
|
521
|
+
/** How many consecutive over-budget frames before degrading quality. */
|
|
522
|
+
const OVER_BUDGET_THRESHOLD = 3;
|
|
523
|
+
/** FPS cap applied when quality is degraded (throttle mode). */
|
|
524
|
+
const DEGRADED_FPS_CAP = 30;
|
|
525
|
+
/**
|
|
526
|
+
* In `degraded: 'pause'` mode a frame-budget degrade pauses the loop, so no
|
|
527
|
+
* further frames tick to clear it. Without a timer the loop would stay paused
|
|
528
|
+
* forever after a transient spike. After this delay the loop optimistically
|
|
529
|
+
* un-pauses and re-measures, rescheduling on each subsequent degrade. (Throttle
|
|
530
|
+
* mode keeps ticking, so it does not use this.)
|
|
531
|
+
*/
|
|
532
|
+
const RECOVERY_RETRY_MS = 2e3;
|
|
533
|
+
/**
|
|
534
|
+
* Lifecycle-aware animation loop composing ticker, visibility, and reduced motion.
|
|
535
|
+
*
|
|
536
|
+
* Pass an element and get a loop that pauses when the element leaves the viewport
|
|
537
|
+
* or the tab is backgrounded, resumes when it returns, and cleans up with `stop()`.
|
|
538
|
+
*
|
|
539
|
+
* @remarks
|
|
540
|
+
* The loop is signal-driven and exposes only `start()` and `stop()`. There is no
|
|
541
|
+
* imperative `pause()`/`resume()`. Pausing is decided by visibility, reduced
|
|
542
|
+
* motion, and quality, so an imperative pause would compete with those signals.
|
|
543
|
+
* For manual control, use `useLoop`'s `enabled` option (React) or `createLifecycle`,
|
|
544
|
+
* which exposes `pause()`/`resume()` for loops you drive yourself.
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* const loop = createLoop({
|
|
548
|
+
* element: el,
|
|
549
|
+
* onTick: (frame) => draw(ctx, frame),
|
|
550
|
+
* });
|
|
551
|
+
* // cleanup:
|
|
552
|
+
* loop.stop();
|
|
553
|
+
*/
|
|
554
|
+
function createLoop(options) {
|
|
555
|
+
if (typeof requestAnimationFrame === "undefined") serverContextError("createLoop");
|
|
556
|
+
const { element, onTick, fps: baseFps, reducedMotion = "pause", degraded = "throttle", degradedFps: configuredDegradedFps, intersectionOptions, start: startMode = "auto", onPhaseChange, signal } = options;
|
|
557
|
+
if (!element) noElementError("createLoop");
|
|
558
|
+
const degradedFps = degraded === "throttle" ? configuredDegradedFps : void 0;
|
|
559
|
+
let _phase = "idle";
|
|
560
|
+
let _reason = "initial";
|
|
561
|
+
let _quality = "full";
|
|
562
|
+
let _qualityReason;
|
|
563
|
+
let intentStarted = false;
|
|
564
|
+
let overBudgetCount = 0;
|
|
565
|
+
let ticker = null;
|
|
566
|
+
let focusDegraded = false;
|
|
567
|
+
let recoveryTimer = null;
|
|
568
|
+
function setPhase(phase, reason) {
|
|
569
|
+
if (_phase === phase && _reason === reason) return;
|
|
570
|
+
_phase = phase;
|
|
571
|
+
_reason = reason;
|
|
572
|
+
onPhaseChange?.(phase, reason);
|
|
573
|
+
}
|
|
574
|
+
function setQuality(quality, reason) {
|
|
575
|
+
const changed = _quality !== quality;
|
|
576
|
+
_quality = quality;
|
|
577
|
+
_qualityReason = reason;
|
|
578
|
+
if (!changed) return;
|
|
579
|
+
if (degraded === "throttle") {
|
|
580
|
+
if (ticker && _phase === "running") queueMicrotask(rebuildTicker);
|
|
581
|
+
} else if (degraded === "pause") reconcile();
|
|
582
|
+
}
|
|
583
|
+
/** Evaluate all quality signals and pick the highest-priority active one. */
|
|
584
|
+
function reconcileQuality() {
|
|
585
|
+
if (focusDegraded) {
|
|
586
|
+
setQuality("degraded", "unfocused");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (overBudgetCount >= OVER_BUDGET_THRESHOLD) {
|
|
590
|
+
setQuality("degraded", "frame-budget");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
setQuality("full");
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Un-pause and re-measure after a pause-mode frame-budget degrade. If frames
|
|
597
|
+
* are still over budget, the next degrade reschedules this.
|
|
598
|
+
*/
|
|
599
|
+
function scheduleBudgetRecovery() {
|
|
600
|
+
if (recoveryTimer !== null) return;
|
|
601
|
+
recoveryTimer = setTimeout(() => {
|
|
602
|
+
recoveryTimer = null;
|
|
603
|
+
overBudgetCount = 0;
|
|
604
|
+
reconcileQuality();
|
|
605
|
+
}, RECOVERY_RETRY_MS);
|
|
606
|
+
}
|
|
607
|
+
function clearBudgetRecovery() {
|
|
608
|
+
if (recoveryTimer === null) return;
|
|
609
|
+
clearTimeout(recoveryTimer);
|
|
610
|
+
recoveryTimer = null;
|
|
611
|
+
}
|
|
612
|
+
function getEffectiveFps() {
|
|
613
|
+
if (_quality !== "degraded") return baseFps;
|
|
614
|
+
const cap = degradedFps ?? DEGRADED_FPS_CAP;
|
|
615
|
+
if (baseFps === void 0) return cap;
|
|
616
|
+
return Math.min(baseFps, cap);
|
|
617
|
+
}
|
|
618
|
+
function destroyTicker() {
|
|
619
|
+
if (!ticker) return;
|
|
620
|
+
ticker.stop();
|
|
621
|
+
ticker = null;
|
|
622
|
+
}
|
|
623
|
+
function buildTicker() {
|
|
624
|
+
const targetFps = getEffectiveFps();
|
|
625
|
+
const budget = 1e3 / (targetFps ?? 60);
|
|
626
|
+
destroyTicker();
|
|
627
|
+
ticker = createTicker({
|
|
628
|
+
fps: targetFps,
|
|
629
|
+
onTick: (frame) => {
|
|
630
|
+
checkFrameBudget(frame.delta, budget);
|
|
631
|
+
onTick(frame);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
ticker.start();
|
|
635
|
+
}
|
|
636
|
+
function rebuildTicker() {
|
|
637
|
+
if (_phase !== "running" || !ticker) return;
|
|
638
|
+
buildTicker();
|
|
639
|
+
}
|
|
640
|
+
function checkFrameBudget(delta, budget) {
|
|
641
|
+
if (delta <= budget * 1.5) {
|
|
642
|
+
overBudgetCount = 0;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
overBudgetCount++;
|
|
646
|
+
if (overBudgetCount < OVER_BUDGET_THRESHOLD) return;
|
|
647
|
+
reconcileQuality();
|
|
648
|
+
if (degraded === "pause") scheduleBudgetRecovery();
|
|
649
|
+
}
|
|
650
|
+
/** Check if any signal requires the loop to be paused. */
|
|
651
|
+
function shouldPause() {
|
|
652
|
+
if (lifecycle.phase === "paused") return lifecycle.phaseReason;
|
|
653
|
+
if (degraded === "pause" && _quality === "degraded") return "degraded";
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
/** Evaluate all signals and transition to the correct phase. */
|
|
657
|
+
function reconcile() {
|
|
658
|
+
if (_phase === "stopped" || !intentStarted) return;
|
|
659
|
+
const pauseReason = shouldPause();
|
|
660
|
+
if (pauseReason) {
|
|
661
|
+
if (ticker && _phase === "running") ticker.pause();
|
|
662
|
+
setPhase("paused", pauseReason);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (!ticker) {
|
|
666
|
+
buildTicker();
|
|
667
|
+
setPhase("running", _reason === "initial" ? "started" : "resumed");
|
|
668
|
+
} else if (_phase === "paused") {
|
|
669
|
+
ticker.resume();
|
|
670
|
+
setPhase("running", "resumed");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function onFocusChange() {
|
|
674
|
+
focusDegraded = !document.hasFocus();
|
|
675
|
+
reconcileQuality();
|
|
676
|
+
}
|
|
677
|
+
const lifecycle = createLifecycle({
|
|
678
|
+
element,
|
|
679
|
+
reducedMotion: reducedMotion === "pause" ? "pause" : "ignore",
|
|
680
|
+
intersectionOptions,
|
|
681
|
+
start: "manual",
|
|
682
|
+
onPhaseChange: reconcile
|
|
683
|
+
});
|
|
684
|
+
const unsubFocus = subscribeFocusTracking(onFocusChange);
|
|
685
|
+
function start() {
|
|
686
|
+
if (_phase === "stopped") return;
|
|
687
|
+
intentStarted = true;
|
|
688
|
+
lifecycle.start();
|
|
689
|
+
reconcile();
|
|
690
|
+
}
|
|
691
|
+
function stop() {
|
|
692
|
+
if (_phase === "stopped") return;
|
|
693
|
+
unlinkAbort?.();
|
|
694
|
+
clearBudgetRecovery();
|
|
695
|
+
destroyTicker();
|
|
696
|
+
lifecycle.stop();
|
|
697
|
+
unsubFocus();
|
|
698
|
+
setPhase("stopped", "disposed");
|
|
699
|
+
}
|
|
700
|
+
let unlinkAbort;
|
|
701
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
702
|
+
if (startMode === "auto") start();
|
|
703
|
+
return {
|
|
704
|
+
start,
|
|
705
|
+
stop,
|
|
706
|
+
get phase() {
|
|
707
|
+
return _phase;
|
|
708
|
+
},
|
|
709
|
+
get phaseReason() {
|
|
710
|
+
return _reason;
|
|
711
|
+
},
|
|
712
|
+
get quality() {
|
|
713
|
+
return _quality;
|
|
714
|
+
},
|
|
715
|
+
get qualityReason() {
|
|
716
|
+
return _qualityReason;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function subscribeFocusTracking(onChange) {
|
|
721
|
+
window.addEventListener("focus", onChange);
|
|
722
|
+
window.addEventListener("blur", onChange);
|
|
723
|
+
return () => {
|
|
724
|
+
window.removeEventListener("focus", onChange);
|
|
725
|
+
window.removeEventListener("blur", onChange);
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
//#region src/core/scroll-progress/index.ts
|
|
730
|
+
const DEFAULT_STEPS = 20;
|
|
731
|
+
const thresholdCache = /* @__PURE__ */ new Map();
|
|
732
|
+
function buildThresholds(steps) {
|
|
733
|
+
const cached = thresholdCache.get(steps);
|
|
734
|
+
if (cached) return cached;
|
|
735
|
+
const thresholds = [];
|
|
736
|
+
for (let i = 0; i <= steps; i++) thresholds.push(i / steps);
|
|
737
|
+
thresholdCache.set(steps, thresholds);
|
|
738
|
+
return thresholds;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Observe what fraction of an element is visible in the viewport (0–1).
|
|
742
|
+
*
|
|
743
|
+
* Uses the shared IntersectionObserver pool with multi-threshold options.
|
|
744
|
+
* Multiple instances with the same `steps` share a single IO.
|
|
745
|
+
*
|
|
746
|
+
* @example
|
|
747
|
+
* const progress = createScrollProgress({
|
|
748
|
+
* element: el,
|
|
749
|
+
* onProgress: (ratio) => {
|
|
750
|
+
* el.style.opacity = String(ratio);
|
|
751
|
+
* },
|
|
752
|
+
* });
|
|
753
|
+
* // cleanup:
|
|
754
|
+
* progress.stop();
|
|
755
|
+
*/
|
|
756
|
+
function createScrollProgress(options) {
|
|
757
|
+
if (typeof IntersectionObserver === "undefined") serverContextError("createScrollProgress");
|
|
758
|
+
const { element, onProgress, steps = DEFAULT_STEPS, root, rootMargin, signal } = options;
|
|
759
|
+
if (!element) noElementError("createScrollProgress");
|
|
760
|
+
let _ratio = 0;
|
|
761
|
+
let stopped = false;
|
|
762
|
+
const unobserve = observeIntersection({
|
|
763
|
+
element,
|
|
764
|
+
onIntersect: (entry) => {
|
|
765
|
+
const newRatio = entry.intersectionRatio;
|
|
766
|
+
if (newRatio === _ratio) return;
|
|
767
|
+
_ratio = newRatio;
|
|
768
|
+
onProgress(newRatio);
|
|
769
|
+
},
|
|
770
|
+
root,
|
|
771
|
+
rootMargin,
|
|
772
|
+
threshold: buildThresholds(steps)
|
|
773
|
+
});
|
|
774
|
+
let unlinkAbort;
|
|
775
|
+
function stop() {
|
|
776
|
+
if (stopped) return;
|
|
777
|
+
stopped = true;
|
|
778
|
+
unlinkAbort?.();
|
|
779
|
+
unobserve();
|
|
780
|
+
}
|
|
781
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
782
|
+
return {
|
|
783
|
+
get ratio() {
|
|
784
|
+
return _ratio;
|
|
785
|
+
},
|
|
786
|
+
stop
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/core/render-state/index.ts
|
|
791
|
+
/**
|
|
792
|
+
* Report whether the browser is rendering an element or skipping it under
|
|
793
|
+
* `content-visibility`. Listens to the `contentvisibilityautostatechange`
|
|
794
|
+
* event, the browser's ground-truth paint decision.
|
|
795
|
+
*
|
|
796
|
+
* Use it to pause raw, non-phase work (a hand-written rAF loop, `setInterval`,
|
|
797
|
+
* expensive effects) when a `Defer` subtree stops painting. phase's own loops
|
|
798
|
+
* already self-pause off-screen, so they do not need this.
|
|
799
|
+
*
|
|
800
|
+
* Listening and reacting has zero layout effect. It never breaks the
|
|
801
|
+
* no-layout-shift guarantee of `content-visibility`.
|
|
802
|
+
*
|
|
803
|
+
* @example
|
|
804
|
+
* const render = createRenderState({
|
|
805
|
+
* element: el,
|
|
806
|
+
* onPhaseChange: (phase) => phase === 'skipped' ? clock.pause() : clock.resume(),
|
|
807
|
+
* });
|
|
808
|
+
* // cleanup:
|
|
809
|
+
* render.stop();
|
|
810
|
+
*
|
|
811
|
+
* @remarks
|
|
812
|
+
* Where `content-visibility` is unsupported, `phase` stays `'rendered'`.
|
|
813
|
+
*
|
|
814
|
+
* Per the CSS Containment spec, `ResizeObserver` callbacks pause for elements
|
|
815
|
+
* inside a skipped `content-visibility: auto` subtree. Use this primitive to
|
|
816
|
+
* detect that transition when your code depends on size observations resuming.
|
|
817
|
+
*/
|
|
818
|
+
function createRenderState(options) {
|
|
819
|
+
if (typeof document === "undefined") serverContextError("createRenderState");
|
|
820
|
+
const { element, onPhaseChange, signal } = options;
|
|
821
|
+
if (!element) noElementError("createRenderState");
|
|
822
|
+
let _phase = "rendered";
|
|
823
|
+
let stopped = false;
|
|
824
|
+
function onStateChange(event) {
|
|
825
|
+
if (stopped) return;
|
|
826
|
+
const next = event.skipped ? "skipped" : "rendered";
|
|
827
|
+
if (next === _phase) return;
|
|
828
|
+
_phase = next;
|
|
829
|
+
onPhaseChange?.(_phase);
|
|
830
|
+
}
|
|
831
|
+
element.addEventListener("contentvisibilityautostatechange", onStateChange);
|
|
832
|
+
let unlinkAbort;
|
|
833
|
+
function stop() {
|
|
834
|
+
if (stopped) return;
|
|
835
|
+
stopped = true;
|
|
836
|
+
unlinkAbort?.();
|
|
837
|
+
element.removeEventListener("contentvisibilityautostatechange", onStateChange);
|
|
838
|
+
}
|
|
839
|
+
unlinkAbort = linkAbortSignal(signal, stop);
|
|
840
|
+
return {
|
|
841
|
+
get phase() {
|
|
842
|
+
return _phase;
|
|
843
|
+
},
|
|
844
|
+
stop
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/core/_internal/pool/dpr.ts
|
|
849
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
850
|
+
let currentDpr = 1;
|
|
851
|
+
let mql = null;
|
|
852
|
+
let handler = null;
|
|
853
|
+
/**
|
|
854
|
+
* Subscribe to devicePixelRatio changes (e.g. user drags window between monitors).
|
|
855
|
+
*
|
|
856
|
+
* Uses a single shared `matchMedia` query that re-subscribes on every DPR change,
|
|
857
|
+
* so chained monitor switches (A -> B -> C) are all caught.
|
|
858
|
+
*
|
|
859
|
+
* @returns Cleanup function that removes the subscriber.
|
|
860
|
+
*/
|
|
861
|
+
function subscribeDpr(callback) {
|
|
862
|
+
listeners.add(callback);
|
|
863
|
+
if (listeners.size === 1) bind();
|
|
864
|
+
let disposed = false;
|
|
865
|
+
return () => {
|
|
866
|
+
if (disposed) return;
|
|
867
|
+
disposed = true;
|
|
868
|
+
listeners.delete(callback);
|
|
869
|
+
if (listeners.size === 0) unbind();
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
/** Read the current devicePixelRatio. */
|
|
873
|
+
function readDpr() {
|
|
874
|
+
if (typeof window === "undefined") return 1;
|
|
875
|
+
return window.devicePixelRatio || 1;
|
|
876
|
+
}
|
|
877
|
+
function bind() {
|
|
878
|
+
if (typeof matchMedia === "undefined") return;
|
|
879
|
+
currentDpr = window.devicePixelRatio || 1;
|
|
880
|
+
mql = matchMedia(`(resolution: ${currentDpr}dppx)`);
|
|
881
|
+
handler = onDprChange;
|
|
882
|
+
mql.addEventListener("change", handler);
|
|
883
|
+
}
|
|
884
|
+
function unbind() {
|
|
885
|
+
if (mql && handler) mql.removeEventListener("change", handler);
|
|
886
|
+
mql = null;
|
|
887
|
+
handler = null;
|
|
888
|
+
}
|
|
889
|
+
function onDprChange() {
|
|
890
|
+
const newDpr = window.devicePixelRatio || 1;
|
|
891
|
+
if (newDpr === currentDpr) return;
|
|
892
|
+
currentDpr = newDpr;
|
|
893
|
+
unbind();
|
|
894
|
+
bind();
|
|
895
|
+
for (const cb of listeners) cb(newDpr);
|
|
896
|
+
}
|
|
897
|
+
//#endregion
|
|
898
|
+
//#region src/core/idle/index.ts
|
|
899
|
+
/** Fallback delay when `requestIdleCallback` is unavailable (e.g. Safari). */
|
|
900
|
+
const FALLBACK_DELAY = 1;
|
|
901
|
+
/**
|
|
902
|
+
* Run a callback once the browser is idle. Wraps `requestIdleCallback`, falling
|
|
903
|
+
* back to a near-immediate `setTimeout` where it is unavailable (Safari).
|
|
904
|
+
*
|
|
905
|
+
* Returns a cancel function. Calling it before the callback runs prevents it.
|
|
906
|
+
*
|
|
907
|
+
* @example
|
|
908
|
+
* const cancel = whenIdle(() => warmCache(), { timeout: 2000 });
|
|
909
|
+
* // later, if no longer needed:
|
|
910
|
+
* cancel();
|
|
911
|
+
*/
|
|
912
|
+
function whenIdle(callback, options) {
|
|
913
|
+
if (typeof window === "undefined") serverContextError("whenIdle");
|
|
914
|
+
const { timeout, signal } = options ?? {};
|
|
915
|
+
if (signal?.aborted) return () => {};
|
|
916
|
+
let cancel;
|
|
917
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
918
|
+
const cancelIdle = window.cancelIdleCallback.bind(window);
|
|
919
|
+
const handle = window.requestIdleCallback(() => callback(), timeout === void 0 ? void 0 : { timeout });
|
|
920
|
+
cancel = () => cancelIdle(handle);
|
|
921
|
+
} else {
|
|
922
|
+
const handle = setTimeout(callback, FALLBACK_DELAY);
|
|
923
|
+
cancel = () => clearTimeout(handle);
|
|
924
|
+
}
|
|
925
|
+
const unlinkAbort = linkAbortSignal(signal, cancel);
|
|
926
|
+
return () => {
|
|
927
|
+
unlinkAbort();
|
|
928
|
+
cancel();
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
//#endregion
|
|
932
|
+
//#region src/core/reduced-motion/index.ts
|
|
933
|
+
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
934
|
+
/**
|
|
935
|
+
* Synchronous check for `prefers-reduced-motion: reduce`.
|
|
936
|
+
*
|
|
937
|
+
* Returns `false` on the server (no `matchMedia`). On the client, reads from
|
|
938
|
+
* the shared MQL pool so the underlying `MediaQueryList` is reused across
|
|
939
|
+
* all callers.
|
|
940
|
+
*/
|
|
941
|
+
function prefersReducedMotion() {
|
|
942
|
+
if (typeof matchMedia === "undefined") return false;
|
|
943
|
+
return readMediaQuery(REDUCED_MOTION_QUERY);
|
|
944
|
+
}
|
|
945
|
+
//#endregion
|
|
946
|
+
export { missingContextError as _, subscribeDpr as a, createLoop as c, subscribeMediaQuery as d, createSight as f, isPhaseError as g, invalidDurationError as h, readDpr as i, createLifecycle as l, PhaseError as m, prefersReducedMotion as n, createRenderState as o, createTicker as p, whenIdle as r, createScrollProgress as s, REDUCED_MOTION_QUERY as t, readMediaQuery as u, serverContextError as v, linkAbortSignal as y };
|
|
947
|
+
|
|
948
|
+
//# sourceMappingURL=reduced-motion-CEJtegNG.js.map
|