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,437 @@
1
+ // Live preview: show any earlier moment instantly, without replaying. The DOM
2
+ // changes (from a MutationObserver) and every animation's timing are logged as
3
+ // they happen; previewing t undoes the DOM changes made after t and puts each
4
+ // animation at its currentTime for t. A scope element limits all of it to one
5
+ // component. Leaving the preview redoes everything back to the live page.
6
+
7
+ let previewing = false
8
+ const domLog = [] // { t, kind, node, ... }
9
+ let domAt = 0 // how many domLog entries are applied to the page right now
10
+ const animLog = [] // { anim, target, keyframes, timing, rate, vStart, vEnd, clone }
11
+ const animEntry = new WeakMap()
12
+ let observer = null
13
+
14
+ function logAnim(a, s, created) {
15
+ if (previewing || animEntry.has(a) || !a.effect || !a.effect.target) return
16
+ const rate = rateOf(a) || 1
17
+ let keyframes = []
18
+ try {
19
+ keyframes = a.effect.getKeyframes()
20
+ } catch {}
21
+ const e = { anim: a, target: a.effect.target, keyframes, timing: a.effect.getTiming(), rate, vStart: clock.now - s.t / rate, vEnd: null, clone: null }
22
+ // From when it's on the page: made right now (element.animate()), or, for a
23
+ // CSS transition or animation (seen only at the frame boundary after its
24
+ // cause), from just after the boundary before (input there lands just after
25
+ // its own boundary; until it starts, it's in its delay/"before" phase).
26
+ e.after = created ? clock.now : frameBefore(clock.now)
27
+ e.strict = !created
28
+ animLog.push(e)
29
+ animEntry.set(a, e)
30
+ recordClip(e)
31
+ }
32
+
33
+ // The last recorded frame boundary before x (-Infinity: none).
34
+ function frameBefore(x) {
35
+ const fr = (rec && rec.frames) || []
36
+ let lo = 0
37
+ let hi = fr.length
38
+ while (lo < hi) {
39
+ const m = (lo + hi) >> 1
40
+ if (fr[m] < x) lo = m + 1
41
+ else hi = m
42
+ }
43
+ return lo ? fr[lo - 1] : -Infinity
44
+ }
45
+
46
+ // Was this animation on the page at t?
47
+ const thereAt = (e, t) => t >= e.vStart || (e.strict ? t > e.after : t >= e.after)
48
+
49
+ function endAnim(a) {
50
+ const e = animEntry.get(a)
51
+ if (e && e.vEnd == null) {
52
+ e.vEnd = clock.now
53
+ endClip(e)
54
+ }
55
+ }
56
+
57
+ // ---- DOM log --------------------------------------------------------------------
58
+
59
+ // Input recorded at a frame boundary B happens just after B (a rebuild to
60
+ // exactly B stops short of it), so what it changes is logged just after B as
61
+ // well (inputAt, 30-recorder.js): a preview of exactly B shows the page before
62
+ // it, like the rebuild.
63
+ const INPUT_EPS = 0.001
64
+ const logTime = () => (clock.now === inputAt ? clock.now + INPUT_EPS : clock.now)
65
+
66
+ function logMutations(records) {
67
+ if (records.length) appRan = true // style may have changed: look for new animations
68
+ const t = logTime()
69
+ for (let i = 0; i < records.length; i++) {
70
+ const r = records[i]
71
+ const later = (pred) => records.slice(i + 1).find(pred)
72
+ if (r.type === "attributes" && r.attributeName === HOVER_ATTR) continue // our :hover stand-in
73
+ if (r.type === "attributes") {
74
+ const next = later((x) => x.type === "attributes" && x.target === r.target && x.attributeName === r.attributeName)
75
+ domLog.push({ t, kind: "attr", node: r.target, name: r.attributeName, old: r.oldValue, now: next ? next.oldValue : r.target.getAttribute(r.attributeName) })
76
+ } else if (r.type === "characterData") {
77
+ const next = later((x) => x.type === "characterData" && x.target === r.target)
78
+ domLog.push({ t, kind: "text", node: r.target, old: r.oldValue, now: next ? next.oldValue : r.target.data })
79
+ } else {
80
+ domLog.push({ t, kind: "list", node: r.target, added: [...r.addedNodes], removed: [...r.removedNodes], next: r.nextSibling })
81
+ for (const n of r.addedNodes) if (n.nodeType === 1) findSmil(n)
82
+ // A subtree taken off the page can still change: Astro's ClientRouter
83
+ // swaps the body, then React empties the old body's islands. Those
84
+ // changes are logged too, or a preview putting the old body back shows
85
+ // them empty. (One record per change even where registrations overlap.)
86
+ for (const n of r.removedNodes) {
87
+ if (n.nodeType !== 1 || !n.firstChild || n.isConnected) continue
88
+ offPage.add(n)
89
+ observer.observe(n, OBSERVE)
90
+ }
91
+ }
92
+ }
93
+ domAt = domLog.length
94
+ }
95
+
96
+ const OBSERVE = { subtree: true, childList: true, attributes: true, attributeOldValue: true, characterData: true, characterDataOldValue: true }
97
+ const offPage = new Set() // subtrees taken off the page, still watched (the log keeps them anyway)
98
+ function observe() {
99
+ observer = observer || new MutationObserver(logMutations)
100
+ observer.observe(document, OBSERVE)
101
+ // (A preview disconnects the observer; back on, the subtrees still off the page.)
102
+ for (const n of offPage) n.isConnected ? offPage.delete(n) : observer.observe(n, OBSERVE)
103
+ // Where the page was scrolled when this document's log starts.
104
+ const se = document.scrollingElement
105
+ if (se && !scrollLog.has("d")) logScroll("d", se, se.scrollTop, se.scrollLeft)
106
+ }
107
+
108
+ function applyEntry(e, forward) {
109
+ try {
110
+ if (e.kind === "attr") {
111
+ const v = forward ? e.now : e.old
112
+ v == null ? e.node.removeAttribute(e.name) : e.node.setAttribute(e.name, v)
113
+ } else if (e.kind === "text") {
114
+ e.node.data = forward ? e.now : e.old
115
+ } else if (e.kind === "value") {
116
+ setField(e.node, forward ? e.now : e.old)
117
+ } else {
118
+ const out = forward ? e.removed : e.added
119
+ const into = forward ? e.added : e.removed
120
+ for (const n of out) if (n.parentNode === e.node) e.node.removeChild(n)
121
+ const ref = e.next && e.next.parentNode === e.node ? e.next : null
122
+ for (const n of into) e.node.insertBefore(n, ref)
123
+ }
124
+ } catch {}
125
+ }
126
+
127
+ const inScope = (node, scope) => !scope || scope === node || scope.contains(node)
128
+
129
+ function moveDom(t, scope) {
130
+ let k = domLog.length
131
+ while (k > 0 && domLog[k - 1].t > t) k--
132
+ while (domAt > k) {
133
+ const e = domLog[--domAt]
134
+ if (inScope(e.node, scope)) applyEntry(e, false)
135
+ }
136
+ while (domAt < k) {
137
+ const e = domLog[domAt++]
138
+ if (inScope(e.node, scope)) applyEntry(e, true)
139
+ }
140
+ }
141
+
142
+ // ---- form fields ------------------------------------------------------------------
143
+ // A field's value and a box's checked state are properties, not markup: the
144
+ // MutationObserver never sees them. Typing (input/change events, live or
145
+ // replayed) and the app setting them are logged as "value" entries in the DOM
146
+ // log, so a preview before the user typed shows the field as it was then.
147
+ const fieldSeen = new WeakMap() // field -> { value, checked } as last logged
148
+ const fieldNative = new Map() // prototype -> { value, checked, ... }: the setters we wrapped
149
+ const isField = (el) => !!el && (el.tagName === "INPUT" ? el.type !== "file" : el.tagName === "TEXTAREA" || el.tagName === "SELECT")
150
+ const fieldOf = (el) => ({ value: el.value, checked: !!el.checked })
151
+
152
+ function setField(el, v) {
153
+ const own = fieldNative.get(Object.getPrototypeOf(el)) || {}
154
+ try {
155
+ if (el.value !== v.value) (own.value || nativeSetter(el, "value")).call(el, v.value)
156
+ if (el.type === "checkbox" || el.type === "radio") (own.checked || nativeSetter(el, "checked")).call(el, v.checked)
157
+ } catch {}
158
+ }
159
+
160
+ function fieldChanged(el, old) {
161
+ const now = fieldOf(el)
162
+ fieldSeen.set(el, now)
163
+ if (!observer || previewing || (old.value === now.value && old.checked === now.checked)) return
164
+ // DOM changes made before this one come first in the log.
165
+ logMutations(observer.takeRecords())
166
+ domLog.push({ t: logTime(), kind: "value", node: el, old, now })
167
+ domAt = domLog.length
168
+ }
169
+
170
+ // The app setting a field (React's controlled inputs included: React wraps
171
+ // whatever setter the prototype has when it first sees the field).
172
+ for (const [C, props] of [
173
+ [W.HTMLInputElement, ["value", "checked"]],
174
+ [W.HTMLTextAreaElement, ["value"]],
175
+ [W.HTMLSelectElement, ["value", "selectedIndex"]],
176
+ ]) {
177
+ const proto = C && C.prototype
178
+ if (!proto) continue
179
+ const own = {}
180
+ for (const p of props) {
181
+ const d = Object.getOwnPropertyDescriptor(proto, p)
182
+ if (!d || !d.set || !d.configurable) continue
183
+ own[p] = d.set
184
+ Object.defineProperty(proto, p, {
185
+ ...d,
186
+ set(v) {
187
+ if (!observer || previewing || !isField(this)) return d.set.call(this, v)
188
+ const old = fieldOf(this)
189
+ d.set.call(this, v)
190
+ fieldChanged(this, old)
191
+ },
192
+ })
193
+ }
194
+ fieldNative.set(proto, own)
195
+ }
196
+
197
+ // The user (or a replay of them) changing one. Its value before: as seen when
198
+ // they got to it (focus, a press, a key); else what a click on a box changed.
199
+ const seeField = (e) => isField(e.target) && !fieldSeen.has(e.target) && fieldSeen.set(e.target, fieldOf(e.target))
200
+ for (const type of ["focusin", "pointerdown", "mousedown", "keydown", "beforeinput"]) W.addEventListener(type, seeField, true)
201
+ for (const type of ["input", "change"])
202
+ W.addEventListener(
203
+ type,
204
+ (e) => {
205
+ const el = e.target
206
+ if (!isField(el)) return
207
+ const box = el.type === "checkbox" || el.type === "radio"
208
+ fieldChanged(el, fieldSeen.get(el) || { value: box ? el.value : el.defaultValue, checked: box ? el.type === "checkbox" && !el.checked : !!el.checked })
209
+ // Checking a radio unchecks the one before it, with no event of its own.
210
+ if (el.type === "radio" && el.checked && el.name) {
211
+ for (const r of (el.form || el.getRootNode()).querySelectorAll(`input[type=radio]`)) {
212
+ const seen = r !== el && r.name === el.name && fieldSeen.get(r)
213
+ if (seen && seen.checked && !r.checked) fieldChanged(r, seen)
214
+ }
215
+ }
216
+ },
217
+ true,
218
+ )
219
+
220
+ // ---- animations at t --------------------------------------------------------------
221
+
222
+ function placeAnimations(t, scope) {
223
+ // Anything the undo/redo itself started (restarted CSS animations,
224
+ // transitions) is noise: the log already knows what was running.
225
+ for (const a of document.getAnimations()) {
226
+ if (!animEntry.has(a) && !isClone(a)) orig.cancel.call(a)
227
+ }
228
+ for (const e of animLog) {
229
+ if (!inScope(e.target, scope)) {
230
+ unpark(e.anim)
231
+ continue
232
+ }
233
+ const time = (t - e.vStart) * e.rate
234
+ const there = thereAt(e, t)
235
+ const live = stateOf(e.anim) !== "idle"
236
+ if (live) {
237
+ // One that only started after t isn't there yet. Paused at a negative
238
+ // time it would still show its start value (CSS transitions fill
239
+ // backwards), over the one really running then (a later transition of
240
+ // the same property wins): its effect comes off the page meanwhile.
241
+ if (!there) {
242
+ park(e.anim)
243
+ continue
244
+ }
245
+ unpark(e.anim)
246
+ orig.pause.call(e.anim)
247
+ orig.currentTime.set.call(e.anim, time)
248
+ continue
249
+ }
250
+ const alive = there && (e.vEnd == null || t < e.vEnd)
251
+ if (alive && !e.clone && e.target.isConnected) {
252
+ e.clone = orig.animate.call(e.target, e.keyframes, { ...e.timing })
253
+ orig.pause.call(e.clone)
254
+ }
255
+ if (e.clone && !alive) {
256
+ orig.cancel.call(e.clone)
257
+ e.clone = null
258
+ }
259
+ if (e.clone) orig.currentTime.set.call(e.clone, time)
260
+ }
261
+ }
262
+
263
+ const isClone = (a) => animLog.some((e) => e.clone === a)
264
+
265
+ // Animations whose effect is off the page for the preview, with the element
266
+ // it goes back on (the target of their effect).
267
+ const parked = new Map()
268
+ function park(a) {
269
+ const fx = a.effect
270
+ if (parked.has(a) || !fx || !fx.target) return
271
+ parked.set(a, fx.target)
272
+ try {
273
+ fx.target = null
274
+ } catch {
275
+ parked.delete(a)
276
+ }
277
+ }
278
+ function unpark(a) {
279
+ if (!parked.has(a)) return
280
+ const target = parked.get(a)
281
+ parked.delete(a)
282
+ try {
283
+ a.effect.target = target
284
+ } catch {}
285
+ }
286
+ function unparkAll() {
287
+ for (const a of [...parked.keys()]) unpark(a)
288
+ }
289
+
290
+ // ---- entering and leaving -----------------------------------------------------------
291
+
292
+ // ---- scroll at t ------------------------------------------------------------------------
293
+ // Each scroller the recording moved goes where it was at t, except the ones
294
+ // the user has scrolled to look while paused (their view stays). Leaving the
295
+ // preview puts the rest back.
296
+ const liveScroll = new Map() // key -> { top, left } before the preview moved it
297
+ const shownScroll = new Map() // key -> { top, left } the preview last put it at
298
+
299
+ function placeScroll(t, scope) {
300
+ for (const [key, L] of scrollLog) {
301
+ if (viewScrolled.has(key)) continue
302
+ const el = scrollerOf(L)
303
+ if (!el || (scope && !scope.contains(el))) continue
304
+ const v = scrollAt(L, t)
305
+ const last = shownScroll.get(key)
306
+ if (last && last.top === v.top && last.left === v.left) continue
307
+ if (!liveScroll.has(key)) liveScroll.set(key, { top: el.scrollTop, left: el.scrollLeft, L })
308
+ shownScroll.set(key, v)
309
+ setOwnScroll(key, el, v.top, v.left)
310
+ }
311
+ }
312
+
313
+ function restoreLiveScroll() {
314
+ for (const [key, v] of liveScroll) {
315
+ if (viewScrolled.has(key)) continue // scrolled by the user meanwhile: theirs now
316
+ const el = scrollerOf(v.L)
317
+ if (el) setOwnScroll(key, el, v.top, v.left)
318
+ }
319
+ liveScroll.clear()
320
+ shownScroll.clear()
321
+ }
322
+
323
+ // ---- entering and leaving -----------------------------------------------------------
324
+
325
+ let previewScope = null
326
+ const liveTimes = new Map() // each live animation's state before the preview
327
+
328
+ function preview(t, scope) {
329
+ // Only this document's past can be shown (earlier pages need a rebuild).
330
+ t = Math.max(t, segmentAt(clock.now).t)
331
+ // An in-place seek is running (or about to): stop it where it is and show
332
+ // the moment once it has (previewing mid-seek would lose DOM changes the
333
+ // replay is making).
334
+ if (clock.seeking || seekTarget != null) {
335
+ PT.cancelSeek()
336
+ pendingPreview = { t, scope: scope || null }
337
+ return
338
+ }
339
+ if (!previewing) {
340
+ reclaimNative()
341
+ logMutations(observer.takeRecords())
342
+ observer.disconnect()
343
+ previewing = true
344
+ pause()
345
+ liveTimes.clear()
346
+ for (const [a, st] of managed) liveTimes.set(a, { t: orig.currentTime.get.call(a), done: st.done })
347
+ }
348
+ if (scope !== previewScope) {
349
+ // A new scope starts from the live page.
350
+ moveDom(Infinity, previewScope)
351
+ restoreLiveScroll()
352
+ previewScope = scope || null
353
+ }
354
+ moveDom(t, previewScope)
355
+ setHover(hoverAt(t))
356
+ alignMedia(t)
357
+ placeAnimations(t, previewScope)
358
+ syncSmil(t, previewScope)
359
+ placeScroll(t, previewScope)
360
+ previewAt = t
361
+ PT.emit()
362
+ }
363
+
364
+ let previewAt = null
365
+
366
+ function endPreview() {
367
+ pendingPreview = null
368
+ if (!previewing) return
369
+ unparkAll()
370
+ moveDom(Infinity, previewScope)
371
+ restoreLiveScroll()
372
+ for (const e of animLog) {
373
+ if (e.clone) orig.cancel.call(e.clone)
374
+ e.clone = null
375
+ }
376
+ for (const [a, was] of liveTimes) {
377
+ if (stateOf(a) === "idle") continue
378
+ if (was.done) orig.finish.call(a)
379
+ else orig.currentTime.set.call(a, was.t)
380
+ }
381
+ // Undo/redo restarted some CSS animations as new objects; carry on the
382
+ // original timing in them. Anything else it started is dropped.
383
+ for (const a of document.getAnimations()) {
384
+ if (animEntry.has(a)) continue
385
+ const lost =
386
+ typeof CSSAnimation !== "undefined" &&
387
+ a instanceof CSSAnimation &&
388
+ animLog.find((e) => e.vEnd == null && e.target === /** @type {KeyframeEffect} */ (a.effect).target && stateOf(e.anim) === "idle" && e.anim.animationName === a.animationName)
389
+ if (!lost) {
390
+ orig.cancel.call(a)
391
+ continue
392
+ }
393
+ managed.set(a, { t: (clock.now - lost.vStart) * lost.rate, v: clock.now, userPaused: false, done: false })
394
+ orig.pause.call(a)
395
+ lost.anim = a
396
+ animEntry.set(a, lost)
397
+ }
398
+ previewing = false
399
+ previewScope = null
400
+ previewAt = null
401
+ if (hasFuture()) setHover(hoverAt())
402
+ else clearHover()
403
+ alignMedia()
404
+ observe()
405
+ syncAnimations()
406
+ PT.emit()
407
+ }
408
+
409
+ // ---- activity, for the timeline's marks ------------------------------------------
410
+
411
+ // When input happened (clicks and keys) and when animations were running.
412
+ let activityCache = null
413
+ function activity() {
414
+ const key = rec.events.length + ":" + animLog.length + ":" + Math.round(clock.now / 250)
415
+ if (activityCache && activityCache.key === key) return activityCache.value
416
+ const inputs = []
417
+ for (const ev of rec.events) if (ev.type === "pointerdown" || ev.type === "keydown") inputs.push(ev.t)
418
+ const spans = []
419
+ for (const e of animLog) {
420
+ let end = e.vEnd
421
+ if (end == null) {
422
+ const total = (e.timing.delay || 0) + (Number(e.timing.duration) || 0) * (e.timing.iterations || 1)
423
+ end = Number.isFinite(total) ? e.vStart + total / (e.rate || 1) : clock.now
424
+ }
425
+ spans.push([e.vStart, Math.min(end, clock.now)])
426
+ }
427
+ // Merge overlapping runs into bands.
428
+ spans.sort((a, b) => a[0] - b[0])
429
+ const bands = []
430
+ for (const [a, b] of spans) {
431
+ const last = bands[bands.length - 1]
432
+ if (last && a <= last[1] + 30) last[1] = Math.max(last[1], b)
433
+ else bands.push([a, b])
434
+ }
435
+ activityCache = { key, value: { inputs, bands } }
436
+ return activityCache.value
437
+ }