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.
Files changed (43) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +198 -0
  3. package/bin/retake.js +319 -0
  4. package/package.json +87 -0
  5. package/src/code-versions.js +357 -0
  6. package/src/core.js +265 -0
  7. package/src/plugin.js +137 -0
  8. package/src/runtime/00-core.js +308 -0
  9. package/src/runtime/10-animations.js +362 -0
  10. package/src/runtime/20-media.js +157 -0
  11. package/src/runtime/30-recorder.js +315 -0
  12. package/src/runtime/32-state.js +315 -0
  13. package/src/runtime/35-network.js +789 -0
  14. package/src/runtime/36-scripts.js +129 -0
  15. package/src/runtime/37-next.js +150 -0
  16. package/src/runtime/38-observers.js +252 -0
  17. package/src/runtime/40-input.js +795 -0
  18. package/src/runtime/45-hover.js +78 -0
  19. package/src/runtime/50-engine.js +341 -0
  20. package/src/runtime/60-preview.js +437 -0
  21. package/src/runtime/65-timeline.js +289 -0
  22. package/src/runtime/66-activity.js +114 -0
  23. package/src/runtime/67-csssource.js +226 -0
  24. package/src/runtime/70-boot.js +460 -0
  25. package/src/server/api.js +285 -0
  26. package/src/server/child.js +174 -0
  27. package/src/server/detect.js +167 -0
  28. package/src/server/front.js +647 -0
  29. package/src/server/mcp.js +332 -0
  30. package/src/shell/00-state.js +85 -0
  31. package/src/shell/05-api.js +136 -0
  32. package/src/shell/10-dock.js +754 -0
  33. package/src/shell/12-checkpoint.js +121 -0
  34. package/src/shell/15-session.js +291 -0
  35. package/src/shell/20-timeline.js +734 -0
  36. package/src/shell/25-input.js +537 -0
  37. package/src/shell/30-notes.js +870 -0
  38. package/src/shell/40-code.js +80 -0
  39. package/src/shell/90-handle.js +15 -0
  40. package/src/shell/shell.css +295 -0
  41. package/src/shell/shell.html +59 -0
  42. package/types/client.d.ts +73 -0
  43. package/types/index.d.ts +111 -0
