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,537 @@
1
+ // Moving through time. Press anywhere on the ruler or the active lane and drag:
2
+ // the playhead follows, snapping to clip edges and markers within 8px (hold
3
+ // alt to move freely). Dragging back shows the past live; letting go keeps it
4
+ // on screen and builds that moment for real behind it (goTo). Keys: ← → a
5
+ // frame, shift+← → the previous or next edge, space play/pause, F fit all,
6
+ // + a new timeline from here.
7
+
8
+ const SNAP_PX = 8
9
+ const GRAB_PX = 4 // a press this close to the playhead picks it up where it is
10
+ const DEAD_PX = 3 // a press that moves less than this is a click, not a drag
11
+ const FRAME_MS = 1000 / 60
12
+
13
+ // Moments worth landing on: clip edges, markers, bookmarks, notes, where
14
+ // timelines split, and the ends. Input lands just after it happened (+1), so
15
+ // its effect shows.
16
+ function edges(s) {
17
+ const active = activeBranch()
18
+ const out = [s.start, Math.max(active.end, s.end), active.forkAt]
19
+ const tl = timeline()
20
+ for (const c of tl.clips) {
21
+ out.push(c.start + 1)
22
+ if (c.end != null) out.push(c.end)
23
+ }
24
+ for (const m of tl.markers) out.push(m.t + 1)
25
+ for (const m of D.markers) if (m.branchId === D.activeId) out.push(m.t)
26
+ for (const n of D.notes) if (n.branchId === D.activeId) out.push(n.t)
27
+ for (const b of D.branches) if (b.parentId === D.activeId) out.push(b.forkAt)
28
+ const lo = active.forkAt
29
+ const hi = Math.max(active.end, s.end)
30
+ return [...new Set(out.map((t) => Math.round(t * 100) / 100))].filter((t) => t >= lo && t <= hi).sort((a, b) => a - b)
31
+ }
32
+
33
+ // Where the playhead may go on the active timeline.
34
+ function bounds(s) {
35
+ const b = activeBranch()
36
+ return [Math.max(s.start, b.forkAt), Math.max(b.end, s.end)]
37
+ }
38
+
39
+ function snapped(t, s, free) {
40
+ D.snapT = null
41
+ if (free) return t
42
+ const ppm = pxPerMs(geom())
43
+ let best = null
44
+ for (const e of edges(s)) {
45
+ const d = Math.abs(e - t) * ppm
46
+ if (d <= SNAP_PX && (!best || d < best.d)) best = { e, d }
47
+ }
48
+ if (!best) return t
49
+ D.snapT = best.e
50
+ return best.e
51
+ }
52
+
53
+ // ---- going to a moment -------------------------------------------------------------
54
+
55
+ // Show `t` straight away: a live preview going back, a quick run going forward
56
+ // through recorded future (on this page only: past a reload is a rebuild,
57
+ // left to the release). goTo() then makes it the real moment.
58
+ function scrubTo(t) {
59
+ const pt = D.PT
60
+ if (!pt) return
61
+ let s
62
+ try {
63
+ s = pt.state()
64
+ } catch {
65
+ return
66
+ }
67
+ if (t < s.now - 1) return pt.preview(t, D.scopeEl)
68
+ if (s.previewing) pt.endPreview()
69
+ if (t > s.now + 1 && s.future && s.storageOk !== false) {
70
+ const to = s.segEnd != null ? Math.min(t, s.segEnd - 1) : t
71
+ if (to <= s.now + 1) return
72
+ // The frame on show runs its app: nothing else may (one frame at a time).
73
+ cancelBuild()
74
+ quietCheckpoint()
75
+ pt.seek(to)
76
+ }
77
+ }
78
+
79
+ // Going to a moment for real: the one way the dock moves the playhead there
80
+ // (a release, a key step, a note, a bookmark, +, Play from a preview).
81
+ // - already there (a press on the playhead): nothing to build;
82
+ // - forward on the same page: the frame on show seeks there in place;
83
+ // - anywhere else: shown at once as a preview, and built for real in a
84
+ // hidden frame that swaps in when ready. The user only waits for it if
85
+ // they ask for the real moment first (play, fork), or if the preview can't
86
+ // show it (a moment on an earlier page).
87
+ function goTo(t, { play = false, fork = false } = {}) {
88
+ const pt = D.PT
89
+ if (!pt) return
90
+ // A switch to another timeline is still being built: the frame on show is
91
+ // the timeline being left, so the moment is that build's to go to.
92
+ const sw = D.building
93
+ if (sw && sw.target != null && sw.branchId === D.activeId && D.frameBranch !== D.activeId) return goToSwitching(sw, t, play, fork)
94
+ let s
95
+ try {
96
+ s = pt.state()
97
+ } catch {
98
+ return
99
+ }
100
+ if (D.scopeEl && !play && !fork) return // a scoped preview stays a picture
101
+ if (s.playing) {
102
+ pt.pause()
103
+ s = pt.state()
104
+ }
105
+ const docStart = s.docStart != null ? s.docStart : s.start
106
+ const here = samePlace(s.now, t) // the frame on show stands at that moment
107
+ const shown = here || (t < s.now && t >= docStart) // it's there, or the preview can show it
108
+ const showIt = () => {
109
+ if (here) {
110
+ if (s.previewing) pt.endPreview()
111
+ return
112
+ }
113
+ if (t >= s.now) return
114
+ const at = Math.max(t, docStart)
115
+ if (!(s.previewing && s.previewAt === at)) pt.preview(at, null)
116
+ }
117
+ const b = D.building
118
+ if (b && b.target != null && b.branchId === D.activeId && samePlace(b.target, t)) {
119
+ // That's the moment being built already: keep it.
120
+ showIt()
121
+ b.play = b.play || play
122
+ b.fork = b.fork || fork
123
+ b.visible = !shown
124
+ return
125
+ }
126
+ // This frame can run its app as it is. (If not, another frame has changed
127
+ // its IndexedDB: even the moment it stands at is built again behind it,
128
+ // and only Play or + wait for that.)
129
+ const mine = s.storageOk !== false
130
+ if (mine && here) {
131
+ cancelBuild()
132
+ if (s.previewing) pt.endPreview()
133
+ if (fork) return forkNow()
134
+ if (play) {
135
+ quietCheckpoint()
136
+ pt.play()
137
+ }
138
+ return
139
+ }
140
+ if (t > s.now && s.future && mine && (s.segEnd == null || t < s.segEnd)) {
141
+ cancelBuild()
142
+ if (s.previewing) pt.endPreview()
143
+ quietCheckpoint()
144
+ pt.seek(t, play)
145
+ if (fork) pendingFork = t
146
+ return
147
+ }
148
+ showIt()
149
+ if (typeof pt.buildAt !== "function") return pt.seek(t, play) // an older runtime: it builds in view
150
+ pt.buildAt(t) // rebuild() keeps, moves on or replaces the frame being built
151
+ const nb = D.building
152
+ if (nb && nb.target != null) {
153
+ nb.play = play
154
+ nb.fork = fork
155
+ nb.visible = !shown
156
+ }
157
+ }
158
+
159
+ // Mid-switch: the build moves on to t, or is made again there from the
160
+ // timeline's own recording (never from the frame on show, which is another).
161
+ function goToSwitching(b, t, play, fork) {
162
+ if (Math.abs(b.target - t) >= 1 && !retargetBuild(b, t)) {
163
+ const br = activeBranch()
164
+ if (!br || !br.json) return
165
+ D.PT.load(br.json, t)
166
+ }
167
+ const nb = D.building
168
+ if (!nb) return
169
+ nb.play = nb.play || play
170
+ nb.fork = nb.fork || fork
171
+ }
172
+
173
+ // While the pointer's down, a frame still building behind works in idle
174
+ // time, so the preview under the pointer stays smooth.
175
+ function setBuildBackground(on) {
176
+ const b = D.building
177
+ try {
178
+ if (b && b.pt && typeof b.pt.setBackground === "function") b.pt.setBackground(on)
179
+ } catch {}
180
+ }
181
+
182
+ let pendingT = null
183
+ function scrubSoon(t) {
184
+ if (pendingT == null)
185
+ requestAnimationFrame(() => {
186
+ const t = pendingT
187
+ pendingT = null
188
+ if (t != null) scrubTo(t)
189
+ })
190
+ pendingT = t
191
+ }
192
+
193
+ // The moment the readout and playhead show: under the pointer, where the keys
194
+ // took it, the moment being built, the one previewed, or the frame's own.
195
+ const shownTime = (s) =>
196
+ D.dragT != null ? D.dragT : D.keyT != null ? D.keyT : D.building && D.building.target != null ? D.building.target : s.previewing ? s.previewAt : s.now
197
+
198
+ // Arrow keys: a frame at a time (the recorded frame boundaries when there are
199
+ // some), or edge to edge with shift. The moment is built once the keys rest.
200
+ let keyTimer = 0
201
+ function stepBy(dir, toEdge) {
202
+ const s = state()
203
+ if (!D.PT || !s || !s.started) return
204
+ if (s.playing) D.PT.pause()
205
+ const cur = shownTime(s)
206
+ const [lo, hi] = bounds(s)
207
+ let t
208
+ if (toEdge) {
209
+ const es = edges(s)
210
+ t = dir > 0 ? es.find((e) => e > cur + 0.5) : es.reverse().find((e) => e < cur - 0.5)
211
+ if (t == null) t = dir > 0 ? hi : lo
212
+ } else {
213
+ let frames = null
214
+ try {
215
+ frames = D.PT.history().frames
216
+ } catch {}
217
+ t = cur + dir * FRAME_MS
218
+ if (frames && frames.length) {
219
+ const next = dir > 0 ? frames.find((f) => f > cur + 0.01) : findLast(frames, (f) => f < cur - 0.01)
220
+ if (next != null) t = next
221
+ }
222
+ }
223
+ t = clamp(t, lo, hi)
224
+ D.keyT = t
225
+ scrubTo(t)
226
+ keepInView(t)
227
+ clearTimeout(keyTimer)
228
+ keyTimer = setTimeout(() => {
229
+ D.keyT = null
230
+ goTo(t)
231
+ }, 350)
232
+ }
233
+ function findLast(arr, fn) {
234
+ for (let i = arr.length - 1; i >= 0; i--) if (fn(arr[i])) return arr[i]
235
+ }
236
+ function keepInView(t) {
237
+ const span = D.view.to - D.view.from
238
+ if (t < D.view.from || t > D.view.to) setView(t - span / 2, t + span / 2)
239
+ }
240
+
241
+ // ---- pointer on the track -----------------------------------------------------------
242
+
243
+ function startScrub(e, s) {
244
+ track.setPointerCapture(e.pointerId)
245
+ document.body.classList.add("scrubbing")
246
+ if (s.playing) D.PT.pause()
247
+ const [lo, hi] = bounds(s)
248
+ const g = geom()
249
+ const shown = shownTime(s)
250
+ // On the playhead (or within 4px of it): pick it up where it is, so a
251
+ // click there is no move at all.
252
+ if (Math.abs(e.clientX - (g.left + xOf(shown, g))) <= GRAB_PX) {
253
+ D.snapT = null
254
+ D.dragT = shown
255
+ } else D.dragT = clamp(snapped(timeAt(e.clientX, g), s, e.altKey), lo, hi)
256
+ D.dragX0 = e.clientX
257
+ D.dragMoved = false
258
+ setBuildBackground(true)
259
+ hideTip()
260
+ scrubSoon(D.dragT)
261
+ }
262
+
263
+ function endScrub() {
264
+ if (D.dragT == null) return
265
+ const t = D.dragT
266
+ D.dragT = null
267
+ D.snapT = null
268
+ document.body.classList.remove("scrubbing")
269
+ setBuildBackground(false)
270
+ if (!D.PT) return
271
+ pendingT = null
272
+ scrubTo(t)
273
+ goTo(t)
274
+ }
275
+
276
+ // Pressing on the track: Control-click branches the active timeline at that
277
+ // moment; a click on another timeline's lane selects it; anywhere else moves
278
+ // the playhead (and dragging scrubs).
279
+ track.addEventListener("pointerdown", (e) => {
280
+ const s = D.last
281
+ if (!D.PT || !s || !s.started || e.button !== 0) return
282
+ menuEl.hidden = true
283
+ if (e.ctrlKey) {
284
+ e.preventDefault()
285
+ const t = branchTimeAt(e.clientX, s, e.altKey)
286
+ D.branchT = null
287
+ document.body.classList.remove("branching")
288
+ return newTimelineAt(t)
289
+ }
290
+ const hit = hitAt(e.clientX, e.clientY)
291
+ if (hit && hit.kind === "note") return goToNoteId(hit.id)
292
+ if (hit && hit.kind === "capsule") {
293
+ // ⌘ held on one of the element's animations: fit it, go to its start.
294
+ const c = hit.clip
295
+ fitRange(c.start, c.end == null ? s.end : c.end)
296
+ goTo(c.start + 1)
297
+ return
298
+ }
299
+ if (hit && hit.kind === "bookmark") {
300
+ const m = D.markers.find((x) => String(x.id) === String(hit.id))
301
+ if (m) goTo(m.t)
302
+ return
303
+ }
304
+ if (hit && hit.kind === "lane" && !hit.active) {
305
+ const b = branchById(hit.id)
306
+ if (b) switchTo(b.id, clamp(shownTime(s), b.forkAt, b.end)).then((ok) => ok || flash("Couldn't switch to that timeline"))
307
+ return
308
+ }
309
+ startScrub(e, s)
310
+ })
311
+
312
+ // Where a Control-click would branch: on the active timeline, snapped.
313
+ function branchTimeAt(clientX, s, free) {
314
+ const [lo, hi] = bounds(s)
315
+ const t = clamp(snapped(timeAt(clientX), s, free), lo, hi)
316
+ D.snapT = null
317
+ return t
318
+ }
319
+
320
+ // Holding Control over the track shows the branch guide.
321
+ let overTrack = null // the last pointer event over the track
322
+ function setBranchGuide(on, e) {
323
+ const s = D.last
324
+ const show = !!(on && e && s && s.started && D.dragT == null)
325
+ D.branchT = show ? branchTimeAt(e.clientX, s, e.altKey) : null
326
+ document.body.classList.toggle("branching", show)
327
+ }
328
+ window.addEventListener("keydown", (e) => e.key === "Control" && overTrack && setBranchGuide(true, overTrack))
329
+ window.addEventListener("keyup", (e) => e.key === "Control" && setBranchGuide(false))
330
+ window.addEventListener("blur", () => setBranchGuide(false))
331
+
332
+ track.addEventListener("pointermove", (e) => {
333
+ const s = D.last
334
+ if (!s || !s.started) return
335
+ const g = geom()
336
+ overTrack = e
337
+ if (D.dragT != null) {
338
+ if (!D.dragMoved && Math.abs(e.clientX - D.dragX0) < DEAD_PX) return
339
+ D.dragMoved = true
340
+ const [lo, hi] = bounds(s)
341
+ D.dragT = clamp(snapped(timeAt(e.clientX, g), s, e.altKey), lo, hi)
342
+ // The first move of each dock frame previews straight away (it paints in
343
+ // this frame); any more wait for the next one.
344
+ if (D.scrubbedAt !== D.frameNo) {
345
+ D.scrubbedAt = D.frameNo
346
+ pendingT = null
347
+ scrubTo(D.dragT)
348
+ } else scrubSoon(D.dragT)
349
+ return
350
+ }
351
+ setBranchGuide(e.ctrlKey, e)
352
+ if (e.ctrlKey) return
353
+ hover(e, s, g)
354
+ })
355
+ track.addEventListener("pointerup", endScrub)
356
+ track.addEventListener("pointercancel", endScrub)
357
+ track.addEventListener("pointerleave", () => {
358
+ overTrack = null
359
+ setBranchGuide(false)
360
+ D.hoverRow = null
361
+ })
362
+
363
+ // Hovering the track: which row the pointer is on (for its faint band).
364
+ function hover(e, s, g) {
365
+ const hit = hitAt(e.clientX, e.clientY)
366
+ D.hoverRow = hit && hit.kind === "lane" ? hit.id : null
367
+ }
368
+
369
+ // The component that wrote the clip's element, found the way notes find it
370
+ // (owner chain, library wrappers skipped); else the runtime's guess.
371
+ function clipComponent(c) {
372
+ try {
373
+ const el = c.selector && D.frame.contentDocument.querySelector(c.selector)
374
+ const names = el ? reactComponents(el) : []
375
+ if (names.length) return names[0]
376
+ } catch {}
377
+ return c.component && !INTERNAL.test(c.component) ? c.component : ""
378
+ }
379
+
380
+ const clipName = (c) => (c.kind === "transition" ? c.property || "transition" : c.label || c.property || "animation")
381
+ const msWord = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s` : `${Math.round(ms)}ms`)
382
+
383
+ function setHot(h) {
384
+ D.hot = h
385
+ }
386
+
387
+ function hideTip() {
388
+ tip.hidden = true
389
+ }
390
+
391
+ // ⌘-scroll or pinch zooms around the pointer; a sideways scroll pans.
392
+ track.addEventListener(
393
+ "wheel",
394
+ (e) => {
395
+ const s = D.last
396
+ if (!s || !s.started) return
397
+ const g = geom()
398
+ if (e.ctrlKey || e.metaKey) {
399
+ e.preventDefault()
400
+ const at = timeAt(e.clientX, g)
401
+ const factor = Math.exp(clamp(e.deltaY, -60, 60) * (e.ctrlKey && !e.metaKey ? 0.012 : 0.004))
402
+ const full = fullRange(s)
403
+ const span = clamp((D.view.to - D.view.from) * factor, 12, (full.to - full.from) * 3)
404
+ const frac = (at - D.view.from) / (D.view.to - D.view.from)
405
+ setView(at - frac * span, at - frac * span + span, { animate: false })
406
+ return
407
+ }
408
+ const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.shiftKey ? e.deltaY : 0
409
+ if (!dx) return
410
+ e.preventDefault()
411
+ const dt = dx / pxPerMs(g)
412
+ setView(D.view.from + dt, D.view.to + dt, { animate: false })
413
+ },
414
+ { passive: false },
415
+ )
416
+
417
+ // ---- lanes: switch, rename, delete ------------------------------------------------
418
+
419
+ const menuEl = $("#wb-menu")
420
+ function openLaneMenu(e, b) {
421
+ e.preventDefault()
422
+ menuEl.innerHTML = b.parentId
423
+ ? `<button data-rename-timeline="${b.id}">Rename</button><button class="danger" data-delete-timeline="${b.id}">Delete ${esc(b.name)}</button>`
424
+ : `<button data-rename-timeline="${b.id}">Rename</button><div class="menu-note">The first timeline can't be deleted</div>`
425
+ menuEl.hidden = false
426
+ menuEl.style.left = Math.min(e.clientX, innerWidth - 200) + "px"
427
+ menuEl.style.top = e.clientY - menuEl.offsetHeight - 6 + "px"
428
+ }
429
+ track.addEventListener("contextmenu", (e) => {
430
+ e.preventDefault()
431
+ // On a Mac, Control-click is also a right-click: that one was a branch.
432
+ if (e.ctrlKey) return
433
+ const hit = hitAt(e.clientX, e.clientY)
434
+ const b = hit && hit.kind === "lane" && branchById(hit.id)
435
+ if (b) openLaneMenu(e, b)
436
+ })
437
+ gutter.addEventListener("contextmenu", (e) => {
438
+ const el = e.target.closest("[data-lane]")
439
+ const b = el && branchById(Number(el.dataset.lane))
440
+ if (b) openLaneMenu(e, b)
441
+ })
442
+ gutter.addEventListener("click", (e) => {
443
+ const el = e.target.closest("[data-lane]")
444
+ const b = el && branchById(Number(el.dataset.lane))
445
+ if (!b || b.id === D.activeId || el.querySelector("input")) return
446
+ const t = D.last ? shownTime(D.last) : b.forkAt
447
+ switchTo(b.id, clamp(t, b.forkAt, b.end)).then((ok) => ok || flash("Couldn't switch to that timeline"))
448
+ })
449
+ gutter.addEventListener("dblclick", (e) => {
450
+ const el = e.target.closest("[data-lane]")
451
+ if (el) renameLane(Number(el.dataset.lane))
452
+ })
453
+ document.addEventListener("pointerdown", (e) => {
454
+ if (!menuEl.hidden && !/** @type {Element} */ (e.target).closest("#wb-menu")) menuEl.hidden = true
455
+ })
456
+
457
+ function renameLane(id) {
458
+ const b = branchById(id)
459
+ const el = gutter.querySelector(`[data-lane="${id}"]`)
460
+ if (!b || !el) return
461
+ el.classList.remove("thin")
462
+ el.innerHTML = `<input value="${esc(b.name)}" aria-label="Timeline name" maxlength="40">`
463
+ const input = el.querySelector("input")
464
+ input.focus()
465
+ input.select()
466
+ let done = false
467
+ const finish = (save) => {
468
+ if (done) return
469
+ done = true
470
+ const v = input.value.trim()
471
+ if (save && v) b.name = v
472
+ invalidateGutter()
473
+ input.remove()
474
+ }
475
+ input.addEventListener("keydown", (e) => {
476
+ e.stopPropagation()
477
+ if (e.key === "Enter") finish(true)
478
+ if (e.key === "Escape") finish(false)
479
+ })
480
+ input.addEventListener("blur", () => finish(true))
481
+ }
482
+
483
+ async function deleteTimeline(id) {
484
+ const b = branchById(id)
485
+ if (!b || !b.parentId) return false
486
+ const doomed = new Set([id])
487
+ for (let grew = true; grew; ) {
488
+ grew = false
489
+ for (const x of D.branches) {
490
+ if (x.parentId && doomed.has(x.parentId) && !doomed.has(x.id)) {
491
+ doomed.add(x.id)
492
+ grew = true
493
+ }
494
+ }
495
+ }
496
+ // Standing on it? Step back onto its parent first (that restores the
497
+ // parent's code too). If that didn't happen, delete nothing.
498
+ if (doomed.has(D.activeId) && !(await switchTo(b.parentId, b.forkAt))) return false
499
+ D.branches = D.branches.filter((x) => !doomed.has(x.id))
500
+ D.notes = D.notes.filter((n) => !doomed.has(n.branchId))
501
+ D.markers = D.markers.filter((m) => !doomed.has(m.branchId))
502
+ return true
503
+ }
504
+
505
+ // ---- keys -----------------------------------------------------------------------
506
+
507
+ const typing = (el) => el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)
508
+
509
+ // Returns true if the key was the dock's. fromApp: handed over by a view-only
510
+ // app (its fields can't be typed in then, so a key in one is still the dock's).
511
+ function dockKey(e, fromApp) {
512
+ if (e.metaKey || e.ctrlKey || (!fromApp && typing(e.target))) return false
513
+ const s = D.last
514
+ if (!s || !s.started) return false
515
+ if (e.code === "Space" || (e.altKey && e.code === "KeyP")) {
516
+ togglePlay()
517
+ return true
518
+ }
519
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
520
+ stepBy(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)
521
+ return true
522
+ }
523
+ if (e.altKey) return false
524
+ if (e.code === "KeyF") {
525
+ fitAll()
526
+ return true
527
+ }
528
+ if (e.key === "+" || e.key === "=") {
529
+ newTimelineAt(shownTime(s))
530
+ return true
531
+ }
532
+ if (e.code === "KeyM") {
533
+ addFlag()
534
+ return true
535
+ }
536
+ return false
537
+ }