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,78 @@
|
|
|
1
|
+
// :hover in replays. Synthetic pointer events don't set :hover, so hover
|
|
2
|
+
// styles (and the transitions they start) never showed in a rebuild or a
|
|
3
|
+
// preview. Every :hover rule gets a twin that matches [data-rt-hover], and
|
|
4
|
+
// while a replay or preview shows the past, that attribute follows the
|
|
5
|
+
// recorded pointer (on the hovered element and its ancestors, like :hover).
|
|
6
|
+
// At the live edge it's cleared and the real :hover takes over.
|
|
7
|
+
|
|
8
|
+
const HOVER_ATTR = "data-rt-hover"
|
|
9
|
+
const mirrored = new WeakMap() // sheet -> rule count after mirroring
|
|
10
|
+
let hoverChain = []
|
|
11
|
+
|
|
12
|
+
function mirrorRules(list, owner) {
|
|
13
|
+
// Walk backwards so inserting a twin right after its rule doesn't shift the rest.
|
|
14
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
15
|
+
const r = list[i]
|
|
16
|
+
if (r.cssRules && r.cssRules.length) mirrorRules(r.cssRules, r)
|
|
17
|
+
if (r.selectorText && r.selectorText.includes(":hover") && !r.selectorText.includes(HOVER_ATTR)) {
|
|
18
|
+
const twin = r.cssText.replace(r.selectorText, r.selectorText.replace(/:hover\b/g, `[${HOVER_ATTR}]`))
|
|
19
|
+
try {
|
|
20
|
+
owner.insertRule(twin, i + 1)
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mirrorHover() {
|
|
27
|
+
for (const sheet of document.styleSheets) {
|
|
28
|
+
let rules
|
|
29
|
+
try {
|
|
30
|
+
rules = sheet.cssRules
|
|
31
|
+
} catch {
|
|
32
|
+
continue // cross-origin
|
|
33
|
+
}
|
|
34
|
+
if (mirrored.get(sheet) === rules.length) continue
|
|
35
|
+
mirrorRules(rules, sheet)
|
|
36
|
+
mirrored.set(sheet, sheet.cssRules.length)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setHover(el) {
|
|
41
|
+
const chain = []
|
|
42
|
+
for (let n = el && el.nodeType === 1 ? el : null; n; n = n.parentElement) chain.push(n)
|
|
43
|
+
if (chain.length) mirrorHover()
|
|
44
|
+
let changed = false
|
|
45
|
+
for (const n of hoverChain) if (!chain.includes(n)) n.removeAttribute(HOVER_ATTR), (changed = true)
|
|
46
|
+
for (const n of chain) if (!n.hasAttribute(HOVER_ATTR)) n.setAttribute(HOVER_ATTR, ""), (changed = true)
|
|
47
|
+
hoverChain = chain
|
|
48
|
+
// A hover change can start transitions: the next frame must look (see appRan).
|
|
49
|
+
if (changed) appRan = true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clearHover() {
|
|
53
|
+
if (!hoverChain.length) return
|
|
54
|
+
for (const n of hoverChain) n.removeAttribute(HOVER_ATTR)
|
|
55
|
+
hoverChain = []
|
|
56
|
+
appRan = true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A replayed pointer event: where the pointer is now.
|
|
60
|
+
function replayHover(ev, target) {
|
|
61
|
+
if (ev.type === "pointerover" || ev.type === "mouseover") setHover(target)
|
|
62
|
+
else if ((ev.type === "pointerout" || ev.type === "mouseout") && !ev.related) setHover(null)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For a preview at t: the element the pointer was over then. Input recorded
|
|
66
|
+
// at exactly t comes just after it (as in a rebuild to t). Without t: what the
|
|
67
|
+
// input this frame has replayed so far says.
|
|
68
|
+
function hoverAt(t) {
|
|
69
|
+
let path = null
|
|
70
|
+
const n = t == null ? cursor.event : rec.events.length
|
|
71
|
+
for (let i = 0; i < n; i++) {
|
|
72
|
+
const ev = rec.events[i]
|
|
73
|
+
if (t != null && ev.t >= t) break
|
|
74
|
+
if (ev.type === "pointerover" || ev.type === "mouseover") path = ev.path
|
|
75
|
+
else if ((ev.type === "pointerout" || ev.type === "mouseout") && !ev.related) path = null
|
|
76
|
+
}
|
|
77
|
+
return path ? resolvePath(path) : null
|
|
78
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// The driver. Time moves in "frames": at each frame boundary B, due timers run,
|
|
2
|
+
// the clock lands on B, rAF callbacks run, then any input recorded at B is
|
|
3
|
+
// re-dispatched. Recording keeps the boundaries, so a replay walks exactly the
|
|
4
|
+
// same frames and the app sees an identical sequence of moments.
|
|
5
|
+
|
|
6
|
+
const FF_STEP = 50 // max virtual ms between frames when skipping ahead live
|
|
7
|
+
let pace = 0 // where playback wants the clock to be, in virtual ms
|
|
8
|
+
let seekTarget = null
|
|
9
|
+
let afterSeek = null
|
|
10
|
+
let seekFrom = null // where the current (or last) seek started, for the dock's progress
|
|
11
|
+
let pendingPreview = null // a preview asked for mid-seek, shown once the seek stops
|
|
12
|
+
|
|
13
|
+
const nextEventT = () => (cursor.event < rec.events.length ? rec.events[cursor.event].t : Infinity)
|
|
14
|
+
const futureFrame = () => (cursor.frame < rec.frames.length ? rec.frames[cursor.frame] : null)
|
|
15
|
+
|
|
16
|
+
// Input recorded at exactly a seek target happened just *after* that moment,
|
|
17
|
+
// so a seek stops short of it.
|
|
18
|
+
let timersBefore = 0
|
|
19
|
+
// Plain input: whatever the app does with it, it does now (in handlers and
|
|
20
|
+
// what they queue). Anything else (a network reply, a worker message, media
|
|
21
|
+
// readiness) may go on in real time, so the next settle yields a real task.
|
|
22
|
+
const PLAIN_INPUT = /^(pointer|mouse|key|wheel|scroll|focus|blur|input|beforeinput|change|click|dblclick|contextmenu|touch|composition|select|nav|resize)/
|
|
23
|
+
async function dispatchUpTo(B) {
|
|
24
|
+
const exclusive = clock.seeking && B === seekTarget
|
|
25
|
+
timersBefore = timers.size
|
|
26
|
+
while (exclusive ? nextEventT() < B : nextEventT() <= B) {
|
|
27
|
+
const before = domLog.length
|
|
28
|
+
const ev = rec.events[cursor.event++]
|
|
29
|
+
markInput(ev)
|
|
30
|
+
dispatchRecorded(ev)
|
|
31
|
+
if (!PLAIN_INPUT.test(ev.type)) dispatchedSinceYield = true
|
|
32
|
+
// Events the browser fired in one task (one mouse move: pointermove,
|
|
33
|
+
// pointerover/out, mouseover/out…) go out back to back, as they did live;
|
|
34
|
+
// the app gets its turn after the group.
|
|
35
|
+
const next = rec.events[cursor.event]
|
|
36
|
+
if (next && next.g && next.t === ev.t && !(exclusive && next.t >= B)) continue
|
|
37
|
+
await settle(true)
|
|
38
|
+
// An event that changed the DOM may have started CSS transitions, which
|
|
39
|
+
// the next frame adopts; one that changed nothing leaves nothing behind.
|
|
40
|
+
if (observer && !previewing) logMutations(observer.takeRecords())
|
|
41
|
+
if (domLog.length !== before || timers.size !== timersBefore || rafQueue.size) appRan = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Did any app code run since the last settle? While rebuilding, a boundary
|
|
46
|
+
// where nothing ran (no input, timer, frame callback, animation, pending
|
|
47
|
+
// work) has nothing to wait for, so it's skipped through. That's most frames
|
|
48
|
+
// of a long session, and it gives the same result as waiting.
|
|
49
|
+
let appRan = true
|
|
50
|
+
// While rebuilding, animations only need a sync when one could have started
|
|
51
|
+
// (app code ran) or one finishes by this frame (its end events must fire
|
|
52
|
+
// here); in between, their state at the target is all that matters.
|
|
53
|
+
function nextAnimationEnd() {
|
|
54
|
+
let min = Infinity
|
|
55
|
+
for (const [a, st] of managed) {
|
|
56
|
+
if (st.done || st.userPaused) continue
|
|
57
|
+
const end = endOf(a)
|
|
58
|
+
const rate = rateOf(a) || 1
|
|
59
|
+
if (!Number.isFinite(end)) continue
|
|
60
|
+
const at = rate > 0 ? st.v + (end - st.t) / rate : st.v + st.t / -rate
|
|
61
|
+
if (at < min) min = at
|
|
62
|
+
}
|
|
63
|
+
return min
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Where a replay resting between recorded frames really is (see runSeek).
|
|
67
|
+
let restFrom = null
|
|
68
|
+
async function processBoundary(B) {
|
|
69
|
+
if (restFrom != null) {
|
|
70
|
+
clock.now = restFrom
|
|
71
|
+
restFrom = null
|
|
72
|
+
}
|
|
73
|
+
await dispatchUpTo(clock.now)
|
|
74
|
+
let ran = 0
|
|
75
|
+
for (let t = nextTimer(); t && t.due <= B && ran < 5000; t = nextTimer(), ran++) {
|
|
76
|
+
clock.now = Math.max(clock.now, t.due)
|
|
77
|
+
runTimer(t)
|
|
78
|
+
}
|
|
79
|
+
if (ran) appRan = true
|
|
80
|
+
clock.now = B
|
|
81
|
+
if (rafQueue.size) appRan = true
|
|
82
|
+
runRaf()
|
|
83
|
+
tickWorkers(B)
|
|
84
|
+
while (futureFrame() != null && futureFrame() <= B) cursor.frame++
|
|
85
|
+
const own0 = real.perfNow()
|
|
86
|
+
recordFrame(B)
|
|
87
|
+
let own = real.perfNow() - own0
|
|
88
|
+
if (!clock.seeking || appRan || appMessages > 0 || otherBusy() || nextAnimationEnd() <= B) {
|
|
89
|
+
const s0 = real.perfNow()
|
|
90
|
+
syncAnimations(appRan || clock.seeking)
|
|
91
|
+
own += real.perfNow() - s0
|
|
92
|
+
await settle(true)
|
|
93
|
+
// Live, whatever the app started while settling belongs to this moment
|
|
94
|
+
// too (a rebuild takes recorded starts instead).
|
|
95
|
+
if (!clock.seeking && appRan) syncAnimations()
|
|
96
|
+
appRan = false
|
|
97
|
+
}
|
|
98
|
+
await dispatchUpTo(B)
|
|
99
|
+
const a0 = real.perfNow()
|
|
100
|
+
sampleActivity()
|
|
101
|
+
own += real.perfNow() - a0
|
|
102
|
+
// The runtime's own work per recorded frame (not the app's), for the perf budget.
|
|
103
|
+
if (!clock.seeking) {
|
|
104
|
+
stats.ownFrames = (stats.ownFrames || 0) + 1
|
|
105
|
+
stats.ownMs = (stats.ownMs || 0) + own
|
|
106
|
+
if (own > (stats.ownMax || 0)) stats.ownMax = own
|
|
107
|
+
}
|
|
108
|
+
if (hoverChain.length && !clock.seeking && !hasFuture()) clearHover()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The next frame boundary at or before `limit`, or null if time should rest.
|
|
112
|
+
function nextBoundary(limit, skipping) {
|
|
113
|
+
const f = futureFrame()
|
|
114
|
+
if (f != null) return f <= limit ? f : null
|
|
115
|
+
if (limit <= clock.now) return null
|
|
116
|
+
if (!skipping) return limit
|
|
117
|
+
// Skipping ahead: jump straight to the next timer, but keep rAF-driven work
|
|
118
|
+
// (streaming text, JS animations) ticking at least every FF_STEP.
|
|
119
|
+
const timer = nextTimer()
|
|
120
|
+
let B = limit
|
|
121
|
+
if (rafQueue.size) B = Math.min(B, clock.now + FF_STEP)
|
|
122
|
+
if (timer) B = Math.min(B, Math.max(timer.due, clock.now + 1))
|
|
123
|
+
return B
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function catchUpAll() {
|
|
127
|
+
for (const [a, st] of managed) if (!st.done) catchUp(a, st)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A frame being built in the background (a checkpoint) replays in idle
|
|
131
|
+
// slices, so the frame you're looking at stays smooth.
|
|
132
|
+
let background = false
|
|
133
|
+
const idleSlice = () => new Promise((r) => (W.requestIdleCallback && real.idle ? real.idle(r, { timeout: 200 }) : real.setTimeout(r, 0)))
|
|
134
|
+
|
|
135
|
+
async function runSeek() {
|
|
136
|
+
const seekStart = real.perfNow()
|
|
137
|
+
seekFrom = clock.now
|
|
138
|
+
takeStorage() // this frame's app runs now: its own storage, not another frame's
|
|
139
|
+
clock.seeking = true
|
|
140
|
+
reclaimNative()
|
|
141
|
+
syncMedia()
|
|
142
|
+
PT.emit()
|
|
143
|
+
let lastPaint = real.perfNow()
|
|
144
|
+
let sliceStart = real.perfNow()
|
|
145
|
+
while (seekTarget != null && clock.now < seekTarget) {
|
|
146
|
+
if (real.perfNow() - sliceStart > 8) {
|
|
147
|
+
// A replay that settles in microtasks would otherwise be one long task:
|
|
148
|
+
// every 8ms the page (the dock, the frame on show) gets a turn, or in
|
|
149
|
+
// background mode, the rest of an idle period.
|
|
150
|
+
await (background ? idleSlice() : yieldTask())
|
|
151
|
+
sliceStart = real.perfNow()
|
|
152
|
+
if (seekTarget == null) break
|
|
153
|
+
}
|
|
154
|
+
const B = nextBoundary(seekTarget, true)
|
|
155
|
+
if (B == null) {
|
|
156
|
+
// Rest between recorded frames, like the original did. The replay is
|
|
157
|
+
// really at its last boundary: timers due before the target run at
|
|
158
|
+
// their own moment when time moves on, as they did live (restFrom).
|
|
159
|
+
if (restFrom == null) restFrom = clock.now
|
|
160
|
+
clock.now = seekTarget
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
await processBoundary(B)
|
|
164
|
+
if (!background && real.perfNow() - lastPaint > 400) {
|
|
165
|
+
PT.emit()
|
|
166
|
+
await new Promise((r) => real.raf(r))
|
|
167
|
+
lastPaint = real.perfNow()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
stats.seekMs = Math.round(real.perfNow() - seekStart)
|
|
171
|
+
seekTarget = null
|
|
172
|
+
clock.seeking = false
|
|
173
|
+
pace = clock.now
|
|
174
|
+
syncAnimations()
|
|
175
|
+
alignMedia()
|
|
176
|
+
syncMedia()
|
|
177
|
+
restoreScroll()
|
|
178
|
+
if (!hasFuture()) clearHover() // live again: the real :hover takes over
|
|
179
|
+
// Put real focus back where the recording had it.
|
|
180
|
+
const f = focused()
|
|
181
|
+
if (f && realActive.call(document) !== f) {
|
|
182
|
+
try {
|
|
183
|
+
f.focus({ preventScroll: true })
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
keepStorage()
|
|
187
|
+
// A preview asked for while this seek ran (it was stopped for it).
|
|
188
|
+
const p = pendingPreview
|
|
189
|
+
pendingPreview = null
|
|
190
|
+
if (p) preview(p.t, p.scope)
|
|
191
|
+
const then = afterSeek
|
|
192
|
+
afterSeek = null
|
|
193
|
+
if (then) then()
|
|
194
|
+
PT.emit()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The last animation frame's time (real ms), for the step to the next one.
|
|
198
|
+
// Play sets it too, so the first frame played steps from the moment Play was
|
|
199
|
+
// pressed, not from before a rebuild's replay this frame sat through.
|
|
200
|
+
let driveLast = 0
|
|
201
|
+
async function drive() {
|
|
202
|
+
driveLast = real.perfNow()
|
|
203
|
+
for (;;) {
|
|
204
|
+
const ts = await new Promise((r) => real.raf(r))
|
|
205
|
+
// Cap the step so a backgrounded tab doesn't come back to a huge jump.
|
|
206
|
+
const dt = Math.max(0, Math.min(ts - driveLast, 100))
|
|
207
|
+
driveLast = ts
|
|
208
|
+
if (!clock.booted) continue
|
|
209
|
+
try {
|
|
210
|
+
if (seekTarget != null) {
|
|
211
|
+
await runSeek()
|
|
212
|
+
} else if (clock.playing) {
|
|
213
|
+
pace = Math.max(pace, clock.now) + dt * clock.rate
|
|
214
|
+
const nextSeg = hasFuture() && segmentsOf().find((sg) => sg.t > clock.now)
|
|
215
|
+
if (nextSeg && pace >= nextSeg.t) {
|
|
216
|
+
rewind(nextSeg.t, true) // the recorded future reloaded here: carry on from a fresh page (this one pauses)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
for (let B = nextBoundary(pace, false); B != null; B = nextBoundary(pace, false)) {
|
|
220
|
+
await processBoundary(B)
|
|
221
|
+
if (seekTarget != null || !clock.playing) break
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
syncAnimations(appRan) // new ones only if something happened since the frame
|
|
225
|
+
syncMedia()
|
|
226
|
+
releaseDeferred() // requests a frame built behind held, now it's on show at the live edge
|
|
227
|
+
releaseHeldScripts() // scripts held for a moment the recording no longer has
|
|
228
|
+
PT.emit()
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.error("[retake]", err)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- controls ----------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
// Play state changes, for the dock (PT.onPlayState).
|
|
238
|
+
const playListeners = new Set()
|
|
239
|
+
function playStateChanged() {
|
|
240
|
+
for (const fn of playListeners) safeCall(fn, [{ playing: clock.playing, now: clock.now }])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function play() {
|
|
244
|
+
if (clock.playing) return
|
|
245
|
+
restoreScroll() // anything scrolled just to look goes back first
|
|
246
|
+
restoreFocus() // and the field you were in gets focus back
|
|
247
|
+
takeStorage() // this frame's own storage, if another frame had it meanwhile
|
|
248
|
+
clock.playing = true
|
|
249
|
+
clockRan = true
|
|
250
|
+
pace = clock.now
|
|
251
|
+
driveLast = real.perfNow()
|
|
252
|
+
syncMedia()
|
|
253
|
+
deliverHeldResize() // the window changed size while paused
|
|
254
|
+
releaseHeld() // network arrivals held while paused at the live edge
|
|
255
|
+
playStateChanged()
|
|
256
|
+
PT.emit()
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pause() {
|
|
260
|
+
const was = clock.playing
|
|
261
|
+
clock.playing = false
|
|
262
|
+
catchUpAll()
|
|
263
|
+
reclaimNative()
|
|
264
|
+
syncMedia()
|
|
265
|
+
keepStorage()
|
|
266
|
+
PT.emit()
|
|
267
|
+
if (was) playStateChanged()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function setRate(rate) {
|
|
271
|
+
catchUpAll()
|
|
272
|
+
reclaimNative()
|
|
273
|
+
clock.rate = rate
|
|
274
|
+
syncMedia()
|
|
275
|
+
PT.emit()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function seek(t, then) {
|
|
279
|
+
t = Math.max(0, t)
|
|
280
|
+
// Past a reload, the moment needs a fresh page: rebuild instead. So does a
|
|
281
|
+
// frame whose IndexedDB another frame has changed since (only a rebuild
|
|
282
|
+
// puts that back).
|
|
283
|
+
if (t >= clock.now && (segmentIndex(t) !== segmentIndex(clock.now) || !storageOk())) return rewind(t, !!then)
|
|
284
|
+
if (t >= clock.now) {
|
|
285
|
+
if (clock.playing) {
|
|
286
|
+
clock.playing = false
|
|
287
|
+
playStateChanged()
|
|
288
|
+
}
|
|
289
|
+
seekTarget = t
|
|
290
|
+
afterSeek = then || null
|
|
291
|
+
} else {
|
|
292
|
+
rewind(t, !!then)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Going back means rebuilding: reload the prototype frame and replay what
|
|
297
|
+
// happened up to t. The dock (parent window) holds the history meanwhile.
|
|
298
|
+
// The dock builds the moment in a fresh frame behind this one and swaps it in
|
|
299
|
+
// when it's ready, so going back never flashes.
|
|
300
|
+
// url: the page the moment's segment started on (the dock loads it; a frame
|
|
301
|
+
// on another URL moves there itself). viewport: the size to build it at.
|
|
302
|
+
// sig: which recording this is (the dock keeps a build of the same recording
|
|
303
|
+
// going instead of starting another); none for another timeline's history.
|
|
304
|
+
function rewind(t, playAfter, json) {
|
|
305
|
+
// This frame is about to be replaced: it stops here (and keeps its storage).
|
|
306
|
+
if (clock.playing) pause()
|
|
307
|
+
let r = rec
|
|
308
|
+
let sig = null
|
|
309
|
+
if (json == null) {
|
|
310
|
+
json = JSON.stringify(rec)
|
|
311
|
+
sig = recSig()
|
|
312
|
+
} else {
|
|
313
|
+
try {
|
|
314
|
+
r = readRec(json)
|
|
315
|
+
} catch {
|
|
316
|
+
r = null
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
let url
|
|
320
|
+
try {
|
|
321
|
+
url = segmentAt(t, r).url
|
|
322
|
+
} catch {}
|
|
323
|
+
let doc = null
|
|
324
|
+
try {
|
|
325
|
+
doc = segmentAt(t, r).doc || null
|
|
326
|
+
} catch {}
|
|
327
|
+
const viewport = r && r.viewport ? { w: r.viewport.w, h: r.viewport.h } : null
|
|
328
|
+
if (shell) shell.rebuild({ rec: json, target: t, play: playAfter, rate: clock.rate, url, viewport, sig, doc })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Behind the front server, the next load of `url` gets the page as it was
|
|
332
|
+
// recorded (F56): a one-shot cookie names the kept copy, and the server takes
|
|
333
|
+
// it out again as it serves it (the dev server never sees it).
|
|
334
|
+
function askStoredDoc(doc, url) {
|
|
335
|
+
if (!doc || !/^[0-9a-f]{16}$/.test(doc)) return
|
|
336
|
+
try {
|
|
337
|
+
const path = new URL(url, location.href).pathname
|
|
338
|
+
document.cookie = `__retake_doc=${doc}; path=${path}; max-age=10; samesite=strict`
|
|
339
|
+
} catch {}
|
|
340
|
+
}
|
|
341
|
+
const recSig = () => `${rec.events.length}:${rec.frames.length}:${rec.end}:${(rec.segments || []).length}:${rec.seed}`
|