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,754 @@
|
|
|
1
|
+
// The dock. Lives in the top window; the prototype runs in a frame on the
|
|
2
|
+
// stage above it, with the time runtime inside. Going to another moment builds
|
|
3
|
+
// it in a second frame behind the visible one (invisible, one at a time) and
|
|
4
|
+
// swaps it in when it's ready, so there's no flash; going back, the visible
|
|
5
|
+
// frame previews the moment meanwhile (goTo() in 25-input.js).
|
|
6
|
+
const stage = $("#wb-stage")
|
|
7
|
+
const dock = $("#wb-dock")
|
|
8
|
+
const track = $(".track")
|
|
9
|
+
const cv = $(".lines") // the timeline canvas
|
|
10
|
+
|
|
11
|
+
const newBranch = (forkAt, parentId = null) => {
|
|
12
|
+
const id = ++D.branchSeq
|
|
13
|
+
const b = { id, name: id === 1 ? "Main" : `Take ${id}`, forkAt, parentId, json: null, end: forkAt, born: performance.now() }
|
|
14
|
+
D.branches.push(b)
|
|
15
|
+
return b
|
|
16
|
+
}
|
|
17
|
+
function resetBranches() {
|
|
18
|
+
D.branches = []
|
|
19
|
+
D.branchSeq = 0
|
|
20
|
+
D.activeId = newBranch(0).id
|
|
21
|
+
}
|
|
22
|
+
resetBranches()
|
|
23
|
+
|
|
24
|
+
// The Vite plugin knows the dock's frame by `?__wb=app` in its URL. Behind the
|
|
25
|
+
// front server (marker "header") the URL stays the app's own: the server knows
|
|
26
|
+
// a frame by its Sec-Fetch-Dest, and the runtime by isAppFrame() below.
|
|
27
|
+
const MARKER = (window.__retakeConfig && window.__retakeConfig.marker) || "url"
|
|
28
|
+
const appUrl = (() => {
|
|
29
|
+
const u = new URL(location.href)
|
|
30
|
+
if (MARKER !== "header") u.searchParams.set("__wb", "app")
|
|
31
|
+
return u.pathname + u.search + u.hash
|
|
32
|
+
})()
|
|
33
|
+
// Every frame the dock makes (the one on show, one being built, checkpoints).
|
|
34
|
+
const appFrames = new WeakSet()
|
|
35
|
+
|
|
36
|
+
// A static build has no server to tell `?__wb=app` from the dock's own page,
|
|
37
|
+
// so it ships the app as a file of its own and the frame always loads that.
|
|
38
|
+
const APP_PAGE = window.__retakeConfig && window.__retakeConfig.appPage
|
|
39
|
+
|
|
40
|
+
function makeFrame(src, stash) {
|
|
41
|
+
if (APP_PAGE) {
|
|
42
|
+
const u = new URL(src, location.href)
|
|
43
|
+
u.pathname = APP_PAGE
|
|
44
|
+
src = u.pathname + u.search + u.hash
|
|
45
|
+
}
|
|
46
|
+
const f = document.createElement("iframe")
|
|
47
|
+
appFrames.add(f)
|
|
48
|
+
f.title = "Prototype"
|
|
49
|
+
f.className = "building"
|
|
50
|
+
if (stash) f.__retakeStash = stash
|
|
51
|
+
f.src = src
|
|
52
|
+
stage.prepend(f)
|
|
53
|
+
return f
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const frameRuntime = (f) => {
|
|
57
|
+
try {
|
|
58
|
+
return f.contentWindow.__retake || null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The frame built behind takes the visible one's place, in one task: it shows
|
|
65
|
+
// the moment the old one was previewing, scrolled where the user had it, so
|
|
66
|
+
// nothing on screen moves (a page with a canvas, whose pixels the preview
|
|
67
|
+
// couldn't take back, fades in over 120ms).
|
|
68
|
+
function swapIn(b) {
|
|
69
|
+
const f = b.frame
|
|
70
|
+
const pt = b.pt
|
|
71
|
+
const old = D.frame && D.frame !== f ? D.frame : null
|
|
72
|
+
// What the user scrolled to look at, copied into this window (the old frame
|
|
73
|
+
// is going) and taken by the new one in its own (no frame keeps another alive).
|
|
74
|
+
// Only into a moment of a recording: a fresh frame (Start fresh) is a live
|
|
75
|
+
// page recording from its first moment, where a scroll it didn't record
|
|
76
|
+
// would be lost on the next rebuild.
|
|
77
|
+
let view = []
|
|
78
|
+
try {
|
|
79
|
+
if (old && b.target != null && D.PT && typeof D.PT.viewScroll === "function") view = JSON.parse(JSON.stringify(D.PT.viewScroll()))
|
|
80
|
+
} catch {}
|
|
81
|
+
// Built at the recorded size; the visible frame fills the stage again.
|
|
82
|
+
f.style.width = f.style.height = ""
|
|
83
|
+
try {
|
|
84
|
+
if (view.length && typeof pt.applyView === "function") pt.applyView(view)
|
|
85
|
+
} catch {}
|
|
86
|
+
f.className = "live"
|
|
87
|
+
f.removeAttribute("aria-hidden")
|
|
88
|
+
if (old) {
|
|
89
|
+
let canvas = false
|
|
90
|
+
try {
|
|
91
|
+
canvas = !!old.contentDocument.querySelector("canvas")
|
|
92
|
+
} catch {}
|
|
93
|
+
if (canvas) {
|
|
94
|
+
f.dataset.enter = ""
|
|
95
|
+
old.className = "leaving"
|
|
96
|
+
setTimeout(() => old.remove(), 140)
|
|
97
|
+
setTimeout(() => delete f.dataset.enter, 200)
|
|
98
|
+
} else old.remove()
|
|
99
|
+
}
|
|
100
|
+
D.frame = f
|
|
101
|
+
D.PT = pt
|
|
102
|
+
D.frameBranch = b.branchId != null ? b.branchId : D.activeId
|
|
103
|
+
D.building = null
|
|
104
|
+
window.__retakeShell.rebuilding = false
|
|
105
|
+
D.scopeEl = null // an element of the old frame
|
|
106
|
+
noteBuilt(b)
|
|
107
|
+
hookFrameKeys(f.contentWindow)
|
|
108
|
+
if (b.fork) forkNow()
|
|
109
|
+
else if (b.play) pt.play()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
window.__retakeShell = {
|
|
113
|
+
rebuilding: false,
|
|
114
|
+
storageOwner: null, // which frame's app last ran (see takeStorage in the runtime)
|
|
115
|
+
fastReplay: true, // false: every replay settle is a full task round trip (a kill switch)
|
|
116
|
+
// The runtime hands over keys it blocks in the view-only past (or that a
|
|
117
|
+
// frame built behind got because its replay focused a field). True if the
|
|
118
|
+
// dock used it.
|
|
119
|
+
key(e) {
|
|
120
|
+
try {
|
|
121
|
+
return dockKey(e, true)
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
// Did the dock make this iframe? The injected runtime asks before it runs
|
|
127
|
+
// (an iframe the app embeds gets `__retake = { inert: true }`).
|
|
128
|
+
isAppFrame(el) {
|
|
129
|
+
return !!el && appFrames.has(el)
|
|
130
|
+
},
|
|
131
|
+
// Is this window the frame on show? (One built behind, a checkpoint or one
|
|
132
|
+
// fading out isn't: its keys are the dock's, ⌥P too.)
|
|
133
|
+
shows(win) {
|
|
134
|
+
try {
|
|
135
|
+
return !!D.frame && D.frame.contentWindow === win
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
// A booting runtime asks for the history it should build. Several frames
|
|
141
|
+
// can be booting (a rewind, a checkpoint), so each keeps its own; the one
|
|
142
|
+
// asking is the one whose runtime exists and hasn't taken it yet.
|
|
143
|
+
take() {
|
|
144
|
+
for (const f of stage.querySelectorAll("iframe")) {
|
|
145
|
+
let mine = false
|
|
146
|
+
try {
|
|
147
|
+
mine = "__retakeStash" in f && !!f.contentWindow.__retake
|
|
148
|
+
} catch {}
|
|
149
|
+
if (!mine) continue
|
|
150
|
+
const p = f.__retakeStash
|
|
151
|
+
delete f.__retakeStash
|
|
152
|
+
return p
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
},
|
|
156
|
+
attach(pt) {
|
|
157
|
+
const b = D.building
|
|
158
|
+
const cp = D.cp
|
|
159
|
+
if (b && b.frame && frameRuntime(b.frame) === pt) {
|
|
160
|
+
// A frame that reloaded itself mid-build (a Vite full reload) comes back
|
|
161
|
+
// as something else: build it again, never swap that in.
|
|
162
|
+
if (b.id != null && pt.buildId !== b.id) return restartBuild(b)
|
|
163
|
+
b.pt = pt
|
|
164
|
+
try {
|
|
165
|
+
if (b.target != null && typeof pt.retarget === "function") pt.retarget(b.target)
|
|
166
|
+
if (typeof pt.setBackground === "function") pt.setBackground(D.dragT != null || D.keyT != null)
|
|
167
|
+
} catch {}
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (cp && frameRuntime(cp.frame) === pt) {
|
|
171
|
+
if (cp.id != null && pt.buildId !== cp.id) return dropCheckpoint()
|
|
172
|
+
cp.pt = pt
|
|
173
|
+
try {
|
|
174
|
+
if (typeof pt.setBackground === "function") pt.setBackground(true)
|
|
175
|
+
} catch {}
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (!D.frame || frameRuntime(D.frame) === pt) {
|
|
179
|
+
// The frame on show started again (it reloaded itself under a preview):
|
|
180
|
+
// the page it shows now is the truth, and its recording has grown, so a
|
|
181
|
+
// moment of this timeline being built behind is out of date.
|
|
182
|
+
if (D.PT && D.PT !== pt && b && b.target != null && b.branchId === D.frameBranch) cancelBuild()
|
|
183
|
+
D.PT = pt
|
|
184
|
+
if (D.frame) hookFrameKeys(D.frame.contentWindow)
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
// Build a moment (a history and a time) in a frame behind the visible one,
|
|
188
|
+
// swap it in when it's ready. There's only ever one: a request for the
|
|
189
|
+
// moment already building keeps it, a later moment of the same recording on
|
|
190
|
+
// the same page moves it on (retarget), anything else replaces it.
|
|
191
|
+
rebuild(payload) {
|
|
192
|
+
const b = D.building
|
|
193
|
+
const sameRec = !!b && b.target != null && b.branchId === D.activeId && payload.sig != null && b.sig === payload.sig
|
|
194
|
+
if (sameRec && samePlace(b.target, payload.target)) {
|
|
195
|
+
b.play = b.play || !!payload.play
|
|
196
|
+
b.visible = true // asked for directly (the API): the dock's goTo() says otherwise if a preview shows it
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (sameRec && retargetBuild(b, payload.target)) {
|
|
200
|
+
b.via = "retarget"
|
|
201
|
+
b.play = !!payload.play
|
|
202
|
+
b.visible = true
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
cancelBuild()
|
|
206
|
+
// A frame built behind never plays by itself: the dock plays it once it's
|
|
207
|
+
// in (Play pressed meanwhile can be taken back).
|
|
208
|
+
const play = !!payload.play
|
|
209
|
+
payload = { ...payload, play: false }
|
|
210
|
+
if (useCheckpoint(payload)) {
|
|
211
|
+
Object.assign(D.building, { play, fork: false, visible: true, sig: payload.sig != null ? payload.sig : null })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
// Copied into this window: the payload object was made in the old frame,
|
|
215
|
+
// and the new frame keeps what take() gives it for its whole life, so
|
|
216
|
+
// passing it on would keep every earlier frame alive in a chain.
|
|
217
|
+
const stash = own(payload)
|
|
218
|
+
stash.buildId = ++D.buildSeq
|
|
219
|
+
let rec = null
|
|
220
|
+
const parsed = () => rec || (rec = JSON.parse(payload.rec))
|
|
221
|
+
// The page the target moment was on (the runtime says, per segment).
|
|
222
|
+
const u = new URL(payload.url || urlAt(parsed(), payload.target), location.href)
|
|
223
|
+
// Replay at the size it was recorded at, or layout, media queries and
|
|
224
|
+
// virtual lists come out differently (F18).
|
|
225
|
+
const vp = okViewport(stash.viewport) || recordedViewport(parsed())
|
|
226
|
+
window.__retakeShell.rebuilding = true
|
|
227
|
+
// It's built for whichever timeline is active now (a switch sets that
|
|
228
|
+
// before it loads the target's recording).
|
|
229
|
+
D.building = {
|
|
230
|
+
id: stash.buildId,
|
|
231
|
+
frame: null,
|
|
232
|
+
pt: null,
|
|
233
|
+
stash,
|
|
234
|
+
url: u.pathname + u.search + u.hash,
|
|
235
|
+
viewport: vp,
|
|
236
|
+
branchId: D.activeId,
|
|
237
|
+
sig: payload.sig != null ? payload.sig : null,
|
|
238
|
+
target: payload.target,
|
|
239
|
+
play,
|
|
240
|
+
fork: false,
|
|
241
|
+
visible: true,
|
|
242
|
+
startedAt: performance.now(),
|
|
243
|
+
via: "replay",
|
|
244
|
+
restarts: 0,
|
|
245
|
+
}
|
|
246
|
+
startBuildFrame(D.building)
|
|
247
|
+
},
|
|
248
|
+
// The runtime is about to cut off its future at `at`: keep it as a branch.
|
|
249
|
+
branchOff(json, end, at) {
|
|
250
|
+
// A moment still being built belongs to the timeline we're leaving; if it
|
|
251
|
+
// landed now it would bring that timeline's future onto the new one.
|
|
252
|
+
cancelBuild()
|
|
253
|
+
const old = activeBranch()
|
|
254
|
+
old.json = json
|
|
255
|
+
old.end = end
|
|
256
|
+
const b = newBranch(at, old.id)
|
|
257
|
+
b.version = old.version // a new timeline starts on its parent's code
|
|
258
|
+
D.activeId = b.id
|
|
259
|
+
D.frameBranch = b.id // the visible frame carries on as the new timeline
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The page a recording was on at t: its segment's (each reload or navigation
|
|
264
|
+
// of the app starts one), else where it began.
|
|
265
|
+
function urlAt(rec, t) {
|
|
266
|
+
let url = rec.url
|
|
267
|
+
for (const n of rec.segments || rec.navs || []) if (n.t <= t && n.url) url = new URL(n.url, rec.url).href
|
|
268
|
+
return url
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// A plain copy made in the dock's realm (values only).
|
|
272
|
+
function own(payload) {
|
|
273
|
+
const out = {}
|
|
274
|
+
for (const k of Object.keys(payload)) {
|
|
275
|
+
const v = payload[k]
|
|
276
|
+
out[k] = v !== null && typeof v === "object" ? JSON.parse(JSON.stringify(v)) : v
|
|
277
|
+
}
|
|
278
|
+
return out
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The viewport a recording was made at: the recording says, or the runtime's
|
|
282
|
+
// timeline() does.
|
|
283
|
+
const okViewport = (v) => (v && v.w > 0 && v.h > 0 ? { w: v.w, h: v.h } : null)
|
|
284
|
+
function recordedViewport(rec) {
|
|
285
|
+
if (okViewport(rec.viewport)) return okViewport(rec.viewport)
|
|
286
|
+
try {
|
|
287
|
+
const v = D.PT && D.PT.timeline && D.PT.timeline().viewport
|
|
288
|
+
if (okViewport(v)) return okViewport(v)
|
|
289
|
+
} catch {}
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Behind the front server (config.docs) every page the frame loads is kept as
|
|
294
|
+
// it came, and a build asks for the copy its recording was made on with a
|
|
295
|
+
// one-shot cookie (the server takes it out as it serves it), so a server whose
|
|
296
|
+
// data or clock has moved on doesn't change the rebuilt page (F56).
|
|
297
|
+
const DOCS = !!(window.__retakeConfig && window.__retakeConfig.docs)
|
|
298
|
+
function askStoredDoc(doc, url) {
|
|
299
|
+
if (!DOCS || !doc || !/^[0-9a-f]{16}$/.test(doc)) return
|
|
300
|
+
try {
|
|
301
|
+
document.cookie = `__retake_doc=${doc}; path=${new URL(url, location.href).pathname}; max-age=10; samesite=strict`
|
|
302
|
+
} catch {}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The frame for a build (again, after a restart), hidden behind the visible one.
|
|
306
|
+
function startBuildFrame(b) {
|
|
307
|
+
askStoredDoc(b.stash && b.stash.doc, b.url)
|
|
308
|
+
b.frame = makeFrame(b.url, b.stash)
|
|
309
|
+
b.frame.setAttribute("aria-hidden", "true")
|
|
310
|
+
if (b.viewport) {
|
|
311
|
+
b.frame.style.width = b.viewport.w + "px"
|
|
312
|
+
b.frame.style.height = b.viewport.h + "px"
|
|
313
|
+
}
|
|
314
|
+
b.pt = null
|
|
315
|
+
b.startedAt = performance.now()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Does the frame on show (its state vs) show the moment build b is making:
|
|
319
|
+
// previewing it, or standing at it (a build of its own recording: an API seek,
|
|
320
|
+
// or a frame whose IndexedDB another frame has changed since)?
|
|
321
|
+
function showsMoment(vs, b) {
|
|
322
|
+
if (!vs || b.branchId !== D.frameBranch) return false
|
|
323
|
+
if (vs.previewing) return samePlace(vs.previewAt, b.target)
|
|
324
|
+
return b.sig != null && !vs.seeking && vs.target == null && samePlace(vs.now, b.target)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Which page (segment) of the active recording a moment is on.
|
|
328
|
+
function segOf(t) {
|
|
329
|
+
let i = 0
|
|
330
|
+
try {
|
|
331
|
+
for (const s of D.PT.history().segments || []) if (s.t <= t) i++
|
|
332
|
+
} catch {}
|
|
333
|
+
return i
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Move the build in flight on to t, if it can get there (same page, not
|
|
337
|
+
// past it already). Before its runtime starts that's just a new target in
|
|
338
|
+
// the stash it will take (if the dock can tell it's the same page: it's the
|
|
339
|
+
// recording on show); after, the runtime decides (PT.retarget).
|
|
340
|
+
function retargetBuild(b, t) {
|
|
341
|
+
if (!b.frame) return false
|
|
342
|
+
if (!b.pt) {
|
|
343
|
+
if (!b.stash || b.sig == null || segOf(t) !== segOf(b.target)) return false
|
|
344
|
+
b.stash.target = b.target = t
|
|
345
|
+
return true
|
|
346
|
+
}
|
|
347
|
+
let ok = false
|
|
348
|
+
try {
|
|
349
|
+
ok = typeof b.pt.retarget === "function" && !!b.pt.retarget(t)
|
|
350
|
+
} catch {}
|
|
351
|
+
if (ok) b.target = t
|
|
352
|
+
return ok
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// The same build from the start in a new frame (its frame reloaded itself,
|
|
356
|
+
// never started, or ran by itself). Twice at most.
|
|
357
|
+
function restartBuild(b) {
|
|
358
|
+
if (D.building !== b) return
|
|
359
|
+
if (b.restarts >= 2 || (!b.stash && !(b.target != null && b.branchId === D.frameBranch && D.PT && typeof D.PT.buildAt === "function"))) {
|
|
360
|
+
cancelBuild()
|
|
361
|
+
flash("Couldn't build that moment")
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (!b.stash) {
|
|
365
|
+
// A checkpoint's frame (there's no stash to start it from again): the
|
|
366
|
+
// frame on show asks for the moment afresh, and the user's wishes carry over.
|
|
367
|
+
const { target, play, fork, restarts } = b
|
|
368
|
+
cancelBuild()
|
|
369
|
+
try {
|
|
370
|
+
D.PT.buildAt(target)
|
|
371
|
+
} catch {}
|
|
372
|
+
const nb = D.building
|
|
373
|
+
if (nb && nb !== b) Object.assign(nb, { play, fork, restarts: restarts + 1 })
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
b.restarts++
|
|
377
|
+
try {
|
|
378
|
+
b.frame.remove()
|
|
379
|
+
} catch {}
|
|
380
|
+
b.stash = { ...own(b.stash), target: b.target, buildId: b.id }
|
|
381
|
+
startBuildFrame(b)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// A build whose frame hasn't started after a while (D.buildWatchdogMs): a page
|
|
385
|
+
// that finished loading without starting it (an error page, a page without the
|
|
386
|
+
// runtime) never will. One still loading (a slow module script, a cold dev
|
|
387
|
+
// server optimizing its dependencies) is left to load, unless it takes
|
|
388
|
+
// absurdly long.
|
|
389
|
+
const BUILD_GIVE_UP = 60000
|
|
390
|
+
function buildStalled(b) {
|
|
391
|
+
const waited = performance.now() - b.startedAt
|
|
392
|
+
if (waited <= D.buildWatchdogMs) return false
|
|
393
|
+
let doc = null
|
|
394
|
+
try {
|
|
395
|
+
doc = b.frame.contentDocument
|
|
396
|
+
} catch {}
|
|
397
|
+
if (!doc) return true // not this app's page any more (another origin, an error page)
|
|
398
|
+
if (doc.readyState === "complete" && doc.URL !== "about:blank") return true
|
|
399
|
+
return waited > BUILD_GIVE_UP
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Drop the build in flight (and a checkpoint still being built: one frame
|
|
403
|
+
// runs the app at a time). A checkpoint that's ready stays.
|
|
404
|
+
function cancelBuild() {
|
|
405
|
+
if (D.cp && !checkpointReady(D.cp)) dropCheckpoint()
|
|
406
|
+
const b = D.building
|
|
407
|
+
if (!b) return
|
|
408
|
+
D.building = null
|
|
409
|
+
window.__retakeShell.rebuilding = false
|
|
410
|
+
if (b.frame && b.frame !== D.frame) b.frame.remove()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// A fresh prototype: no history.
|
|
414
|
+
function freshFrame() {
|
|
415
|
+
cancelBuild()
|
|
416
|
+
dropCheckpoint()
|
|
417
|
+
D.building = { id: null, frame: makeFrame(appUrl), pt: null, branchId: D.activeId, target: null, play: false, fork: false, visible: true, startedAt: performance.now(), via: "fresh", restarts: 0 }
|
|
418
|
+
window.__retakeShell.rebuilding = true
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// The first frame is opened by restore() (15-session.js) once it knows what
|
|
422
|
+
// was saved: always at the page the user asked for, live, never a rebuild.
|
|
423
|
+
function openFirstFrame(stash, branchId) {
|
|
424
|
+
if (D.frame) return
|
|
425
|
+
D.frame = makeFrame(appUrl, stash)
|
|
426
|
+
D.frame.className = "live"
|
|
427
|
+
D.frameBranch = branchId
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Each dock frame: keep the build in flight honest, and swap it in once it's
|
|
431
|
+
// at its moment. Unless the user is waiting for it, that also waits for
|
|
432
|
+
// nothing to be in hand: a drag, a key step, a note being written, a picking
|
|
433
|
+
// tool, a scoped preview (they all point into the frame on show).
|
|
434
|
+
function checkBuilding() {
|
|
435
|
+
const b = D.building
|
|
436
|
+
if (!b) return
|
|
437
|
+
let vs = null
|
|
438
|
+
try {
|
|
439
|
+
vs = D.PT && D.PT.state()
|
|
440
|
+
} catch {}
|
|
441
|
+
if (b.target != null) {
|
|
442
|
+
// Built for a timeline we've since left.
|
|
443
|
+
if (b.branchId !== D.activeId) return cancelBuild()
|
|
444
|
+
// The frame on show runs its app again (played, ⌥P, an in-place seek
|
|
445
|
+
// somewhere; not one just stopped for a preview): that's where the user
|
|
446
|
+
// is now, not this moment.
|
|
447
|
+
if (vs && (vs.playing || (vs.seeking && vs.target != null && vs.target > vs.now))) return cancelBuild()
|
|
448
|
+
// The Select tool scoped a preview: it stays a picture.
|
|
449
|
+
if (D.scopeEl && !waits(b)) return cancelBuild()
|
|
450
|
+
}
|
|
451
|
+
// Is the moment on screen meanwhile (the frame on show previews it, or is
|
|
452
|
+
// at it)? If not (the preview was ended from elsewhere, or it's on an
|
|
453
|
+
// earlier page), the user is waiting for it.
|
|
454
|
+
if (b.target != null && D.dragT == null && D.keyT == null) b.visible = !showsMoment(vs, b)
|
|
455
|
+
if (!b.pt) {
|
|
456
|
+
if (buildStalled(b)) restartBuild(b)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
let s
|
|
460
|
+
try {
|
|
461
|
+
s = b.pt.state()
|
|
462
|
+
} catch {
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
// A moment built behind never runs by itself (only the dock plays a frame,
|
|
466
|
+
// once it's in): one that does has left its moment. Build it again. (A fresh
|
|
467
|
+
// frame does: it records from its first moment.)
|
|
468
|
+
if (b.target != null && s.playing) {
|
|
469
|
+
try {
|
|
470
|
+
b.pt.pause()
|
|
471
|
+
} catch {}
|
|
472
|
+
return restartBuild(b)
|
|
473
|
+
}
|
|
474
|
+
if (!s.booted || s.seeking || s.target != null) return
|
|
475
|
+
if (b.target != null && !samePlace(s.now, b.target)) return
|
|
476
|
+
if (D.holdSwap) return
|
|
477
|
+
if (!waits(b) && (D.dragT != null || D.keyT != null || composing() || mode() || D.scopeEl)) return
|
|
478
|
+
if (frameRuntime(b.frame) !== b.pt) return // it reloaded itself; attach() starts it again
|
|
479
|
+
swapIn(b)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Returns true only if the switch happened.
|
|
483
|
+
async function switchTo(id, t) {
|
|
484
|
+
const target = branchById(id)
|
|
485
|
+
if (!D.PT || !target || D.switching || id === D.activeId) return false
|
|
486
|
+
D.switching = true
|
|
487
|
+
try {
|
|
488
|
+
const json = await recordingOf(target)
|
|
489
|
+
if (!json || !D.PT) return false
|
|
490
|
+
const cur = activeBranch()
|
|
491
|
+
// A timeline made on other code runs on its own version of the code.
|
|
492
|
+
if (target.version && cur && cur.version && target.version !== cur.version) {
|
|
493
|
+
const r = await checkoutCode(target.version)
|
|
494
|
+
if (!r.ok) return false
|
|
495
|
+
await settleLeftVersion(cur, r)
|
|
496
|
+
}
|
|
497
|
+
if (cur) {
|
|
498
|
+
cur.json = JSON.stringify(D.PT.history())
|
|
499
|
+
cur.end = D.PT.state().end
|
|
500
|
+
}
|
|
501
|
+
D.activeId = id
|
|
502
|
+
D.PT.load(json, clamp(t, target.forkAt, target.end))
|
|
503
|
+
return true
|
|
504
|
+
} finally {
|
|
505
|
+
D.switching = false
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Bookmarks: dropped on a timeline at a moment, to come back to.
|
|
510
|
+
function addFlag() {
|
|
511
|
+
const s = state()
|
|
512
|
+
if (!s || !s.started) return
|
|
513
|
+
D.markers.push({ id: ++D.markerSeq, t: shownTime(s), branchId: D.activeId })
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Keep the address bar and title in step with the prototype's own route (the
|
|
517
|
+
// route at the moment previewed, while a preview is on show).
|
|
518
|
+
setInterval(() => {
|
|
519
|
+
try {
|
|
520
|
+
const inner = new URL(D.frame.contentWindow.location.href)
|
|
521
|
+
inner.searchParams.delete("__wb")
|
|
522
|
+
const s = D.PT && D.PT.state()
|
|
523
|
+
const next = (s && s.previewing && s.route) || inner.pathname + inner.search + inner.hash
|
|
524
|
+
if (next !== location.pathname + location.search + location.hash) history.replaceState(null, "", next)
|
|
525
|
+
if (D.frame.contentDocument.title) document.title = D.frame.contentDocument.title
|
|
526
|
+
} catch {}
|
|
527
|
+
}, 400)
|
|
528
|
+
|
|
529
|
+
const state = () => {
|
|
530
|
+
try {
|
|
531
|
+
if (D.PT) D.last = D.PT.state()
|
|
532
|
+
} catch {
|
|
533
|
+
D.PT = null
|
|
534
|
+
}
|
|
535
|
+
return D.last
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function render() {
|
|
539
|
+
D.frameNo++
|
|
540
|
+
checkBuilding()
|
|
541
|
+
dropStaleResume()
|
|
542
|
+
tendCheckpoint()
|
|
543
|
+
guardFocus()
|
|
544
|
+
// The active timeline can vanish under us (a delete racing a switch); stand
|
|
545
|
+
// on the first one rather than draw nothing.
|
|
546
|
+
if (!activeBranch()) D.activeId = D.branches[0].id
|
|
547
|
+
const s = state()
|
|
548
|
+
const dh = dockHeight()
|
|
549
|
+
dock.style.height = dh + "px"
|
|
550
|
+
document.body.style.setProperty("--dock-h", dh + "px")
|
|
551
|
+
if (!s || !D.frame) return
|
|
552
|
+
autoStart(s)
|
|
553
|
+
if (D.perfNoDraw) return // (perf measurements: the runtime alone)
|
|
554
|
+
const active = activeBranch()
|
|
555
|
+
// Only the active timeline's own frame says how far it goes: mid-switch the
|
|
556
|
+
// visible frame is still the timeline we're leaving.
|
|
557
|
+
// Its recording is the truth, which also mends an end saved wrongly before.
|
|
558
|
+
if (s.started && D.frameBranch === D.activeId && !s.seeking) active.end = s.end
|
|
559
|
+
const shownT = shownTime(s)
|
|
560
|
+
renderHead(s, shownT)
|
|
561
|
+
renderShield(s)
|
|
562
|
+
if (s.started) renderTimeline(s, shownT)
|
|
563
|
+
renderExtras(s)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// A frame built behind can take the window's focus (its replay focused a
|
|
567
|
+
// field). If the user was typing in the dock (a note, a timeline's name),
|
|
568
|
+
// they get it back.
|
|
569
|
+
let dockField = null
|
|
570
|
+
document.addEventListener("focusin", (e) => {
|
|
571
|
+
const el = /** @type {HTMLElement | null} */ (e.target)
|
|
572
|
+
if (el && (el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)) dockField = el
|
|
573
|
+
})
|
|
574
|
+
function guardFocus() {
|
|
575
|
+
if (!dockField) return
|
|
576
|
+
const a = document.activeElement
|
|
577
|
+
if (a === dockField) return
|
|
578
|
+
if (dockField.isConnected && a && a.tagName === "IFRAME" && (a.classList.contains("building") || a.classList.contains("checkpoint"))) {
|
|
579
|
+
try {
|
|
580
|
+
dockField.focus({ preventScroll: true })
|
|
581
|
+
return
|
|
582
|
+
} catch {}
|
|
583
|
+
}
|
|
584
|
+
dockField = null
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// A frame that reloads itself leaves shell.__resume for its next document.
|
|
588
|
+
// A frame we removed can leave one too (pagehide runs as it goes), and that
|
|
589
|
+
// object, made in its realm, would keep the whole old window alive.
|
|
590
|
+
function dropStaleResume() {
|
|
591
|
+
const r = window.__retakeShell.__resume
|
|
592
|
+
if (r && (!r.frame || !r.frame.isConnected)) window.__retakeShell.__resume = null
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Recording is always on from page load: a runtime that waits for Record gets
|
|
596
|
+
// it once, as soon as it has booted.
|
|
597
|
+
// (A WeakSet, so a runtime we've moved on from can be collected.)
|
|
598
|
+
const autoStarted = new WeakSet()
|
|
599
|
+
function autoStart(s) {
|
|
600
|
+
if (s.started || !s.booted || D.building || autoStarted.has(D.PT)) return
|
|
601
|
+
autoStarted.add(D.PT)
|
|
602
|
+
D.PT.record()
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// In the past the app is view-only: a clear shield takes its pointer events
|
|
606
|
+
// and keyboard focus stays with the dock. The comment and select tools see
|
|
607
|
+
// through it (they work on the past).
|
|
608
|
+
const shield = $("#wb-shield")
|
|
609
|
+
function renderShield(s) {
|
|
610
|
+
const block = !!s.started && !isInteractive(s) && !mode()
|
|
611
|
+
shield.hidden = !block
|
|
612
|
+
// In the past, keys belong to the dock, so the visible frame shouldn't keep
|
|
613
|
+
// focus. Never touch the frame being built: its replay moves focus around
|
|
614
|
+
// (a replayed click on a textarea) and the keys it replays need that focus.
|
|
615
|
+
if (block && D.frame && document.activeElement === D.frame && !D.building) {
|
|
616
|
+
D.frame.blur()
|
|
617
|
+
window.focus()
|
|
618
|
+
}
|
|
619
|
+
if (!block) {
|
|
620
|
+
if (wasBlocked) restoreFocus()
|
|
621
|
+
else noteFocus(D.frame)
|
|
622
|
+
}
|
|
623
|
+
wasBlocked = block
|
|
624
|
+
}
|
|
625
|
+
// Pausing takes focus off the app's field (clicking the dock alone does);
|
|
626
|
+
// playing again gives it back, with the caret where it was. Remembered while
|
|
627
|
+
// the app is interactive, and only given back to that same document: a
|
|
628
|
+
// rebuilt frame's focus is its replay's business.
|
|
629
|
+
let wasBlocked = false
|
|
630
|
+
let lastFocus = null
|
|
631
|
+
function noteFocus(frame) {
|
|
632
|
+
try {
|
|
633
|
+
const doc = frame && frame.contentDocument
|
|
634
|
+
const el = doc && doc.activeElement
|
|
635
|
+
// Focus on the page itself: the user left the field (keep what we had
|
|
636
|
+
// only if focus went out to the dock instead).
|
|
637
|
+
if (!el || el === doc.body) {
|
|
638
|
+
if (doc.hasFocus()) lastFocus = null
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
lastFocus = { frame, doc, el, sel: typeof el.selectionStart === "number" ? [el.selectionStart, el.selectionEnd] : null }
|
|
642
|
+
} catch {}
|
|
643
|
+
}
|
|
644
|
+
function restoreFocus() {
|
|
645
|
+
const f = lastFocus
|
|
646
|
+
lastFocus = null
|
|
647
|
+
try {
|
|
648
|
+
if (!f || f.frame !== D.frame || f.frame.contentDocument !== f.doc || !f.el.isConnected) return
|
|
649
|
+
D.frame.focus()
|
|
650
|
+
f.el.focus({ preventScroll: true })
|
|
651
|
+
if (f.sel && f.el.setSelectionRange) f.el.setSelectionRange(f.sel[0], f.sel[1])
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
// One bad frame must never stop the dock: log it and keep going.
|
|
655
|
+
let renderErrors = 0
|
|
656
|
+
requestAnimationFrame(function loop() {
|
|
657
|
+
try {
|
|
658
|
+
render()
|
|
659
|
+
} catch (err) {
|
|
660
|
+
if (renderErrors++ < 5) console.error("[retake] dock render failed", err)
|
|
661
|
+
}
|
|
662
|
+
requestAnimationFrame(loop)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
const refocus = () => D.frame && D.frame.contentWindow && D.frame.contentWindow.focus()
|
|
666
|
+
|
|
667
|
+
document.addEventListener("click", (e) => {
|
|
668
|
+
const target = /** @type {Element} */ (e.target)
|
|
669
|
+
const b = /** @type {HTMLButtonElement | null} */ (target.closest("button, [data-branch], [data-note]"))
|
|
670
|
+
if (!b) {
|
|
671
|
+
if (!target.closest(".card")) closeCard()
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const a = b.dataset.a
|
|
675
|
+
if (a === "play") togglePlay()
|
|
676
|
+
if (a === "fresh") return startFresh()
|
|
677
|
+
if (a === "undo") return undoFresh()
|
|
678
|
+
if (a === "live") return followLive()
|
|
679
|
+
if (b.dataset.deleteTimeline) {
|
|
680
|
+
menuEl.hidden = true
|
|
681
|
+
deleteTimeline(Number(b.dataset.deleteTimeline)).then((ok) => ok || flash("Couldn't delete that timeline right now"))
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
if (b.dataset.renameTimeline) {
|
|
685
|
+
menuEl.hidden = true
|
|
686
|
+
renameLane(Number(b.dataset.renameTimeline))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
// Tools: Hand (nothing picked, just use the prototype), Select, Comment.
|
|
690
|
+
if (b.dataset.tool && !b.disabled) setPicking(b.dataset.tool === "hand" ? null : b.dataset.tool)
|
|
691
|
+
if (handleNoteClick(b)) return
|
|
692
|
+
if (isInteractive()) refocus()
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// The dock never grows by itself (that would resize the app mid-recording);
|
|
696
|
+
// only the divider changes it.
|
|
697
|
+
const MIN_H = 96
|
|
698
|
+
// Never so short that the lanes (at their closest) don't fit under the ruler.
|
|
699
|
+
const dockHeight = () => {
|
|
700
|
+
const min = Math.max(MIN_H, 56 + 24 + D.branches.length * 10 + 16)
|
|
701
|
+
return Math.round(clamp(D.height, min, Math.max(min, innerHeight * 0.7)))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Resize by dragging the top edge, like docked DevTools.
|
|
705
|
+
const divider = $(".divider")
|
|
706
|
+
divider.addEventListener("pointerdown", (e) => {
|
|
707
|
+
divider.setPointerCapture(e.pointerId)
|
|
708
|
+
document.body.classList.add("dragging")
|
|
709
|
+
const startY = e.clientY
|
|
710
|
+
const startH = dock.offsetHeight
|
|
711
|
+
const move = (ev) => {
|
|
712
|
+
D.height = Math.round(clamp(startH + startY - ev.clientY, MIN_H, innerHeight * 0.7))
|
|
713
|
+
}
|
|
714
|
+
const up = () => {
|
|
715
|
+
document.body.classList.remove("dragging")
|
|
716
|
+
divider.removeEventListener("pointermove", move)
|
|
717
|
+
divider.removeEventListener("pointerup", up)
|
|
718
|
+
store.set("height", D.height)
|
|
719
|
+
}
|
|
720
|
+
divider.addEventListener("pointermove", move)
|
|
721
|
+
divider.addEventListener("pointerup", up)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
window.addEventListener("keydown", (e) => {
|
|
725
|
+
if (e.key === "Meta") return window.__retakeShell.meta(true)
|
|
726
|
+
if (e.key === "Escape") {
|
|
727
|
+
setPicking(null)
|
|
728
|
+
closeCard()
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (e.altKey && e.code === "KeyP") {
|
|
732
|
+
e.preventDefault()
|
|
733
|
+
return togglePlay()
|
|
734
|
+
}
|
|
735
|
+
if (dockKey(e)) e.preventDefault()
|
|
736
|
+
})
|
|
737
|
+
// Keys pressed inside a view-only app are the dock's too.
|
|
738
|
+
function hookFrameKeys(win) {
|
|
739
|
+
try {
|
|
740
|
+
if (!win || win.__retakeKeys) return
|
|
741
|
+
win.__retakeKeys = true
|
|
742
|
+
win.addEventListener(
|
|
743
|
+
"keydown",
|
|
744
|
+
(e) => {
|
|
745
|
+
if (isInteractive() || !dockKey(e)) return
|
|
746
|
+
e.preventDefault()
|
|
747
|
+
e.stopImmediatePropagation()
|
|
748
|
+
},
|
|
749
|
+
true,
|
|
750
|
+
)
|
|
751
|
+
} catch {}
|
|
752
|
+
}
|
|
753
|
+
window.addEventListener("keyup", (e) => e.key === "Meta" && window.__retakeShell.meta(false))
|
|
754
|
+
window.addEventListener("blur", () => window.__retakeShell.meta(false))
|