retake-dev 0.4.0
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 +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- package/types/index.d.ts +111 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Virtual clock. Every app-visible notion of time (timers, rAF, performance.now,
|
|
2
|
+
// Date) reads from `clock.now`, which only the engine advances. All runtime
|
|
3
|
+
// files are concatenated into one IIFE by the Vite plugin, in filename order.
|
|
4
|
+
|
|
5
|
+
// W is the window the runtime patches (its timers, Date, network...), so its
|
|
6
|
+
// globals take shapes lib.dom doesn't describe: untyped for the type check.
|
|
7
|
+
const W = /** @type {any} */ (window)
|
|
8
|
+
/** @type {any} the runtime API (70-boot.js fills it in) */
|
|
9
|
+
const PT = (W.__retake = { emit() {} })
|
|
10
|
+
const real = {
|
|
11
|
+
setTimeout: W.setTimeout.bind(W),
|
|
12
|
+
clearTimeout: W.clearTimeout.bind(W),
|
|
13
|
+
setInterval: W.setInterval.bind(W),
|
|
14
|
+
raf: W.requestAnimationFrame.bind(W),
|
|
15
|
+
perfNow: performance.now.bind(performance),
|
|
16
|
+
Date: W.Date,
|
|
17
|
+
random: Math.random,
|
|
18
|
+
fetch: W.fetch.bind(W),
|
|
19
|
+
idle: W.requestIdleCallback ? W.requestIdleCallback.bind(W) : null,
|
|
20
|
+
local: W.localStorage,
|
|
21
|
+
session: W.sessionStorage,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY = "retake:"
|
|
25
|
+
// performance.now() and rAF timestamps are offset so they never read 0 (lots of
|
|
26
|
+
// code treats a 0 timestamp as "unset").
|
|
27
|
+
const PERF_BASE = 1000
|
|
28
|
+
// Timers created by the dev server itself must keep real time, or HMR stalls
|
|
29
|
+
// whenever the clock is paused. (Turbopack's HMR client is a chunk of its own.)
|
|
30
|
+
const EXEMPT = /@vite\/client|@react-refresh|vite\/dist\/client|dev_hmr-client_hmr-client/
|
|
31
|
+
// The dev server's own traffic, by URL (RT.exemptUrls, picked by the server
|
|
32
|
+
// for the framework): its HMR socket, overlay and hot-update requests. A call
|
|
33
|
+
// stack can't always tell (Turbopack bundles Next's router and its HMR client
|
|
34
|
+
// together), a URL can. Exempt traffic is never recorded, held or replayed.
|
|
35
|
+
let exemptUrlRe = null
|
|
36
|
+
try {
|
|
37
|
+
if (RT.exemptUrls && RT.exemptUrls.length) exemptUrlRe = new RegExp(RT.exemptUrls.join("|"))
|
|
38
|
+
} catch {}
|
|
39
|
+
function isExemptUrl(url) {
|
|
40
|
+
if (!exemptUrlRe) return false
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(String(url), location.href)
|
|
43
|
+
return exemptUrlRe.test(u.pathname + u.search)
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clock = { now: 0, rate: 1, playing: false, seeking: false, booted: false }
|
|
50
|
+
|
|
51
|
+
// ---- timers ---------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const timers = new Map()
|
|
54
|
+
let timerSeq = 1e9
|
|
55
|
+
|
|
56
|
+
function isExempt() {
|
|
57
|
+
return EXEMPT.test(new Error().stack || "")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// While rebuilding, React's per-element console tasks (async stack tagging
|
|
61
|
+
// for devtools) are skipped. Its per-element Error stacks are NOT: notes read
|
|
62
|
+
// an element's source file:line from them, and notes are made in rebuilt frames.
|
|
63
|
+
const realCreateTask = console.createTask
|
|
64
|
+
if (realCreateTask) {
|
|
65
|
+
const noTask = { run: (fn) => fn() }
|
|
66
|
+
console.createTask = function (name) {
|
|
67
|
+
return clock.seeking ? noTask : realCreateTask.call(console, name)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function addTimer(fn, delay, args, repeat) {
|
|
72
|
+
const id = timerSeq++
|
|
73
|
+
const d = Math.max(repeat ? 4 : 0, Number(delay) || 0)
|
|
74
|
+
timers.set(id, { id, fn, args, due: clock.now + d, delay: d, repeat })
|
|
75
|
+
return id
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nextTimer() {
|
|
79
|
+
let best = null
|
|
80
|
+
for (const t of timers.values()) if (!best || t.due < best.due) best = t
|
|
81
|
+
return best
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Debug aid: `window.__retakeTrace = []` before load collects a log of
|
|
85
|
+
// what ran when, to diff a recording against its replay.
|
|
86
|
+
const trace = (...entry) => W.__retakeTrace && W.__retakeTrace.push([clock.now, ...entry])
|
|
87
|
+
|
|
88
|
+
function runTimer(t) {
|
|
89
|
+
trace("timer", t.due, String(t.fn).slice(0, 80))
|
|
90
|
+
if (t.repeat) t.due += t.delay
|
|
91
|
+
else timers.delete(t.id)
|
|
92
|
+
safeCall(t.fn, t.args)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeCall(fn, args) {
|
|
96
|
+
try {
|
|
97
|
+
fn.apply(W, args)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Rethrow outside our loop so the app's error overlay still sees it.
|
|
100
|
+
real.setTimeout(() => {
|
|
101
|
+
throw err
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
W.setTimeout = function (fn, delay, ...args) {
|
|
107
|
+
if (typeof fn !== "function" || isExempt()) return real.setTimeout(fn, delay, ...args)
|
|
108
|
+
return addTimer(fn, delay, args, false)
|
|
109
|
+
}
|
|
110
|
+
W.setInterval = function (fn, delay, ...args) {
|
|
111
|
+
if (typeof fn !== "function" || isExempt()) return real.setInterval(fn, delay, ...args)
|
|
112
|
+
return addTimer(fn, delay, args, true)
|
|
113
|
+
}
|
|
114
|
+
W.clearTimeout = W.clearInterval = function (id) {
|
|
115
|
+
if (timers.has(id)) timers.delete(id)
|
|
116
|
+
else real.clearTimeout(id)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// requestIdleCallback runs on the virtual clock too: "idle" is right after
|
|
120
|
+
// the current moment's work.
|
|
121
|
+
if (W.requestIdleCallback) {
|
|
122
|
+
W.requestIdleCallback = function (cb, opts) {
|
|
123
|
+
if (typeof cb !== "function" || isExempt()) return real.setTimeout(cb, 1)
|
|
124
|
+
return addTimer(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1, [], false)
|
|
125
|
+
}
|
|
126
|
+
W.cancelIdleCallback = function (id) {
|
|
127
|
+
W.clearTimeout(id)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- animation frames -----------------------------------------------------
|
|
132
|
+
|
|
133
|
+
let rafQueue = new Map()
|
|
134
|
+
let rafSeq = 1
|
|
135
|
+
|
|
136
|
+
W.requestAnimationFrame = function (cb) {
|
|
137
|
+
if (W.__retakeTrace) trace("req", (new Error().stack || "").split("\n")[2])
|
|
138
|
+
const id = rafSeq++
|
|
139
|
+
rafQueue.set(id, cb)
|
|
140
|
+
return id
|
|
141
|
+
}
|
|
142
|
+
W.cancelAnimationFrame = function (id) {
|
|
143
|
+
rafQueue.delete(id)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runRaf() {
|
|
147
|
+
if (!rafQueue.size) return
|
|
148
|
+
const queue = rafQueue
|
|
149
|
+
rafQueue = new Map()
|
|
150
|
+
trace("raf", queue.size)
|
|
151
|
+
for (const cb of queue.values()) safeCall(cb, [clock.now + PERF_BASE])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- performance.now / Date -----------------------------------------------
|
|
155
|
+
|
|
156
|
+
performance.now = () => clock.now + PERF_BASE
|
|
157
|
+
try {
|
|
158
|
+
Object.defineProperty(document.timeline, "currentTime", {
|
|
159
|
+
get: () => clock.now + PERF_BASE,
|
|
160
|
+
configurable: true,
|
|
161
|
+
})
|
|
162
|
+
} catch {}
|
|
163
|
+
|
|
164
|
+
let epoch = real.Date.now()
|
|
165
|
+
function VDate(...args) {
|
|
166
|
+
if (!new.target) return new real.Date(epoch + clock.now).toString()
|
|
167
|
+
return args.length ? new real.Date(...args) : new real.Date(epoch + clock.now)
|
|
168
|
+
}
|
|
169
|
+
VDate.prototype = real.Date.prototype
|
|
170
|
+
VDate.now = () => epoch + clock.now
|
|
171
|
+
VDate.parse = real.Date.parse
|
|
172
|
+
VDate.UTC = real.Date.UTC
|
|
173
|
+
W.Date = VDate
|
|
174
|
+
|
|
175
|
+
// ---- seeded randomness ----------------------------------------------------
|
|
176
|
+
|
|
177
|
+
let seed = 0
|
|
178
|
+
let byteState = 0
|
|
179
|
+
function seedRandom(s) {
|
|
180
|
+
seed = s >>> 0
|
|
181
|
+
byteState = (seed ^ 0x9e3779b9) >>> 0
|
|
182
|
+
let state = seed
|
|
183
|
+
Math.random = function () {
|
|
184
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
185
|
+
let x = state
|
|
186
|
+
x = Math.imul(x ^ (x >>> 15), x | 1)
|
|
187
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
|
|
188
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// crypto.getRandomValues from its own seeded stream, so ids made with it
|
|
192
|
+
// (nanoid, uuid v4) come out the same on replay without shifting Math.random.
|
|
193
|
+
function nextByteWord() {
|
|
194
|
+
byteState = (byteState + 0x6d2b79f5) >>> 0
|
|
195
|
+
let x = byteState
|
|
196
|
+
x = Math.imul(x ^ (x >>> 15), x | 1)
|
|
197
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61)
|
|
198
|
+
return (x ^ (x >>> 14)) >>> 0
|
|
199
|
+
}
|
|
200
|
+
if (W.crypto && crypto.getRandomValues) {
|
|
201
|
+
const realGetRandomValues = crypto.getRandomValues.bind(crypto)
|
|
202
|
+
crypto.getRandomValues = function (arr) {
|
|
203
|
+
if (!arr || !ArrayBuffer.isView(arr) || isExempt()) return realGetRandomValues(arr)
|
|
204
|
+
if (arr.byteLength > 65536) throw new DOMException("The ArrayBufferView's byte length exceeds 65536", "QuotaExceededError")
|
|
205
|
+
const bytes = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength)
|
|
206
|
+
for (let i = 0; i < bytes.length; i += 4) {
|
|
207
|
+
const w = nextByteWord()
|
|
208
|
+
for (let j = 0; j < 4 && i + j < bytes.length; j++) bytes[i + j] = (w >>> (j * 8)) & 255
|
|
209
|
+
}
|
|
210
|
+
return arr
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (W.crypto && crypto.randomUUID) {
|
|
214
|
+
crypto.randomUUID = /** @type {Crypto["randomUUID"]} */ (function () {
|
|
215
|
+
const h = () => Math.floor(Math.random() * 16).toString(16)
|
|
216
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) =>
|
|
217
|
+
c === "x" ? h() : ((Math.random() * 4) | 8).toString(16),
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- event-loop yield (no 4ms clamp, unlike setTimeout) -------------------
|
|
223
|
+
|
|
224
|
+
const yieldChannel = new MessageChannel()
|
|
225
|
+
const yieldQueue = []
|
|
226
|
+
yieldChannel.port1.onmessage = () => yieldQueue.shift()()
|
|
227
|
+
function yieldTask() {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
yieldQueue.push(resolve)
|
|
230
|
+
yieldChannel.port2.postMessage(0)
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
// React's scheduler (and similar) queue work through MessageChannel. Counting
|
|
234
|
+
// in-flight messages on app channels tells us when the app has gone idle, so
|
|
235
|
+
// time only advances once the previous moment's renders and effects are done.
|
|
236
|
+
// Recording and replay both wait this way, which is what makes replay exact.
|
|
237
|
+
let appMessages = 0
|
|
238
|
+
const appPorts = new WeakSet()
|
|
239
|
+
const RealChannel = W.MessageChannel
|
|
240
|
+
W.MessageChannel = function MessageChannel() {
|
|
241
|
+
const ch = new RealChannel()
|
|
242
|
+
appPorts.add(ch.port1)
|
|
243
|
+
appPorts.add(ch.port2)
|
|
244
|
+
return ch
|
|
245
|
+
}
|
|
246
|
+
W.MessageChannel.prototype = RealChannel.prototype
|
|
247
|
+
const MPP = MessagePort.prototype
|
|
248
|
+
const portPost = MPP.postMessage
|
|
249
|
+
MPP.postMessage = function (...args) {
|
|
250
|
+
if (appPorts.has(this)) appMessages++
|
|
251
|
+
return portPost.apply(this, args)
|
|
252
|
+
}
|
|
253
|
+
const portOnMessage = Object.getOwnPropertyDescriptor(MPP, "onmessage")
|
|
254
|
+
Object.defineProperty(MPP, "onmessage", {
|
|
255
|
+
configurable: true,
|
|
256
|
+
get() {
|
|
257
|
+
return portOnMessage.get.call(this)
|
|
258
|
+
},
|
|
259
|
+
set(fn) {
|
|
260
|
+
if (!appPorts.has(this) || typeof fn !== "function") return portOnMessage.set.call(this, fn)
|
|
261
|
+
portOnMessage.set.call(this, function (e) {
|
|
262
|
+
if (appMessages > 0) appMessages--
|
|
263
|
+
return fn.call(this, e)
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const stats = { settles: 0, stuck: 0, yields: 0, idbWaits: 0, fastSettles: 0 }
|
|
269
|
+
// Set when a replay hands the app something that may finish in real time (a
|
|
270
|
+
// network reply, a worker message, media readiness): the next settle then
|
|
271
|
+
// yields a real task, whatever else is true.
|
|
272
|
+
let dispatchedSinceYield = true
|
|
273
|
+
// Wait until the app is idle: no pending MessageChannel work (React's
|
|
274
|
+
// scheduler) and no IndexedDB work or replayed script load in flight (they
|
|
275
|
+
// answer in real time).
|
|
276
|
+
// fastOk (replay only): when the app has nothing queued, a few microtask turns
|
|
277
|
+
// do instead of a task round trip, which is most of a canvas-heavy page's
|
|
278
|
+
// rebuild time. The dock's `fastReplay = false` turns it off (a kill switch).
|
|
279
|
+
async function settle(fastOk) {
|
|
280
|
+
stats.settles++
|
|
281
|
+
if (fastOk && clock.seeking && !dispatchedSinceYield && appMessages === 0 && !otherBusy() && !(shell && shell.fastReplay === false)) {
|
|
282
|
+
for (let i = 0; i < 8; i++) await null
|
|
283
|
+
if (appMessages === 0) {
|
|
284
|
+
stats.fastSettles++
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const deadline = real.perfNow() + 2000
|
|
289
|
+
for (let round = 0; round < 20; round++) {
|
|
290
|
+
let i = 0
|
|
291
|
+
for (; i < 60; i++) {
|
|
292
|
+
await yieldTask()
|
|
293
|
+
dispatchedSinceYield = false
|
|
294
|
+
stats.yields++
|
|
295
|
+
if (appMessages === 0) break
|
|
296
|
+
}
|
|
297
|
+
if (i === 60) {
|
|
298
|
+
stats.stuck++
|
|
299
|
+
appMessages = 0 // lost count (a port we can't see); don't stall forever
|
|
300
|
+
}
|
|
301
|
+
if (!otherBusy() || real.perfNow() > deadline) return
|
|
302
|
+
stats.idbWaits++
|
|
303
|
+
while (otherBusy() && real.perfNow() < deadline) await new Promise((r) => real.setTimeout(r, 0))
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Work that answers in real time and that time waits for: IndexedDB requests
|
|
307
|
+
// (32-state.js) and, replaying, scripts the recording loaded at this moment (36-scripts.js).
|
|
308
|
+
const otherBusy = () => (typeof idbBusy !== "undefined" && idbBusy > 0) || (typeof scriptBusy !== "undefined" && scriptBusy > 0)
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// Web Animations + CSS animations/transitions. Every animation is held paused
|
|
2
|
+
// underneath and its currentTime is driven from the virtual clock, so pausing,
|
|
3
|
+
// slow motion and seeking all apply. App-facing calls (play, pause, startTime…)
|
|
4
|
+
// are translated so libraries like motion still see a normal running animation.
|
|
5
|
+
|
|
6
|
+
const managed = new Map() // Animation -> { t, v, userPaused, done }
|
|
7
|
+
const AP = Animation.prototype
|
|
8
|
+
const orig = {
|
|
9
|
+
play: AP.play,
|
|
10
|
+
pause: AP.pause,
|
|
11
|
+
finish: AP.finish,
|
|
12
|
+
cancel: AP.cancel,
|
|
13
|
+
reverse: AP.reverse,
|
|
14
|
+
updatePlaybackRate: AP.updatePlaybackRate,
|
|
15
|
+
currentTime: Object.getOwnPropertyDescriptor(AP, "currentTime"),
|
|
16
|
+
startTime: Object.getOwnPropertyDescriptor(AP, "startTime"),
|
|
17
|
+
playbackRate: Object.getOwnPropertyDescriptor(AP, "playbackRate"),
|
|
18
|
+
playState: Object.getOwnPropertyDescriptor(AP, "playState"),
|
|
19
|
+
animate: Element.prototype.animate,
|
|
20
|
+
}
|
|
21
|
+
const rateOf = (a) => orig.playbackRate.get.call(a)
|
|
22
|
+
const stateOf = (a) => orig.playState.get.call(a)
|
|
23
|
+
const endOf = (a) => (a.effect ? a.effect.getComputedTiming().endTime : Infinity)
|
|
24
|
+
|
|
25
|
+
// created: the app just made it (element.animate()), rather than it being
|
|
26
|
+
// found on a scan (a CSS transition or animation, seen a frame after its cause).
|
|
27
|
+
function adopt(a, created) {
|
|
28
|
+
if (managed.has(a)) return managed.get(a)
|
|
29
|
+
const ps = stateOf(a)
|
|
30
|
+
if (ps === "idle") return null
|
|
31
|
+
// A running animation seen for the first time starts from 0 at this
|
|
32
|
+
// boundary, whatever real time the browser gave it before we saw it: live,
|
|
33
|
+
// a CSS transition has usually run a few real ms by then, in a rebuild
|
|
34
|
+
// none, and the two must agree.
|
|
35
|
+
// Replaying: if the recording has this animation, it starts when it did
|
|
36
|
+
// live. (Live, which frame first sees a new CSS transition races with the
|
|
37
|
+
// browser's own scheduling; the recorded start settles it.)
|
|
38
|
+
const recorded = ps === "running" && (hasFuture() || clock.seeking) ? recordedStart(a) : null
|
|
39
|
+
const s = {
|
|
40
|
+
t: recorded != null ? (clock.now - recorded) * (rateOf(a) || 1) : ps === "running" ? 0 : Number(orig.currentTime.get.call(a)) || 0,
|
|
41
|
+
v: clock.now,
|
|
42
|
+
userPaused: ps === "paused",
|
|
43
|
+
done: ps === "finished",
|
|
44
|
+
native: false,
|
|
45
|
+
checks: 0,
|
|
46
|
+
}
|
|
47
|
+
managed.set(a, s)
|
|
48
|
+
if (!s.done && !s.userPaused) orig.pause.call(a)
|
|
49
|
+
// The browser drops finished animations once a later one covers them; the
|
|
50
|
+
// timeline still needs them to show earlier moments.
|
|
51
|
+
if (a.persist) a.persist()
|
|
52
|
+
logAnim(a, s, !!created)
|
|
53
|
+
return s
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A CSS animation paused via `animation-play-state` stays frozen.
|
|
57
|
+
function cssPaused(a) {
|
|
58
|
+
if (typeof CSSAnimation === "undefined" || !(a instanceof CSSAnimation)) return false
|
|
59
|
+
const el = a.effect && /** @type {KeyframeEffect} */ (a.effect).target
|
|
60
|
+
if (!el) return false
|
|
61
|
+
const cs = getComputedStyle(el)
|
|
62
|
+
const names = cs.animationName.split(", ")
|
|
63
|
+
const states = cs.animationPlayState.split(", ")
|
|
64
|
+
const i = names.indexOf(a.animationName)
|
|
65
|
+
return i >= 0 && states[i % states.length] === "paused"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function catchUp(a, s) {
|
|
69
|
+
if (!s.done && !s.userPaused && !cssPaused(a)) s.t += (clock.now - s.v) * rateOf(a)
|
|
70
|
+
s.v = clock.now
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let syncCount = 0
|
|
74
|
+
// scan: look for new animations too. A new CSS animation needs a style
|
|
75
|
+
// change, which comes from a DOM change, input, a timer or a frame callback;
|
|
76
|
+
// when none of those happened, only the running ones need driving.
|
|
77
|
+
function syncAnimations(scan = true) {
|
|
78
|
+
// While previewing another moment, the preview owns every animation.
|
|
79
|
+
if (previewing) return
|
|
80
|
+
syncSmil(clock.now)
|
|
81
|
+
if (scan) for (const a of document.getAnimations()) adopt(a)
|
|
82
|
+
// Finished animations are only checked for removal now and then; a long
|
|
83
|
+
// session collects hundreds of them.
|
|
84
|
+
const sweep = ++syncCount % 30 === 0
|
|
85
|
+
for (const [a, s] of managed) {
|
|
86
|
+
if (s.done && !sweep) continue
|
|
87
|
+
if (stateOf(a) === "idle") {
|
|
88
|
+
managed.delete(a)
|
|
89
|
+
endAnim(a)
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (s.done) continue
|
|
93
|
+
catchUp(a, s)
|
|
94
|
+
const rate = rateOf(a)
|
|
95
|
+
// An endless loop (a spinner, a shimmer) runs natively on the compositor
|
|
96
|
+
// while recording at normal pace, lined up with the virtual clock and
|
|
97
|
+
// re-lined if it drifts; the clock takes it back on pause, seek or preview.
|
|
98
|
+
const endless = endOf(a) === Infinity && !s.userPaused && !cssPaused(a)
|
|
99
|
+
const native = endless && clock.playing && !clock.seeking && clock.rate === 1 && !hasFuture()
|
|
100
|
+
if (native) {
|
|
101
|
+
if (!s.native) {
|
|
102
|
+
orig.currentTime.set.call(a, s.t)
|
|
103
|
+
orig.play.call(a)
|
|
104
|
+
orig.currentTime.set.call(a, s.t)
|
|
105
|
+
s.native = true
|
|
106
|
+
} else if (++s.checks % 30 === 0 && Math.abs((Number(orig.currentTime.get.call(a)) || 0) - s.t) > 20) {
|
|
107
|
+
orig.currentTime.set.call(a, s.t)
|
|
108
|
+
}
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (s.native) {
|
|
112
|
+
orig.pause.call(a)
|
|
113
|
+
s.native = false
|
|
114
|
+
}
|
|
115
|
+
s.checks = 0
|
|
116
|
+
if ((rate > 0 && s.t >= endOf(a)) || (rate < 0 && s.t <= 0)) {
|
|
117
|
+
finishNow(a, s)
|
|
118
|
+
} else {
|
|
119
|
+
orig.currentTime.set.call(a, s.t)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- SMIL ---------------------------------------------------------------------
|
|
125
|
+
// SVG's own animations (<animate>, <set>, <animateMotion>, <animateTransform>)
|
|
126
|
+
// aren't on the document's animation timeline: they run on their outermost
|
|
127
|
+
// <svg>'s clock, from when it came onto the page. Each of those clocks is held
|
|
128
|
+
// paused and set from the virtual one, so pausing pauses them, a rebuild lands
|
|
129
|
+
// them where they were, and a preview shows them at t (instead of every frame
|
|
130
|
+
// swapped in starting them again from 0).
|
|
131
|
+
const SVG_NS = "http://www.w3.org/2000/svg"
|
|
132
|
+
const SMIL = "animate, set, animateMotion, animateTransform"
|
|
133
|
+
const smilSvgs = new Set() // outermost <svg>s with SMIL animations in them, as found
|
|
134
|
+
const smilBegin = new WeakMap() // outermost <svg> -> the virtual ms its clock started at
|
|
135
|
+
const smilShown = new WeakMap() // outermost <svg> -> the clock time (s) last set
|
|
136
|
+
// Look for SMIL in a subtree: the document at boot, then what the app adds
|
|
137
|
+
// (logMutations), so nothing walks the whole page every frame.
|
|
138
|
+
function findSmil(root) {
|
|
139
|
+
if (!root || !root.querySelectorAll || (root.nodeType === 1 && root.namespaceURI !== SVG_NS && !root.firstElementChild)) return
|
|
140
|
+
let els
|
|
141
|
+
try {
|
|
142
|
+
els = [...root.querySelectorAll(SMIL)]
|
|
143
|
+
if (root.nodeType === 1 && root.matches(SMIL)) els.push(root)
|
|
144
|
+
} catch {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
for (const el of els) {
|
|
148
|
+
if (el.namespaceURI !== SVG_NS) continue
|
|
149
|
+
let svg = el.ownerSVGElement
|
|
150
|
+
while (svg && svg.ownerSVGElement) svg = svg.ownerSVGElement
|
|
151
|
+
if (svg) smilSvgs.add(svg)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Show every SMIL clock at virtual time `at` (only those inside `scope`).
|
|
155
|
+
// A clock seen for the first time starts at `begin`: the page's start for the
|
|
156
|
+
// page's own svgs (at boot), else now (one the app put in since).
|
|
157
|
+
function syncSmil(at, scope, begin = clock.now) {
|
|
158
|
+
if (!smilSvgs.size) return
|
|
159
|
+
for (const svg of smilSvgs) {
|
|
160
|
+
if (!svg.isConnected || (scope && !scope.contains(svg))) continue
|
|
161
|
+
let b = smilBegin.get(svg)
|
|
162
|
+
if (b == null) {
|
|
163
|
+
if (previewing) continue // never seen live: nothing known to show
|
|
164
|
+
smilBegin.set(svg, (b = begin))
|
|
165
|
+
try {
|
|
166
|
+
svg.pauseAnimations()
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
if (clock.seeking) continue // set where the seek lands
|
|
170
|
+
const sec = Math.max(0, at - b) / 1000
|
|
171
|
+
if (smilShown.get(svg) === sec) continue
|
|
172
|
+
smilShown.set(svg, sec)
|
|
173
|
+
try {
|
|
174
|
+
svg.setCurrentTime(sec)
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The clock stopped or jumped: endless loops come back under its control now.
|
|
180
|
+
function reclaimNative() {
|
|
181
|
+
for (const [a, s] of managed) {
|
|
182
|
+
if (!s.native) continue
|
|
183
|
+
orig.pause.call(a)
|
|
184
|
+
s.native = false
|
|
185
|
+
orig.currentTime.set.call(a, s.t)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
Element.prototype.animate = function (...args) {
|
|
190
|
+
const a = orig.animate.apply(this, args)
|
|
191
|
+
adopt(a, true)
|
|
192
|
+
return a
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
AP.play = function () {
|
|
196
|
+
const s = managed.get(this)
|
|
197
|
+
if (!s) return orig.play.call(this)
|
|
198
|
+
catchUp(this, s)
|
|
199
|
+
const rate = rateOf(this)
|
|
200
|
+
if (rate > 0 && (s.done || s.t >= endOf(this))) s.t = 0
|
|
201
|
+
if (rate < 0 && (s.done || s.t <= 0)) s.t = endOf(this)
|
|
202
|
+
s.done = false
|
|
203
|
+
s.userPaused = false
|
|
204
|
+
orig.play.call(this) // renews the `finished` promise
|
|
205
|
+
orig.pause.call(this)
|
|
206
|
+
orig.currentTime.set.call(this, s.t)
|
|
207
|
+
}
|
|
208
|
+
AP.pause = function () {
|
|
209
|
+
const s = managed.get(this)
|
|
210
|
+
if (!s) return orig.pause.call(this)
|
|
211
|
+
catchUp(this, s)
|
|
212
|
+
s.userPaused = true
|
|
213
|
+
}
|
|
214
|
+
AP.finish = function () {
|
|
215
|
+
const s = managed.get(this)
|
|
216
|
+
if (!s) return orig.finish.call(this)
|
|
217
|
+
finishNow(this, s)
|
|
218
|
+
}
|
|
219
|
+
AP.cancel = function () {
|
|
220
|
+
managed.delete(this)
|
|
221
|
+
endAnim(this)
|
|
222
|
+
return orig.cancel.call(this)
|
|
223
|
+
}
|
|
224
|
+
AP.reverse = function () {
|
|
225
|
+
const s = managed.get(this)
|
|
226
|
+
if (!s) return orig.reverse.call(this)
|
|
227
|
+
catchUp(this, s)
|
|
228
|
+
orig.playbackRate.set.call(this, -rateOf(this))
|
|
229
|
+
s.t = Math.min(Math.max(s.t, 0), endOf(this))
|
|
230
|
+
s.done = false
|
|
231
|
+
s.userPaused = false
|
|
232
|
+
}
|
|
233
|
+
AP.updatePlaybackRate = function (r) {
|
|
234
|
+
const s = managed.get(this)
|
|
235
|
+
if (!s) return orig.updatePlaybackRate.call(this, r)
|
|
236
|
+
catchUp(this, s)
|
|
237
|
+
orig.playbackRate.set.call(this, r)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
Object.defineProperty(AP, "currentTime", {
|
|
241
|
+
configurable: true,
|
|
242
|
+
get() {
|
|
243
|
+
return orig.currentTime.get.call(this)
|
|
244
|
+
},
|
|
245
|
+
set(value) {
|
|
246
|
+
const s = managed.get(this)
|
|
247
|
+
if (s && value != null) {
|
|
248
|
+
s.t = Number(value)
|
|
249
|
+
s.v = clock.now
|
|
250
|
+
s.done = false
|
|
251
|
+
}
|
|
252
|
+
orig.currentTime.set.call(this, value)
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
Object.defineProperty(AP, "startTime", {
|
|
256
|
+
configurable: true,
|
|
257
|
+
get() {
|
|
258
|
+
const s = managed.get(this)
|
|
259
|
+
if (!s) return orig.startTime.get.call(this)
|
|
260
|
+
catchUp(this, s)
|
|
261
|
+
return clock.now + PERF_BASE - s.t / (rateOf(this) || 1)
|
|
262
|
+
},
|
|
263
|
+
set(value) {
|
|
264
|
+
const s = managed.get(this) || adopt(this, true)
|
|
265
|
+
if (!s || value == null) return orig.startTime.set.call(this, value)
|
|
266
|
+
s.t = (clock.now + PERF_BASE - Number(value)) * rateOf(this)
|
|
267
|
+
s.v = clock.now
|
|
268
|
+
s.done = false
|
|
269
|
+
s.userPaused = false
|
|
270
|
+
orig.currentTime.set.call(this, s.t)
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
Object.defineProperty(AP, "playbackRate", {
|
|
274
|
+
configurable: true,
|
|
275
|
+
get() {
|
|
276
|
+
return rateOf(this)
|
|
277
|
+
},
|
|
278
|
+
set(value) {
|
|
279
|
+
const s = managed.get(this)
|
|
280
|
+
if (s) catchUp(this, s)
|
|
281
|
+
orig.playbackRate.set.call(this, value)
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
Object.defineProperty(AP, "playState", {
|
|
285
|
+
configurable: true,
|
|
286
|
+
get() {
|
|
287
|
+
const s = managed.get(this)
|
|
288
|
+
const ps = stateOf(this)
|
|
289
|
+
if (!s || ps === "idle" || ps === "finished") return ps
|
|
290
|
+
return s.userPaused ? "paused" : "running"
|
|
291
|
+
},
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ---- deterministic end events -----------------------------------------------
|
|
295
|
+
// The browser delivers `finish`, `animationend` and `transitionend` on its next
|
|
296
|
+
// rendering step, i.e. at a real-time moment. We fire them ourselves, right when
|
|
297
|
+
// the virtual clock finishes the animation, and drop the browser's copies.
|
|
298
|
+
|
|
299
|
+
const finishHandlers = new WeakMap() // Animation -> { prop, listeners: Set }
|
|
300
|
+
const handlersOf = (a) => {
|
|
301
|
+
if (!finishHandlers.has(a)) finishHandlers.set(a, { prop: null, listeners: new Set() })
|
|
302
|
+
return finishHandlers.get(a)
|
|
303
|
+
}
|
|
304
|
+
Object.defineProperty(AP, "onfinish", {
|
|
305
|
+
configurable: true,
|
|
306
|
+
get() {
|
|
307
|
+
return finishHandlers.get(this)?.prop ?? null
|
|
308
|
+
},
|
|
309
|
+
set(fn) {
|
|
310
|
+
handlersOf(this).prop = typeof fn === "function" ? fn : null
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
const animAdd = AP.addEventListener
|
|
314
|
+
const animRemove = AP.removeEventListener
|
|
315
|
+
AP.addEventListener = function (type, fn, opts) {
|
|
316
|
+
if (type !== "finish" || !fn) return animAdd.call(this, type, fn, opts)
|
|
317
|
+
handlersOf(this).listeners.add(fn)
|
|
318
|
+
}
|
|
319
|
+
AP.removeEventListener = function (type, fn, opts) {
|
|
320
|
+
if (type !== "finish") return animRemove.call(this, type, fn, opts)
|
|
321
|
+
finishHandlers.get(this)?.listeners.delete(fn)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let synthesizing = 0
|
|
325
|
+
function finishNow(a, s) {
|
|
326
|
+
if (s.done) return
|
|
327
|
+
s.done = true
|
|
328
|
+
orig.finish.call(a)
|
|
329
|
+
const h = finishHandlers.get(a)
|
|
330
|
+
const ev = new AnimationPlaybackEvent("finish", { currentTime: a.currentTime, timelineTime: clock.now + PERF_BASE })
|
|
331
|
+
if (h) {
|
|
332
|
+
for (const fn of [h.prop, ...h.listeners]) {
|
|
333
|
+
if (!fn) continue
|
|
334
|
+
safeCall(typeof fn === "function" ? fn : fn.handleEvent.bind(fn), [ev])
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const target = a.effect && a.effect.target
|
|
338
|
+
if (!target) return
|
|
339
|
+
let dom = null
|
|
340
|
+
if (typeof CSSAnimation !== "undefined" && a instanceof CSSAnimation) {
|
|
341
|
+
dom = new AnimationEvent("animationend", { bubbles: true, animationName: a.animationName, elapsedTime: endOf(a) / 1000 })
|
|
342
|
+
} else if (typeof CSSTransition !== "undefined" && a instanceof CSSTransition) {
|
|
343
|
+
dom = new TransitionEvent("transitionend", { bubbles: true, propertyName: a.transitionProperty, elapsedTime: endOf(a) / 1000 })
|
|
344
|
+
}
|
|
345
|
+
if (!dom) return
|
|
346
|
+
synthesizing++
|
|
347
|
+
try {
|
|
348
|
+
target.dispatchEvent(dom)
|
|
349
|
+
} finally {
|
|
350
|
+
synthesizing--
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const type of ["animationend", "transitionend"]) {
|
|
355
|
+
W.addEventListener(
|
|
356
|
+
type,
|
|
357
|
+
(e) => {
|
|
358
|
+
if (e.isTrusted && !synthesizing) e.stopImmediatePropagation()
|
|
359
|
+
},
|
|
360
|
+
true,
|
|
361
|
+
)
|
|
362
|
+
}
|