retake-dev 0.5.0 → 0.5.1

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.
@@ -0,0 +1,192 @@
1
+ // A compiled bundle's line → the original file:line, through the bundle's
2
+ // source map. For notes whose source is a served chunk (Next/Turbopack and
3
+ // webpack chunks, Vite's optimized deps, a production-style bundle) rather
4
+ // than the app's own file. No dependencies: a VLQ decoder, and index maps
5
+ // (`sections`, as Turbopack writes them) read section by section.
6
+ // The dock has the same logic in src/shell/30-notes.js (it can't import).
7
+
8
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
9
+
10
+ /** "mappings" → per generated line, segments [genCol, sourceIndex, origLine, origCol] (0-based). */
11
+ export function decodeMappings(mappings) {
12
+ const lines = []
13
+ let src = 0, oLine = 0, oCol = 0
14
+ for (const text of String(mappings || "").split(";")) {
15
+ const segs = []
16
+ let gCol = 0
17
+ for (const seg of text.split(",")) {
18
+ if (!seg) continue
19
+ const v = []
20
+ for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
21
+ const d = B64.indexOf(seg[i])
22
+ if (d < 0) break
23
+ acc += (d & 31) << shift
24
+ if (d & 32) shift += 5
25
+ else {
26
+ v.push(acc & 1 ? -(acc >> 1) : acc >> 1)
27
+ acc = shift = 0
28
+ }
29
+ }
30
+ gCol += v[0] || 0
31
+ if (v.length >= 4) {
32
+ src += v[1]
33
+ oLine += v[2]
34
+ oCol += v[3]
35
+ segs.push([gCol, src, oLine, oCol])
36
+ }
37
+ }
38
+ lines.push(segs)
39
+ }
40
+ return lines
41
+ }
42
+
43
+ const decoded = new WeakMap()
44
+ /**
45
+ * Where generated line/column (1-based; col may be null) came from.
46
+ * @returns {{ source: string, line: number, sources: string[] } | null}
47
+ */
48
+ export function originalPosition(map, line, col) {
49
+ if (!map || !(line > 0)) return null
50
+ if (Array.isArray(map.sections)) {
51
+ const L = line - 1
52
+ const C = col > 0 ? col - 1 : null
53
+ /** @type {any} */
54
+ let hit = null
55
+ for (const s of map.sections) {
56
+ const o = s.offset || { line: 0, column: 0 }
57
+ if (o.line < L || (o.line === L && (C == null || o.column <= C))) hit = s
58
+ else break
59
+ }
60
+ if (!hit || !hit.map) return null
61
+ const o = hit.offset || { line: 0, column: 0 }
62
+ const c = C == null ? null : (L === o.line ? C - o.column : C) + 1
63
+ const pos = originalPosition(hit.map, L - o.line + 1, c)
64
+ return pos && { ...pos, sources: map.sections.flatMap((x) => (x.map && x.map.sources) || []) }
65
+ }
66
+ let lines = decoded.get(map)
67
+ if (!lines) decoded.set(map, (lines = decodeMappings(map.mappings)))
68
+ const segs = lines[line - 1]
69
+ if (!segs || !segs.length) return null
70
+ let best = segs[0]
71
+ if (col > 0) for (const s of segs) if (s[0] <= col - 1) best = s
72
+ const sources = (map.sources || []).map((s) => (map.sourceRoot && s && !/^[a-z]+:/i.test(s) && !s.startsWith("/") ? map.sourceRoot.replace(/\/?$/, "/") + s : s))
73
+ const source = sources[best[1]]
74
+ return source ? { source, line: best[2] + 1, sources } : null
75
+ }
76
+
77
+ /** Library code (not where the app's own element was written). */
78
+ export const isLibrary = (file) => /(^|\/)node_modules\/|\/\.vite\/deps\/|react-dom|jsx-dev-runtime|jsx-runtime|\[turbopack\]|turbopack\/|webpack\/(bootstrap|runtime)/.test(String(file || ""))
79
+
80
+ /**
81
+ * A served bundle rather than a source file: Next's /_next/static chunks,
82
+ * webpack/Turbopack chunk names, Vite's deps, hashed build assets.
83
+ */
84
+ export function isCompiled(file) {
85
+ const f = String(file || "").replace(/[?#].*$/, "")
86
+ return (
87
+ /\/_next\/static\/|\/\.vite\/deps\/|\/node_modules\/\.vite\//.test(f) ||
88
+ /\._\.js$|[-_.](?=[0-9a-z]*\d)[0-9a-z]{6,}\.(m?js|cjs)$/i.test(f.split("/").pop() || "") ||
89
+ /\/assets\/[^/]+-[\w-]{8}\.js$/.test(f)
90
+ )
91
+ }
92
+
93
+ /**
94
+ * A map's source name as a path: `webpack://app/./src/a.tsx` → `src/a.tsx`,
95
+ * `turbopack:///[project]/app/page.tsx` → `app/page.tsx`, `file:///x/y` → `/x/y`,
96
+ * then relative to `root` when it's inside it. Without a root, an absolute path
97
+ * is made relative to the project the map's own node_modules sources sit in.
98
+ * @param {string} name
99
+ * @param {{ root?: string | null, sources?: string[] }} [options]
100
+ */
101
+ export function cleanSource(name, { root = null, sources = [] } = {}) {
102
+ let p = String(name || "").replace(/[?#].*$/, "")
103
+ p = p.replace(/^webpack:\/\/[^/]*\//, "").replace(/^turbopack:\/\/\/?(\[project\]\/)?/, "").replace(/^\[project\]\//, "")
104
+ if (p.startsWith("file://")) {
105
+ try {
106
+ p = decodeURIComponent(new URL(p).pathname)
107
+ } catch {
108
+ p = p.slice(7)
109
+ }
110
+ }
111
+ p = p.replace(/^\.\//, "")
112
+ if (p.startsWith("/")) {
113
+ const roots = []
114
+ if (root) roots.push(String(root).replace(/\/$/, ""), "/private" + String(root).replace(/\/$/, ""))
115
+ for (const s of sources) {
116
+ const m = /^(?:file:\/\/)?(\/.*?)\/node_modules\//.exec(String(s || ""))
117
+ if (m) roots.push(m[1])
118
+ }
119
+ for (const r of roots) if (r && p.startsWith(r + "/")) return p.slice(r.length + 1)
120
+ }
121
+ return p
122
+ }
123
+
124
+ /** The map's URL for a bundle: a SourceMap header, its last sourceMappingURL comment, else `<url>.map`. */
125
+ export function mapUrlOf(code, url, headers) {
126
+ const h = headers && (headers.get("sourcemap") || headers.get("x-sourcemap"))
127
+ if (h) return new URL(h, url).href
128
+ const all = [...String(code).matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)]
129
+ if (all.length) {
130
+ const ref = all[all.length - 1][1]
131
+ return ref.startsWith("data:") ? ref : new URL(ref, url).href
132
+ }
133
+ return null
134
+ }
135
+
136
+ function parseDataUrl(ref) {
137
+ const comma = ref.indexOf(",")
138
+ const body = ref.slice(comma + 1)
139
+ return /;base64/.test(ref.slice(0, comma)) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body)
140
+ }
141
+
142
+ /**
143
+ * Loads `url`'s source map. Resolves to the parsed map, or null.
144
+ * @param {string} url
145
+ * @param {{ fetch?: typeof fetch, timeoutMs?: number }} [options]
146
+ */
147
+ export async function loadSourceMap(url, { fetch: get = fetch, timeoutMs = 5000 } = {}) {
148
+ const ac = new AbortController()
149
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
150
+ try {
151
+ const res = await get(url, { signal: ac.signal })
152
+ if (!res.ok) return null
153
+ const code = await res.text()
154
+ let ref = mapUrlOf(code, url, res.headers)
155
+ const tries = ref ? [ref] : [url.replace(/[?#].*$/, "") + ".map"]
156
+ for (const r of tries) {
157
+ try {
158
+ if (r.startsWith("data:")) return JSON.parse(parseDataUrl(r))
159
+ const m = await get(r, { signal: ac.signal })
160
+ if (m.ok) return JSON.parse(await m.text())
161
+ } catch {}
162
+ }
163
+ return null
164
+ } catch {
165
+ return null
166
+ } finally {
167
+ clearTimeout(timer)
168
+ }
169
+ }
170
+
171
+ /**
172
+ * A compiled source { file, line, col? } → { file, line, from } in the app's
173
+ * own code, or { library } when the line is library code, or null.
174
+ * @param {{ file: string, line?: number | null, col?: number | null, url?: string }} src
175
+ * @param {{ base: string, root?: string | null, fetch?: typeof fetch }} options
176
+ */
177
+ export async function resolveSource(src, { base, root = null, fetch: get = fetch }) {
178
+ if (!src || !src.file || !((src.line ?? 0) > 0)) return null
179
+ let url
180
+ try {
181
+ url = src.url && /^https?:/.test(src.url) ? new URL(new URL(src.url).pathname + new URL(src.url).search, base.replace(/\/?$/, "/")).href : new URL(src.file, base.replace(/\/?$/, "/")).href
182
+ } catch {
183
+ return null
184
+ }
185
+ const map = await loadSourceMap(url, { fetch: get })
186
+ if (!map) return null
187
+ const pos = originalPosition(map, src.line, src.col || null)
188
+ if (!pos) return null
189
+ const file = cleanSource(pos.source, { root, sources: pos.sources })
190
+ if (isLibrary(file)) return { library: file, line: pos.line }
191
+ return { file, line: pos.line, from: `${src.file}:${src.line}` }
192
+ }
@@ -90,6 +90,10 @@ function swapIn(b) {
90
90
  } catch {}
91
91
  f.className = "live"
92
92
  f.removeAttribute("aria-hidden")
93
+ // The new frame is the one on show before the old one goes: the old one
94
+ // blurs as it's removed, which must not read as ⌘ let go (F99).
95
+ D.frame = f
96
+ D.PT = pt
93
97
  if (old) {
94
98
  let canvas = false
95
99
  try {
@@ -102,14 +106,14 @@ function swapIn(b) {
102
106
  setTimeout(() => delete f.dataset.enter, 200)
103
107
  } else old.remove()
104
108
  }
105
- D.frame = f
106
- D.PT = pt
107
109
  D.frameBranch = b.branchId != null ? b.branchId : D.activeId
108
110
  D.building = null
109
111
  window.__retakeShell.rebuilding = false
110
112
  D.scopeEl = null // an element of the old frame
111
113
  noteBuilt(b)
112
114
  hookFrameKeys(f.contentWindow)
115
+ // A tool that's on stays on, in the new frame, over what's under the pointer.
116
+ syncPicking()
113
117
  if (b.fork) forkNow()
114
118
  else if (b.play) pt.play()
115
119
  }
@@ -568,6 +572,7 @@ function render() {
568
572
  renderShield(s)
569
573
  if (s.started) renderTimeline(s, shownT)
570
574
  renderExtras(s)
575
+ renderFocusOverlay(s)
571
576
  if (D.collapsed && fab.dataset.phase !== readoutPhase.dataset.phase) fab.dataset.phase = readoutPhase.dataset.phase
572
577
  }
573
578
 
@@ -688,8 +693,12 @@ const refocus = () => D.frame && D.frame.contentWindow && D.frame.contentWindow.
688
693
  document.addEventListener("click", (e) => {
689
694
  const target = /** @type {Element} */ (e.target)
690
695
  const b = /** @type {HTMLButtonElement | null} */ (target.closest("button, [data-branch], [data-note]"))
696
+ // A press on the track while writing a note (the element row, a range, the
697
+ // playhead) keeps the note open.
698
+ const held = D.focusHold
699
+ D.focusHold = false
691
700
  if (!b) {
692
- if (!target.closest(".card")) closeCard()
701
+ if (!target.closest(".card") && !(held && target.closest(".track"))) closeCard()
693
702
  return
694
703
  }
695
704
  const a = b.dataset.a
@@ -896,7 +905,14 @@ window.addEventListener("keydown", (e) => {
896
905
  e.preventDefault()
897
906
  return setCollapsed(!D.collapsed)
898
907
  }
908
+ // Tab with the dock's window focused cycles what's under the pointer, as in the app.
909
+ if (e.key === "Tab" && !e.metaKey && mode() && !typing(e.target) && layers.length > 1) {
910
+ e.preventDefault()
911
+ return cycleLayer(e.shiftKey ? -1 : 1)
912
+ }
899
913
  if (e.key === "Escape") {
914
+ // An open clip closes first, then a selected range, then the note.
915
+ if (closeFocusClip() || clearRange()) return
900
916
  setPicking(null)
901
917
  closeCard()
902
918
  return
@@ -923,5 +939,11 @@ function hookFrameKeys(win) {
923
939
  )
924
940
  } catch {}
925
941
  }
926
- window.addEventListener("keyup", (e) => e.key === "Meta" && window.__retakeShell.meta(false))
927
- window.addEventListener("blur", () => window.__retakeShell.meta(false))
942
+ window.addEventListener("keyup", (e) => e.key === "Meta" && setMeta(false))
943
+ // The dock's window losing focus lets go of ⌘, unless the focus only went
944
+ // into the app's frame (whose own blur, if it goes, says so).
945
+ window.addEventListener("blur", () => {
946
+ const a = document.activeElement
947
+ if (a && a.tagName === "IFRAME") return
948
+ window.__retakeShell.meta(false)
949
+ })
@@ -16,15 +16,21 @@ const net = {
16
16
  activeSavedAt: 0,
17
17
  }
18
18
 
19
+ const GZIP_OVER = 1 << 20
20
+
19
21
  async function api(method, path, body) {
20
22
  const headers = {}
21
23
  if (method !== "GET") headers["x-retake-token"] = TOKEN
22
24
  if (body !== undefined) headers["content-type"] = "application/json"
23
- const res = await fetch("/__retake/" + path, {
24
- method,
25
- headers,
26
- body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
27
- })
25
+ /** @type {string | Blob | undefined} */
26
+ let data = body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body)
27
+ // A large upload goes gzipped (JSON shrinks ~10x): a Next.js middleware
28
+ // (retake-dev/next) gets 10 MB of a request body at most.
29
+ if (typeof data === "string" && data.length > GZIP_OVER && typeof CompressionStream === "function") {
30
+ data = await new Response(new Blob([data]).stream().pipeThrough(new CompressionStream("gzip"))).blob()
31
+ headers["content-encoding"] = "gzip"
32
+ }
33
+ const res = await fetch("/__retake/" + path, { method, headers, body: data })
28
34
  if (res.status === 404) return { missing: true }
29
35
  if (!res.ok) throw new Error(`${method} /__retake/${path}: ${res.status}`)
30
36
  const text = await res.text()
@@ -198,7 +198,9 @@ function treeOrder() {
198
198
  }
199
199
 
200
200
  function layoutLanes(g) {
201
- const avail = g.h - RULER - 4
201
+ // The focused element's row (32-anims.js) takes its height first.
202
+ const fh = focusHeight(g)
203
+ const avail = g.h - RULER - 4 - fh
202
204
  const order = treeOrder()
203
205
  const n = order.length
204
206
  const compact = n * LANE > avail
@@ -212,6 +214,7 @@ function layoutLanes(g) {
212
214
  y += pitch
213
215
  })
214
216
  D.rowOrder = order.map((b) => b.id)
217
+ D.focusRow = fh ? { y0: Math.min(Math.round(y) + 2, g.h - fh), h: fh } : null
215
218
  return lanes
216
219
  }
217
220
 
@@ -479,6 +482,7 @@ function renderTimeline(s, shownT) {
479
482
  D.branches.map((b) => `${b.id}.${b.end.toFixed(0)}.${b.forkAt}`).join(","),
480
483
  tl.markers.length, act ? act.values.length : -1, D.notes.map((n) => `${n.id}.${n.status}.${n.branchId}`).join(","),
481
484
  D.markers.length, lensClips ? D.lens.key : "", D.branchT, D.snapT, D.dragT, growing || bandFading ? performance.now() : 0, D.hoverRow,
485
+ focusKey(), D.range ? `${D.range.from}-${D.range.to}` : "",
482
486
  ].join("|")
483
487
  if (key === sceneKey) return
484
488
  sceneKey = key
@@ -532,6 +536,9 @@ function renderTimeline(s, shownT) {
532
536
  scene.lanes.push({ id: active.id, y: p.y, x0: p.x0, x1: p.x1, color, active: true, empty: p.empty })
533
537
  scene.actions = drawActions(g, tl.markers, A.y, lo, activeEnd, dim)
534
538
  if (lensClips) scene.capsules = drawLens(g, lensClips, A.y)
539
+ // The focused element's own row, and a range of recording time (Shift+drag).
540
+ if (D.focus) scene.focus = drawFocusRow(g, s)
541
+ if (D.range) scene.range = drawRecordingRange(g)
535
542
 
536
543
  // Bookmarks, on the ruler.
537
544
  ctx.fillStyle = "#f2f2f5"
@@ -602,6 +609,12 @@ function renderTimeline(s, shownT) {
602
609
  scene.notes.push({ id: n.id, x, y: L.y, r: 5 })
603
610
  return
604
611
  }
612
+ // A range note: a thin bracket from its start to its end.
613
+ if (n.range && n.range.to > n.range.from) {
614
+ const x1 = xOf(n.range.to, g)
615
+ ctx.fillRect(x, L.y - 12, Math.max(1, x1 - x), 1)
616
+ ctx.fillRect(x1 - 0.5, L.y - 14, 1, 5)
617
+ }
605
618
  const label = String(i + 1)
606
619
  const w = 8 + label.length * 5
607
620
  roundRect(x - w / 2, L.y - 17, w, 12, 6)
@@ -627,6 +640,8 @@ function hitAt(clientX, clientY) {
627
640
  const sc = D.scene
628
641
  if (!sc) return null
629
642
  for (const n of sc.notes) if (Math.abs(x - n.x) <= n.r && Math.abs(y - n.y) <= n.r) return { kind: "note", id: n.id }
643
+ const fh = focusHit(x, y)
644
+ if (fh) return fh
630
645
  for (const c of sc.capsules) if (x >= c.x0 - 2 && x <= c.x1 + 2 && Math.abs(y - c.y) <= 8) return { kind: "capsule", clip: c.clip }
631
646
  if (y < RULER) for (const b of sc.bookmarks) if (Math.abs(x - b.x) <= 5) return { kind: "bookmark", id: b.id }
632
647
  let best = null
@@ -642,7 +657,8 @@ let gutterKey = ""
642
657
  const invalidateGutter = () => (gutterKey = "")
643
658
  function renderGutter(lanes) {
644
659
  if (gutter.querySelector("input")) return // renaming
645
- const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|")
660
+ const fr = D.focus && D.focusRow ? `${D.focus.label}@${D.focusRow.y0}:${D.focusRow.h}` : ""
661
+ const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|") + fr
646
662
  if (key === gutterKey) return
647
663
  gutterKey = key
648
664
  // Short dock: just the colour dots (the name shows on hover).
@@ -655,7 +671,7 @@ function renderGutter(lanes) {
655
671
  const title = D.compact ? ` title="${esc(b.name)}"` : ""
656
672
  return `<div class="${cls}" data-lane="${b.id}" style="top:${L.y}px;height:${D.rowPitch}px;margin-top:${-D.rowPitch / 2}px;--c:${colorOf(b)}"${title}><i></i><span>${esc(b.name)}</span></div>`
657
673
  })
658
- .join("")
674
+ .join("") + (fr ? `<div class="focus-name" style="top:${D.focusRow.y0}px;height:${Math.min(D.focusRow.h, 26)}px" title="${esc(D.focus.label)}">${esc(D.focus.label)}</div>` : "")
659
675
  }
660
676
 
661
677
  // ---- the header ---------------------------------------------------------------------
@@ -700,16 +716,25 @@ function renderHead(s, shownT) {
700
716
  const playing = (!!s.playing || !!(D.building && D.building.play)) && D.dragT == null
701
717
  playBtn.classList.toggle("playing", playing)
702
718
  playBtn.setAttribute("aria-label", playing ? "Pause" : "Play")
703
- // The hint area is only for a short warning (see flash()).
719
+ // The hint area is only for a short warning (see flash()), or, while a
720
+ // clip of the focused element is open, where the playhead is on its clock.
704
721
  if (performance.now() < flashUntil) return
722
+ const ro = focusReadout(s, shownT)
723
+ if (ro) {
724
+ if (hintEl.textContent !== ro) hintEl.textContent = ro
725
+ hintEl.classList.add("show", "readout")
726
+ hintEl.classList.remove("warn")
727
+ return
728
+ }
705
729
  if (hintEl.textContent) hintEl.textContent = ""
706
- hintEl.classList.remove("show", "warn")
730
+ hintEl.classList.remove("show", "warn", "readout")
707
731
  }
708
732
 
709
733
  // A short message in the hint's place: a warning (a refused checkout, say),
710
734
  // or with `warn: false` news ("Timeline 2 started").
711
735
  function flash(msg, { warn = true } = {}) {
712
736
  flashUntil = performance.now() + (warn ? 3500 : 2500)
737
+ hintEl.classList.remove("readout")
713
738
  hintEl.textContent = msg
714
739
  hintEl.classList.add("show")
715
740
  hintEl.classList.toggle("warn", warn)
@@ -289,6 +289,12 @@ track.addEventListener("pointerdown", (e) => {
289
289
  }
290
290
  const hit = hitAt(e.clientX, e.clientY)
291
291
  if (hit && hit.kind === "note") return goToNoteId(hit.id)
292
+ // The focused element's row (32-anims.js): open a clip, pin a point, drag a range.
293
+ if (hit && (hit.kind.startsWith("focus") || hit.kind === "range-edge") && focusPointerDown(e, hit)) return
294
+ // A press on the track while writing a note moves the note's moment with it.
295
+ if (D.focus) (D.focusHold = true), (D.focus.touched = true)
296
+ // Shift+drag: a range of recording time, for the next element picked.
297
+ if (e.shiftKey) return rangePointerDown(e, s)
292
298
  if (hit && hit.kind === "capsule") {
293
299
  // ⌘ held on one of the element's animations: fit it, go to its start.
294
300
  const c = hit.clip
@@ -334,6 +340,13 @@ track.addEventListener("pointermove", (e) => {
334
340
  if (!s || !s.started) return
335
341
  const g = geom()
336
342
  overTrack = e
343
+ if (D.focusDrag && focusPointerMove(e)) return
344
+ if (D.rangeDrag && rangePointerMove(e)) return
345
+ if (D.focus) {
346
+ const fh = focusHit(e.clientX - g.left, e.clientY - g.top)
347
+ D.focus.hoverT = fh && fh.kind === "focus-row" && D.dragT == null ? fh.t : null
348
+ D.focus.hoverClip = fh && fh.kind === "focus-clip" ? fh.clip : null
349
+ }
337
350
  if (D.dragT != null) {
338
351
  if (!D.dragMoved && Math.abs(e.clientX - D.dragX0) < DEAD_PX) return
339
352
  D.dragMoved = true
@@ -352,9 +365,11 @@ track.addEventListener("pointermove", (e) => {
352
365
  if (e.ctrlKey) return
353
366
  hover(e, s, g)
354
367
  })
355
- track.addEventListener("pointerup", endScrub)
356
- track.addEventListener("pointercancel", endScrub)
368
+ const endPress = () => focusPointerUp() || rangePointerUp() || endScrub()
369
+ track.addEventListener("pointerup", endPress)
370
+ track.addEventListener("pointercancel", endPress)
357
371
  track.addEventListener("pointerleave", () => {
372
+ if (D.focus) D.focus.hoverT = D.focus.hoverClip = null
358
373
  overTrack = null
359
374
  setBranchGuide(false)
360
375
  D.hoverRow = null
@@ -512,11 +527,19 @@ function dockKey(e, fromApp) {
512
527
  if (e.metaKey || e.ctrlKey || (!fromApp && typing(e.target))) return false
513
528
  const s = D.last
514
529
  if (!s || !s.started) return false
530
+ // Esc in a view-only app: an open clip closes, then a selected range goes, then the note.
531
+ if (e.key === "Escape" && (closeFocusClip() || clearRange())) return true
532
+ if (e.key === "Escape" && fromApp && !card.hidden) {
533
+ closeCard()
534
+ return true
535
+ }
515
536
  if (e.code === "Space" || (e.altKey && e.code === "KeyP")) {
516
537
  togglePlay()
517
538
  return true
518
539
  }
519
540
  if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
541
+ // An open clip of the focused element: its own frames and keyframes.
542
+ if (focusStep(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)) return true
520
543
  stepBy(e.key === "ArrowRight" ? 1 : -1, e.shiftKey)
521
544
  return true
522
545
  }