@@ -0,0 +1,795 @@
1
+ // Captures real input into the recording and re-dispatches it during replay.
2
+ // Targets are stored as child-index paths from <html>; replay reproduces the
3
+ // same DOM, so the same path finds the same element.
4
+ //
5
+ // Events are stored compactly: fields at their usual value are left out and
6
+ // filled back in on replay (see DEFAULTS).
7
+
8
+ const POINTER = ["pointerdown", "pointerup", "pointermove", "pointerover", "pointerout", "pointerenter", "pointerleave", "pointercancel"]
9
+ const MOUSE = ["mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave", "click", "dblclick", "contextmenu", "auxclick"]
10
+ const KEYS = ["keydown", "keyup", "keypress"]
11
+ const TOUCH = ["touchstart", "touchmove", "touchend", "touchcancel"]
12
+ const DRAG = ["dragstart", "drag", "dragenter", "dragover", "dragleave", "drop", "dragend"]
13
+ const CLIP = ["paste", "copy", "cut"]
14
+ const COMPOSE = ["compositionstart", "compositionupdate", "compositionend"]
15
+ const OTHER = ["input", "beforeinput", "change", "focusin", "focusout", "focus", "blur", "scroll", "wheel", "submit"]
16
+ const HOVER = new Set(["pointermove", "pointerover", "pointerout", "pointerenter", "pointerleave", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave"])
17
+ const SCROLL_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End", " "])
18
+
19
+ let dispatching = 0
20
+ let missingTargets = 0
21
+ let lastScreenOffset = null // recording
22
+ let replayScreenOffset = [0, 0] // replay
23
+
24
+ // True from an input event until the current task ends (a message on a
25
+ // private channel clears it at the start of the next task).
26
+ let inputTask = false
27
+ const taskChannel = new RealChannel()
28
+ taskChannel.port1.onmessage = () => (inputTask = false)
29
+ function markInputTask() {
30
+ if (inputTask) return
31
+ inputTask = true
32
+ portPost.call(taskChannel.port2, 0)
33
+ }
34
+
35
+ // Virtual focus: while rebuilding, the frame being built doesn't have real
36
+ // focus (the visible frame or the dock does), so the browser keeps moving
37
+ // focus away from what the recording focused. The recording's own focus is
38
+ // tracked here: keys go to it, and document.activeElement reports it.
39
+ let vFocus = null
40
+ const realActive = Object.getOwnPropertyDescriptor(Document.prototype, "activeElement").get
41
+ const focused = () => (vFocus && vFocus.isConnected ? vFocus : null)
42
+ Object.defineProperty(document, "activeElement", {
43
+ configurable: true,
44
+ get() {
45
+ return (clock.seeking && focused()) || realActive.call(document)
46
+ },
47
+ })
48
+
49
+ // Paths to an element. A recording made since pathV 2 (F51) starts at the
50
+ // nearest ancestor with a hand-written id (`["#banner", 0, 1]`) and counts
51
+ // element children only, leaving out the ones that come and go with the
52
+ // server or the dev tools (script, style, link, template, Next's portals,
53
+ // Vite's overlay...), so content a script adds in real time elsewhere doesn't
54
+ // shift it. A text node's last step is its child-node index, as -(i + 1).
55
+ // Older recordings index every child node from <html>.
56
+ const PATH_SKIP = /^(SCRIPT|STYLE|LINK|NOSCRIPT|TEMPLATE|NEXTJS-PORTAL|NEXT-ROUTE-ANNOUNCER|VITE-ERROR-OVERLAY|ASTRO-DEV-TOOLBAR)$/
57
+ // Ids a library made up (React's useId, Radix, Headless UI, MUI...) differ
58
+ // between renders; they're not anchors.
59
+ const MADE_UP_ID = /[:«»]|^_?r_|^(radix|headlessui|mui|react-aria|rc-|ember|ext-gen)|\d{4,}/i
60
+ const stepsOf = (parent) => Array.prototype.filter.call(parent.children, (c) => !PATH_SKIP.test(c.tagName))
61
+ const pathV2 = () => !!rec && rec.pathV === 2
62
+
63
+ function anchorId(el) {
64
+ const id = el.id
65
+ if (!id || MADE_UP_ID.test(id) || el === document.documentElement) return null
66
+ return document.getElementById(id) === el ? id : null
67
+ }
68
+
69
+ function pathOf(node) {
70
+ if (node === W) return "w"
71
+ if (node === document) return "d"
72
+ if (!pathV2()) return pathV1(node)
73
+ const path = []
74
+ if (node && node.nodeType !== 1) {
75
+ const parent = node.parentNode
76
+ if (!parent || parent.nodeType !== 1) return null
77
+ path.unshift(-1 - Array.prototype.indexOf.call(parent.childNodes, node))
78
+ node = parent
79
+ }
80
+ while (node && node !== document.documentElement) {
81
+ const id = anchorId(node)
82
+ if (id) {
83
+ path.unshift("#" + id)
84
+ return path
85
+ }
86
+ const parent = node.parentNode
87
+ if (!parent || parent.nodeType !== 1) return null
88
+ const i = stepsOf(parent).indexOf(node)
89
+ if (i < 0) return pathV1Within(path, node) // a skipped element itself (a <script>)
90
+ path.unshift(i)
91
+ node = parent
92
+ }
93
+ return node ? path : null
94
+ }
95
+ // A skipped element is targeted by its child-node index (like a text node).
96
+ function pathV1Within(path, node) {
97
+ const parent = node.parentNode
98
+ path.unshift(-1 - Array.prototype.indexOf.call(parent.childNodes, node))
99
+ const up = pathOf(parent)
100
+ return up && Array.isArray(up) ? [...up, ...path] : null
101
+ }
102
+
103
+ function pathV1(node) {
104
+ const path = []
105
+ while (node && node !== document.documentElement) {
106
+ const parent = node.parentNode
107
+ if (!parent || parent.nodeType !== 1) return null
108
+ path.unshift(Array.prototype.indexOf.call(parent.childNodes, node))
109
+ node = parent
110
+ }
111
+ return node ? path : null
112
+ }
113
+
114
+ function resolvePath(path) {
115
+ if (path === "w") return W
116
+ if (path === "d") return document
117
+ if (!Array.isArray(path)) return null
118
+ /** @type {any} */
119
+ let node = document.documentElement
120
+ let i = 0
121
+ if (typeof path[0] === "string" && path[0][0] === "#") {
122
+ node = document.getElementById(path[0].slice(1))
123
+ i = 1
124
+ } else if (!pathV2()) {
125
+ for (const k of path) node = node && node.childNodes[k]
126
+ return node || null
127
+ }
128
+ for (; i < path.length && node; i++) {
129
+ const k = path[i]
130
+ node = k < 0 ? node.childNodes[-1 - k] : stepsOf(node)[k]
131
+ }
132
+ return node || null
133
+ }
134
+
135
+ // Usual values, left out of the recording.
136
+ const DEFAULTS = {
137
+ bubbles: true,
138
+ cancelable: true,
139
+ composed: true,
140
+ pointerId: 1,
141
+ pointerType: "mouse",
142
+ isPrimary: true,
143
+ width: 1,
144
+ height: 1,
145
+ }
146
+ const NUMS = ["clientX", "clientY", "screenX", "screenY", "button", "buttons", "detail", "movementX", "movementY", "pressure", "location", "keyCode", "which", "charCode", "deltaX", "deltaY", "deltaZ", "deltaMode"]
147
+ const BOOLS = ["ctrlKey", "shiftKey", "altKey", "metaKey", "repeat", "isComposing"]
148
+
149
+ function put(ev, k, v) {
150
+ if (v === undefined || v === null) return
151
+ if (k in DEFAULTS) {
152
+ if (v !== DEFAULTS[k]) ev[k] = v
153
+ } else if (typeof v === "number") {
154
+ if (v !== 0) ev[k] = v
155
+ } else if (typeof v === "boolean") {
156
+ if (v) ev[k] = v
157
+ } else if (v !== "") ev[k] = v
158
+ }
159
+
160
+ function touchList(list) {
161
+ return [...(list || [])].map((t) => ({ id: t.identifier, x: t.clientX, y: t.clientY, sx: t.screenX, sy: t.screenY, path: pathOf(t.target) }))
162
+ }
163
+
164
+ function transferData(dt) {
165
+ if (!dt) return undefined
166
+ const out = {}
167
+ try {
168
+ for (const type of dt.types || []) if (type !== "Files") out[type] = dt.getData(type)
169
+ } catch {}
170
+ return Object.keys(out).length ? out : undefined
171
+ }
172
+
173
+ function serialize(e) {
174
+ const ev = { type: e.type, path: pathOf(e.target) }
175
+ for (const k of ["bubbles", "cancelable", "composed"]) put(ev, k, e[k])
176
+ if (e instanceof MouseEvent) {
177
+ for (const k of ["clientX", "clientY", "buttons", "detail", "movementX", "movementY"]) put(ev, k, e[k])
178
+ // button: -1 is usual for moves, 0 otherwise
179
+ if (e.button !== (/move|over|out|enter|leave/.test(e.type) ? -1 : 0)) ev.button = e.button
180
+ // screen = client + the window's offset, stored only when it changes
181
+ const so = [e.screenX - e.clientX, e.screenY - e.clientY]
182
+ if (!lastScreenOffset || lastScreenOffset[0] !== so[0] || lastScreenOffset[1] !== so[1]) ev.so = lastScreenOffset = so
183
+ if (e.relatedTarget) ev.related = pathOf(e.relatedTarget)
184
+ }
185
+ if (e instanceof PointerEvent) for (const k of ["pointerId", "pointerType", "isPrimary", "width", "height", "pressure"]) put(ev, k, e[k])
186
+ if (typeof WheelEvent !== "undefined" && e instanceof WheelEvent) for (const k of ["deltaX", "deltaY", "deltaZ", "deltaMode"]) put(ev, k, e[k])
187
+ if (e instanceof MouseEvent || e instanceof KeyboardEvent || (typeof TouchEvent !== "undefined" && e instanceof TouchEvent)) for (const k of BOOLS) put(ev, k, e[k])
188
+ if (e instanceof KeyboardEvent) {
189
+ if (e.target === document.activeElement) ev.active = true
190
+ for (const k of ["key", "code"]) put(ev, k, e[k])
191
+ for (const k of ["location", "keyCode", "which", "charCode"]) put(ev, k, e[k])
192
+ }
193
+ if (typeof TouchEvent !== "undefined" && e instanceof TouchEvent) {
194
+ ev.touches = touchList(e.touches)
195
+ ev.targetTouches = touchList(e.targetTouches)
196
+ ev.changedTouches = touchList(e.changedTouches)
197
+ }
198
+ if (typeof DragEvent !== "undefined" && e instanceof DragEvent) ev.dt = transferData(e.dataTransfer)
199
+ if (typeof ClipboardEvent !== "undefined" && e instanceof ClipboardEvent) ev.dt = transferData(e.clipboardData)
200
+ if (typeof CompositionEvent !== "undefined" && e instanceof CompositionEvent) put(ev, "data", e.data)
201
+ if (e.type === "beforeinput") {
202
+ put(ev, "inputType", e.inputType)
203
+ put(ev, "data", e.data)
204
+ }
205
+ if (e.type === "input" || e.type === "change") {
206
+ const el = e.target
207
+ ev.value = el.isContentEditable ? el.innerHTML : el.value
208
+ put(ev, "inputType", e.inputType)
209
+ put(ev, "data", e.data)
210
+ if (el.type === "checkbox" || el.type === "radio") ev.checked = !!el.checked
211
+ try {
212
+ if (typeof el.selectionStart === "number") ev.range = [el.selectionStart, el.selectionEnd]
213
+ } catch {}
214
+ }
215
+ if (e.type === "submit") {
216
+ if (e.submitter) ev.submitter = pathOf(e.submitter)
217
+ // Submitted by a click we recorded (a button, or Enter's implicit click)?
218
+ // Then replaying that click submits it; don't submit twice.
219
+ const last = rec.events[rec.events.length - 1]
220
+ ev.viaClick = !!(last && last.type === "click" && last.t === clock.now)
221
+ }
222
+ // For the dock's markers: what was acted on, readably.
223
+ if (e.type === "focusin" && isEditable(e.target)) {
224
+ ev.css = selectorOf(e.target)
225
+ ev.label = labelOf(e.target)
226
+ ev.editable = 1
227
+ }
228
+ if (e.type === "click" || e.type === "keydown" || e.type === "input" || e.type === "change" || e.type === "submit") {
229
+ const el = e.target && e.target.nodeType === 1 ? e.target : null
230
+ if (el) {
231
+ ev.css = selectorOf(el)
232
+ ev.label = e.type === "keydown" ? e.key : labelOf(el)
233
+ if (e.type === "keydown") ev.inField = !!(el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))
234
+ }
235
+ }
236
+ if (e.type === "scroll") {
237
+ const el = e.target === document ? document.scrollingElement : e.target
238
+ ev.path = e.target === document ? "d" : ev.path
239
+ ev.top = el.scrollTop
240
+ ev.left = el.scrollLeft
241
+ }
242
+ return ev
243
+ }
244
+
245
+ // ---- view-only input and scrolling while paused ----------------------------------
246
+ // Scroll positions as the recording has them (live recording and replay), and
247
+ // the elements the user scrolled just to look while paused. Those go back to
248
+ // the recorded position when playback resumes or on a seek.
249
+ const scrollRec = new Map() // path key -> { top, left }
250
+ const viewScrolled = new Map() // path key -> path
251
+ const restoredScroll = new Map() // path key -> { top, left } just set by the runtime itself
252
+ const keyOf = (path) => (Array.isArray(path) ? path.join(",") : String(path))
253
+ // Every recorded scroll over time, per scroller (for a preview of an earlier
254
+ // moment): { path, ref (the element), ts, tops, lefts }.
255
+ const scrollLog = new Map()
256
+
257
+ function noteScroll(ev, el) {
258
+ scrollRec.set(keyOf(ev.path), { top: ev.top, left: ev.left })
259
+ logScroll(ev.path, el, ev.top, ev.left)
260
+ }
261
+
262
+ function logScroll(path, el, top, left) {
263
+ const key = keyOf(path)
264
+ let L = scrollLog.get(key)
265
+ if (!L) scrollLog.set(key, (L = { path, ref: null, ts: [], tops: [], lefts: [] }))
266
+ if (el) L.ref = typeof WeakRef === "function" ? new WeakRef(el) : { deref: () => el }
267
+ const n = L.ts.length
268
+ if (n && L.ts[n - 1] === clock.now) {
269
+ L.tops[n - 1] = top
270
+ L.lefts[n - 1] = left
271
+ } else if (!n || L.tops[n - 1] !== top || L.lefts[n - 1] !== left) {
272
+ L.ts.push(clock.now)
273
+ L.tops.push(top)
274
+ L.lefts.push(left)
275
+ }
276
+ }
277
+
278
+ // Where a scroller was at t on the recording (0 before it first scrolled).
279
+ function scrollAt(L, t) {
280
+ let lo = 0
281
+ let hi = L.ts.length
282
+ while (lo < hi) {
283
+ const mid = (lo + hi) >> 1
284
+ if (L.ts[mid] <= t) lo = mid + 1
285
+ else hi = mid
286
+ }
287
+ return lo ? { top: L.tops[lo - 1], left: L.lefts[lo - 1] } : { top: 0, left: 0 }
288
+ }
289
+ const scrollerOf = (L) => {
290
+ const el = L.path === "d" ? document.scrollingElement : L.ref && L.ref.deref()
291
+ return el && el.isConnected ? el : null
292
+ }
293
+
294
+ function scrollElOf(path) {
295
+ if (path === "d") return document.scrollingElement
296
+ const n = resolvePath(path)
297
+ return n && n.nodeType === 1 ? n : null
298
+ }
299
+
300
+ // Scroll there at once, whatever the page's `scroll-behavior` (a smooth
301
+ // scroll set while replaying never lands, F32). Returns where it got to (the
302
+ // browser clamps to what's scrollable).
303
+ function setScroll(el, top, left) {
304
+ try {
305
+ el.scrollTo({ top, left, behavior: "instant" })
306
+ } catch {
307
+ el.scrollTop = top
308
+ el.scrollLeft = left
309
+ }
310
+ return { top: el.scrollTop, left: el.scrollLeft }
311
+ }
312
+ // The runtime moving a scroller itself: the scroll event that follows isn't the user's.
313
+ function setOwnScroll(key, el, top, left) {
314
+ const t0 = el.scrollTop
315
+ const l0 = el.scrollLeft
316
+ const r = setScroll(el, top, left)
317
+ if (r.top !== t0 || r.left !== l0) restoredScroll.set(key, r)
318
+ return r
319
+ }
320
+
321
+ // What the user has scrolled to look while paused, to carry into the frame
322
+ // that replaces this one (the dock copies it across, see swapIn).
323
+ function viewScroll() {
324
+ const out = []
325
+ for (const [key, path] of viewScrolled) {
326
+ const el = scrollElOf(path)
327
+ if (el) out.push({ key, path, top: el.scrollTop, left: el.scrollLeft })
328
+ }
329
+ return out
330
+ }
331
+ // ...and taken here: the view stays where the user had it. Play still puts
332
+ // the recording's scroll back (each one that differs counts as looked-at).
333
+ function applyView(list) {
334
+ let view = []
335
+ try {
336
+ view = JSON.parse(JSON.stringify(list || []))
337
+ } catch {}
338
+ for (const v of view) {
339
+ const el = scrollElOf(v.path)
340
+ if (!el) continue
341
+ const key = keyOf(v.path)
342
+ setOwnScroll(key, el, v.top, v.left)
343
+ const r = scrollRec.get(key) || { top: 0, left: 0 }
344
+ if (r.top !== v.top || r.left !== v.left) viewScrolled.set(key, v.path)
345
+ }
346
+ }
347
+
348
+ // Pausing hands focus to the dock (the app is view-only); playing gives the
349
+ // app's focused element (and its caret) back.
350
+ function restoreFocus() {
351
+ const f = focused()
352
+ if (!f || f === document.body) return
353
+ try {
354
+ if (W.frameElement && W.parent.document.activeElement !== W.frameElement) W.focus()
355
+ } catch {}
356
+ if (realActive.call(document) !== f) {
357
+ try {
358
+ dispatching++ // our own focus change isn't input to record
359
+ f.focus({ preventScroll: true })
360
+ } catch {} finally {
361
+ dispatching--
362
+ }
363
+ }
364
+ }
365
+
366
+ function restoreScroll() {
367
+ for (const [key, path] of viewScrolled) {
368
+ const el = scrollElOf(path)
369
+ if (!el) continue
370
+ const r = scrollRec.get(key) || { top: 0, left: 0 }
371
+ if (el.scrollTop !== r.top || el.scrollLeft !== r.left) setOwnScroll(key, el, r.top, r.left)
372
+ }
373
+ viewScrolled.clear()
374
+ }
375
+
376
+ // The scroll event that restoreScroll itself causes isn't the user's.
377
+ function ignoreRestoredScroll(e) {
378
+ const path = e.target === document ? "d" : pathOf(e.target)
379
+ const key = keyOf(path)
380
+ const r = restoredScroll.get(key)
381
+ if (!r) return false
382
+ restoredScroll.delete(key)
383
+ const el = scrollElOf(path)
384
+ return !!el && el.scrollTop === r.top && el.scrollLeft === r.left
385
+ }
386
+
387
+ function isEditable(el) {
388
+ return !!el && el.nodeType === 1 && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))
389
+ }
390
+
391
+ // The user's last scroll gesture (wheel, touch, a scroll key, a press on a
392
+ // scrollbar): a scroll counts as theirs only shortly after one. A page that
393
+ // moves because a preview changed it underneath isn't the user looking.
394
+ let lastGesture = -1e9
395
+ const GESTURES = new Set(["wheel", "touchstart", "touchmove", "pointerdown", "mousedown"])
396
+
397
+ function viewOnly(e) {
398
+ if (GESTURES.has(e.type) || (e.type === "keydown" && SCROLL_KEYS.has(e.key))) lastGesture = real.perfNow()
399
+ if (e.type === "scroll") {
400
+ // Looking around: the browser scrolls, the app doesn't hear of it, and
401
+ // it isn't part of the recording.
402
+ if (!ignoreRestoredScroll(e) && real.perfNow() - lastGesture < 500) {
403
+ const path = e.target === document ? "d" : pathOf(e.target)
404
+ if (path) viewScrolled.set(keyOf(path), path)
405
+ }
406
+ e.stopImmediatePropagation()
407
+ return
408
+ }
409
+ if (e.type === "wheel" || e.type === "touchstart" || e.type === "touchmove" || e.type === "touchend") {
410
+ e.stopImmediatePropagation() // default (scrolling) still happens
411
+ return
412
+ }
413
+ if (e.type === "keydown" || e.type === "keyup" || e.type === "keypress") {
414
+ // The dock drives space/arrows/F/+ while focus is in the app.
415
+ let handled = false
416
+ if (e.type === "keydown" && shell && typeof shell.key === "function") {
417
+ try {
418
+ handled = !!shell.key(e)
419
+ } catch {}
420
+ }
421
+ if (!handled && SCROLL_KEYS.has(e.key) && !isEditable(e.target)) {
422
+ e.stopImmediatePropagation() // keyboard scrolling still works
423
+ return
424
+ }
425
+ return block(e)
426
+ }
427
+ return block(e)
428
+ }
429
+
430
+ function block(e) {
431
+ e.stopImmediatePropagation()
432
+ if (e.cancelable && e.type !== "scroll") e.preventDefault()
433
+ }
434
+
435
+ // A page that has just loaded live (the recording's first page, or one the app
436
+ // reloaded itself into: a Vite full reload, location.reload(), a navigation)
437
+ // and whose clock hasn't run yet: a scroll nobody gestured for is the browser
438
+ // putting the page back where it was (scroll restoration on a reload), or the
439
+ // app's own scroll as it loads. That's how this page starts, so it goes into
440
+ // the recording at the page's first moment, and a rebuild of the page replays
441
+ // it (else every rebuild after a reload would land at the top). Like any
442
+ // replayed scroll, the app doesn't hear it.
443
+ let clockRan = false // this document's clock has run (play())
444
+ function startingScroll(e) {
445
+ if (e.type !== "scroll" || clockRan || clock.seeking || previewing || !rec || rec.start == null || hasFuture()) return false
446
+ if (pending && !pending.reloaded && !pending.continue) return false // a rebuild: its scroll is replayed
447
+ if (real.perfNow() - lastGesture < 500) return false // the user's: view-only (paused)
448
+ e.stopImmediatePropagation()
449
+ if (ignoreRestoredScroll(e)) return true
450
+ const ev = serialize(e)
451
+ if (ev.path) {
452
+ noteScroll(ev, e.target === document ? document.scrollingElement : e.target)
453
+ recordEvent(ev)
454
+ }
455
+ return true
456
+ }
457
+
458
+ function onInput(e) {
459
+ if (!e.isTrusted || dispatching || !rec) return
460
+ // Holding ⌘ is the dock's "pick an element" gesture, never app input.
461
+ // (The dock may still be wiring these up while the prototype boots.)
462
+ if (shell && shell.meta && e.key === "Meta") return shell.meta(e.type === "keydown")
463
+ if (shell && shell.pointer && e.type === "pointermove") shell.pointer(e.clientX, e.clientY, e.metaKey)
464
+ // A click in the prototype folds away an open note, like a click anywhere else.
465
+ if (shell && shell.appPointerDown && e.type === "pointerdown" && !shell.inspecting) shell.appPointerDown()
466
+ // Comment mode: the dock is picking an element. The app sees nothing.
467
+ if (shell && shell.inspecting) {
468
+ shell.inspect(e)
469
+ e.stopImmediatePropagation()
470
+ if (e.cancelable && e.type !== "scroll" && e.type !== "input") e.preventDefault()
471
+ return
472
+ }
473
+ // A dock tool (comment/select) is on: the app sees nothing.
474
+ if (toolActive) return block(e)
475
+ if ((e.type === "keydown" || e.type === "keyup") && PT.shortcut && PT.shortcut(e)) return
476
+ if (startingScroll(e)) return
477
+ // Input during a rewind would land at the wrong moment; drop it. A frame
478
+ // being built behind can have the window's focus (its replay focused a
479
+ // field), so its keys still go to the dock (space, arrows, +).
480
+ if (clock.seeking) {
481
+ if (e.type === "keydown" && shell && typeof shell.key === "function") {
482
+ try {
483
+ shell.key(e)
484
+ } catch {}
485
+ }
486
+ return block(e)
487
+ }
488
+ // While a past moment is on show (or being rebuilt), the page is a picture
489
+ // you can still scroll around in.
490
+ if (previewing || (shell && shell.rebuilding)) return viewOnly(e)
491
+ // Paused, or in the past (rewound, or playing the recorded future back):
492
+ // the app is view-only, like a paused video. Nothing it gets reaches it or
493
+ // moves time, except that you can scroll around to look (CONTRACT.md).
494
+ // Only Play starts the clock.
495
+ if (!clock.playing || hasFuture()) return viewOnly(e)
496
+ // A mousemove right after the pointermove it mirrors is one event.
497
+ // (It may come after the pointerover/out events the same move fired.)
498
+ if (e.type === "mousemove") {
499
+ for (let i = rec.events.length - 1, n = 0; i >= 0 && n < 8; i--, n++) {
500
+ const ev = rec.events[i]
501
+ if (ev.t !== clock.now) break
502
+ if (ev.type === "pointermove") {
503
+ if (Math.abs((ev.clientX || 0) - e.clientX) < 1 && Math.abs((ev.clientY || 0) - e.clientY) < 1 && !ev.mm) {
504
+ ev.mm = 1
505
+ return
506
+ }
507
+ break
508
+ }
509
+ }
510
+ }
511
+ // mouseover/mouseout mirror the pointerover/pointerout just before them:
512
+ // folded in the same way (replayed right after their pointer twin).
513
+ if (e.type === "mouseover" || e.type === "mouseout") {
514
+ const twin = e.type === "mouseover" ? "pointerover" : "pointerout"
515
+ const path = pathOf(e.target)
516
+ for (let i = rec.events.length - 1, n = 0; i >= 0 && n < 6; i--, n++) {
517
+ const ev = rec.events[i]
518
+ if (ev.t !== clock.now) break
519
+ if (ev.type === twin) {
520
+ if (!ev.mo && ev.path + "" === path + "") {
521
+ ev.mo = 1
522
+ return
523
+ }
524
+ break
525
+ }
526
+ }
527
+ }
528
+ // Wheel ticks within one frame add up to one event.
529
+ if (e.type === "wheel") {
530
+ const last = rec.events[rec.events.length - 1]
531
+ if (last && last.type === "wheel" && last.t === clock.now && last.path + "" === pathOf(e.target) + "") {
532
+ last.deltaX = (last.deltaX || 0) + e.deltaX
533
+ last.deltaY = (last.deltaY || 0) + e.deltaY
534
+ return
535
+ }
536
+ }
537
+ if (e.type === "focus" || e.type === "blur") return // focusin/focusout carry these
538
+ if (e.type === "focusin") vFocus = e.target
539
+ if (e.type === "focusout" && vFocus === e.target) vFocus = null
540
+ if (e.type === "scroll" && ignoreRestoredScroll(e)) return
541
+ appRan = true // the app is handling input: it may start animations
542
+ const ev = serialize(e)
543
+ if (ev.type === "scroll" && ev.path) noteScroll(ev, e.target === document ? document.scrollingElement : e.target)
544
+ if (ev.path) {
545
+ trace("input", ev.type)
546
+ const before = rec.events.length
547
+ // Fired by the browser in the same task as the previous input event?
548
+ if (inputTask && before && rec.events[before - 1].t === clock.now) ev.g = 1
549
+ recordEvent(ev)
550
+ markInputTask()
551
+ }
552
+ }
553
+
554
+ for (const type of [...POINTER, ...MOUSE, ...KEYS, ...TOUCH, ...DRAG, ...CLIP, ...COMPOSE, ...OTHER]) {
555
+ W.addEventListener(type, onInput, { capture: true, passive: false })
556
+ }
557
+
558
+ // Another frame of this app (one being built behind, a checkpoint) puts the
559
+ // origin's storage back to its own moment: an app that isn't running doesn't
560
+ // hear of it.
561
+ W.addEventListener("storage", (e) => (!clock.playing || clock.seeking) && e.stopImmediatePropagation(), true)
562
+
563
+ // Paused, the app doesn't hear the window resize either (a frame swapped in
564
+ // leaves the size it was built at, and on some pages a resize resets what's
565
+ // drawn); Play hands it one if the size changed meanwhile.
566
+ let heldResize = false
567
+ let heardSize = { w: W.innerWidth, h: W.innerHeight }
568
+ W.addEventListener(
569
+ "resize",
570
+ (e) => {
571
+ if (!e.isTrusted) return
572
+ if (clock.playing && !clock.seeking) {
573
+ heardSize = { w: W.innerWidth, h: W.innerHeight }
574
+ return
575
+ }
576
+ heldResize = true
577
+ e.stopImmediatePropagation()
578
+ },
579
+ true,
580
+ )
581
+ function deliverHeldResize() {
582
+ if (!heldResize) return
583
+ heldResize = false
584
+ if (W.innerWidth === heardSize.w && W.innerHeight === heardSize.h) return
585
+ heardSize = { w: W.innerWidth, h: W.innerHeight }
586
+ dispatching++
587
+ try {
588
+ W.dispatchEvent(new Event("resize"))
589
+ } finally {
590
+ dispatching--
591
+ }
592
+ }
593
+
594
+ // Back/forward (and hash changes) are navigation the app reacts to; they're
595
+ // recorded as one "nav" event with where the page ended up.
596
+ let lastNav = null
597
+ function onNav(e) {
598
+ if (!e.isTrusted || dispatching || !rec || hasFuture() || clock.seeking) return
599
+ const url = location.href
600
+ if (lastNav && lastNav.t === clock.now && lastNav.url === url) {
601
+ if (e.type === "hashchange") lastNav.hash = 1
602
+ return
603
+ }
604
+ let state = null
605
+ try {
606
+ state = JSON.parse(JSON.stringify(history.state))
607
+ } catch {}
608
+ const ev = { type: "nav", path: "w", url, state }
609
+ if (e.type === "hashchange") ev.hash = 1
610
+ recordEvent(ev)
611
+ lastNav = ev
612
+ }
613
+ W.addEventListener("popstate", onNav, true)
614
+ W.addEventListener("hashchange", onNav, true)
615
+
616
+ const setters = new Map()
617
+ function nativeSetter(el, prop) {
618
+ const proto = Object.getPrototypeOf(el)
619
+ const key = proto
620
+ if (!setters.has(key)) setters.set(key, {})
621
+ const cache = setters.get(key)
622
+ if (!(prop in cache)) {
623
+ let p = proto
624
+ let desc = null
625
+ while (p && !(desc = Object.getOwnPropertyDescriptor(p, prop))) p = Object.getPrototypeOf(p)
626
+ cache[prop] = desc && desc.set
627
+ }
628
+ return cache[prop]
629
+ }
630
+
631
+ // Keys go to whatever has focus now; pointers fall back to what's under the
632
+ // recorded coordinates if the DOM has shifted.
633
+ function findTarget(ev) {
634
+ if (ev.active) {
635
+ const f = focused()
636
+ if (f) return f
637
+ const a = realActive.call(document)
638
+ if (a && a !== document.body) return a
639
+ // Focus got lost altogether: fall back to what the key was pressed in.
640
+ if (ev.css) {
641
+ try {
642
+ const el = document.querySelector(ev.css)
643
+ if (el) return el
644
+ } catch {}
645
+ }
646
+ return a || document.body
647
+ }
648
+ const byPath = resolvePath(ev.path)
649
+ if (byPath) return byPath
650
+ if (ev.clientX == null || HOVER.has(ev.type)) return null
651
+ return document.elementFromPoint(ev.clientX, ev.clientY)
652
+ }
653
+
654
+ function dispatchRecorded(ev) {
655
+ if (ev.type === "net") return deliverNet(ev)
656
+ if (ev.type === "fetch") return deliverNet({ list: "fetches", i: ev.i }) // older recordings
657
+ if (ev.type === "async") return settleAsync(ev)
658
+ if (ev.type === "obs") return deliverObserved(ev)
659
+ if (ev.type === "worker") return deliverWorker(ev)
660
+ if (ev.type === "ready") return deliverReady(ev)
661
+ if (ev.type === "script") return deliverScript(ev)
662
+ const target = findTarget(ev)
663
+ // Focus leaving an element that's gone: the browser fired that itself as it
664
+ // removed it (a soft navigation replacing the page), nothing to replay.
665
+ if (!target && ev.type === "focusout") return
666
+ if (!target) {
667
+ // The DOM came out different (code changed?). Say so a few times, not 10,000.
668
+ if (++missingTargets <= 3) console.warn("[retake] replay target missing", ev.type, ev.path, missingTargets === 3 ? "(further ones not shown)" : "")
669
+ return
670
+ }
671
+ trace("input", ev.type)
672
+ dispatching++
673
+ try {
674
+ replayOne(ev, target)
675
+ } finally {
676
+ dispatching--
677
+ }
678
+ }
679
+
680
+ function makeTransfer(data) {
681
+ const dt = new DataTransfer()
682
+ for (const [type, value] of Object.entries(data || {})) {
683
+ try {
684
+ dt.setData(type, value)
685
+ } catch {}
686
+ }
687
+ return dt
688
+ }
689
+
690
+ function makeTouches(list) {
691
+ return (list || []).map((t) => {
692
+ try {
693
+ const target = resolvePath(t.path) || document.body
694
+ return new Touch({ identifier: t.id, target, clientX: t.x, clientY: t.y, screenX: t.sx, screenY: t.sy, pageX: t.x + scrollX, pageY: t.y + scrollY })
695
+ } catch {
696
+ return null
697
+ }
698
+ }).filter(Boolean)
699
+ }
700
+
701
+ // Legacy key fields can't go through the KeyboardEvent constructor.
702
+ function withLegacyKeys(event, ev) {
703
+ for (const k of ["keyCode", "which", "charCode"]) {
704
+ const v = ev[k] || 0
705
+ try {
706
+ Object.defineProperty(event, k, { get: () => v })
707
+ } catch {}
708
+ }
709
+ return event
710
+ }
711
+
712
+ function replayOne(ev, target) {
713
+ if (ev.type.startsWith("pointer") || ev.type.startsWith("mouse")) replayHover(ev, target)
714
+ const init = { ...DEFAULTS, ...ev, view: W }
715
+ if (ev.so) replayScreenOffset = ev.so
716
+ if (ev.clientX != null || ev.so) {
717
+ if (init.screenX == null) init.screenX = (ev.clientX || 0) + replayScreenOffset[0]
718
+ if (init.screenY == null) init.screenY = (ev.clientY || 0) + replayScreenOffset[1]
719
+ }
720
+ if (init.button == null && /move|over|out|enter|leave/.test(ev.type)) init.button = -1
721
+ if (ev.related) init.relatedTarget = resolvePath(ev.related)
722
+ switch (true) {
723
+ case ev.type === "scroll": {
724
+ const el = target === document ? document.scrollingElement : target
725
+ setScroll(el, ev.top, ev.left)
726
+ noteScroll(ev, el)
727
+ return
728
+ }
729
+ case ev.type === "nav": {
730
+ const moved = location.href !== ev.url
731
+ if (!moved) return // the replayed click already navigated there
732
+ const oldURL = location.href
733
+ history.replaceState(ev.state, "", ev.url)
734
+ W.dispatchEvent(new PopStateEvent("popstate", { state: ev.state }))
735
+ if (ev.hash) W.dispatchEvent(new HashChangeEvent("hashchange", { oldURL, newURL: ev.url }))
736
+ return
737
+ }
738
+ case ev.type === "focusin":
739
+ vFocus = target
740
+ return target.focus && target.focus({ preventScroll: true })
741
+ case ev.type === "focusout":
742
+ if (vFocus === target) vFocus = null
743
+ return realActive.call(document) === target && target.blur()
744
+ case ev.type === "submit": {
745
+ if (ev.viaClick) return // the replayed click submits it
746
+ const submitter = ev.submitter ? resolvePath(ev.submitter) : null
747
+ if (typeof target.requestSubmit === "function") {
748
+ try {
749
+ return submitter ? target.requestSubmit(submitter) : target.requestSubmit()
750
+ } catch {
751
+ return target.requestSubmit()
752
+ }
753
+ }
754
+ return target.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }))
755
+ }
756
+ case ev.type === "input" || ev.type === "change": {
757
+ // A checkbox or radio is toggled by the replayed click, which fires its
758
+ // own input/change; only step in if the state didn't come out the same.
759
+ if (target.type === "checkbox" || target.type === "radio") {
760
+ if (!!target.checked === !!ev.checked) return
761
+ nativeSetter(target, "checked").call(target, !!ev.checked)
762
+ } else if (target.isContentEditable) target.innerHTML = ev.value
763
+ else if (ev.value != null) nativeSetter(target, "value").call(target, ev.value)
764
+ if (ev.range && target.setSelectionRange) {
765
+ try {
766
+ target.setSelectionRange(ev.range[0], ev.range[1])
767
+ } catch {}
768
+ }
769
+ const E = ev.type === "input" ? InputEvent : Event
770
+ return target.dispatchEvent(new E(ev.type, init))
771
+ }
772
+ case ev.type === "beforeinput":
773
+ return target.dispatchEvent(new InputEvent("beforeinput", init))
774
+ case POINTER.includes(ev.type): {
775
+ target.dispatchEvent(new PointerEvent(ev.type, init))
776
+ if (ev.mm) target.dispatchEvent(new MouseEvent("mousemove", init))
777
+ if (ev.mo) target.dispatchEvent(new MouseEvent(ev.type === "pointerover" ? "mouseover" : "mouseout", init))
778
+ return
779
+ }
780
+ case ev.type === "wheel":
781
+ return target.dispatchEvent(new WheelEvent("wheel", init))
782
+ case MOUSE.includes(ev.type):
783
+ return target.dispatchEvent(new MouseEvent(ev.type, init))
784
+ case KEYS.includes(ev.type):
785
+ return target.dispatchEvent(withLegacyKeys(new KeyboardEvent(ev.type, init), ev))
786
+ case TOUCH.includes(ev.type):
787
+ return target.dispatchEvent(new TouchEvent(ev.type, { ...init, touches: makeTouches(ev.touches), targetTouches: makeTouches(ev.targetTouches), changedTouches: makeTouches(ev.changedTouches) }))
788
+ case DRAG.includes(ev.type):
789
+ return target.dispatchEvent(new DragEvent(ev.type, { ...init, dataTransfer: makeTransfer(ev.dt) }))
790
+ case CLIP.includes(ev.type):
791
+ return target.dispatchEvent(new ClipboardEvent(ev.type, { ...init, clipboardData: makeTransfer(ev.dt) }))
792
+ case COMPOSE.includes(ev.type):
793
+ return target.dispatchEvent(new CompositionEvent(ev.type, init))
794
+ }
795
+ }