retake-dev 0.4.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,1353 @@
1
+ // An element's own animations, on its own clock. ⌘-clicking an element (the
2
+ // composer opens) makes it the dock's focus: a row under the lanes shows its
3
+ // clips; clicking one opens it, with a local ruler (0 = the end of its delay),
4
+ // keyframe ticks and sparklines of what it moves. On an open clip a click pins
5
+ // the note at that local time and a drag selects a range; Shift+drag on the
6
+ // track selects a range of recording time before any element is picked. The
7
+ // note then carries, for each animation, the exact point or range on its own
8
+ // clock, the keyframe segment, the values and the box there (CONTRACT.md
9
+ // "Note"), and src/note-text.js (NT) writes that for the agent.
10
+
11
+ const FOCUS_CLOSED_H = 26
12
+ const FOCUS_RULER_H = 14
13
+ const FOCUS_CAPSULE_H = 18
14
+ const SPARK_H = 10
15
+ const SPARK_N = 24
16
+ const SAMPLE_BUDGET_MS = 50
17
+ const RANGE_SAMPLES = 8
18
+ const MAX_ANIMS = 3
19
+
20
+ // The browser's own Animation methods, from the dock's window: the runtime
21
+ // patches the app frame's (it drives every animation from its clock), and a
22
+ // sample must not go through that.
23
+ const nativeAnim = {
24
+ currentTime: Object.getOwnPropertyDescriptor(Animation.prototype, "currentTime"),
25
+ playbackRate: Object.getOwnPropertyDescriptor(Animation.prototype, "playbackRate"),
26
+ cancel: Animation.prototype.cancel,
27
+ }
28
+ const KF_META = ["offset", "easing", "composite", "computedOffset"]
29
+ const MOVES = /^(transform|translate|scale|rotate|opacity|filter|clip-?path|top|left|right|bottom|inset|width|height|offset)/i
30
+ const r3 = (n) => Math.round(n * 1000) / 1000
31
+ const kebabProp = (p) => String(p).replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())
32
+
33
+ // ---- which animations are the element's -----------------------------------------------
34
+ // By the exact recorded path (the runtime's clipsFor is a prefix match on it):
35
+ // an element's own clips are its clipsFor minus its children's. A clip on an
36
+ // ancestor counts only if what it animates moves the element.
37
+
38
+ function ownClips(node) {
39
+ const all = clipsOfElement(node)
40
+ if (!all.length) return []
41
+ const inner = new Set()
42
+ const kids = [...node.children].slice(0, 80)
43
+ for (const ch of kids) for (const c of clipsOfElement(ch)) inner.add(c)
44
+ return all.filter((c) => !inner.has(c))
45
+ }
46
+ const clipProps = (c) => {
47
+ if (c.kf && c.kf.length) return [...new Set(c.kf.flatMap((k) => Object.keys(k.values || {})))]
48
+ if (c.kind === "transition" && c.property) return [c.property]
49
+ if (c.from || c.to) return [...new Set([...Object.keys(c.from || {}), ...Object.keys(c.to || {})])]
50
+ return String(c.property || "").split(/,\s*/).filter(Boolean)
51
+ }
52
+ const movesIt = (c) => {
53
+ const props = clipProps(c)
54
+ return !props.length || props.some((p) => MOVES.test(kebabProp(p)))
55
+ }
56
+
57
+ // { on: [{ clip, target, relation }], ancestors: [{ clip, target, relation, depth }], inside: [clip] }
58
+ function animGroups(el, pseudo) {
59
+ const pt = D.PT
60
+ if (pt && typeof pt.animationsOf === "function") {
61
+ try {
62
+ const r = pt.animationsOf(el, D.last ? shownTime(D.last) : 0)
63
+ if (r && Array.isArray(r.on)) {
64
+ const wrap = (c, target, relation, depth) => ({ clip: c.clip || c, target: c.el || target, relation: c.relation || relation, depth: c.depth || depth })
65
+ return {
66
+ on: r.on.map((c) => wrap(c, el, c.pseudoElement ? `pseudo ${c.pseudoElement}` : "on", 0)).filter((x) => !pseudo || (x.clip.pseudoElement || null) === pseudo),
67
+ ancestors: (r.ancestors || []).map((c) => wrap(c, el.parentElement, "ancestor (moves it)", 1)),
68
+ inside: (r.inside || []).map((c) => c.clip || c),
69
+ }
70
+ }
71
+ } catch {}
72
+ }
73
+ let on = ownClips(el).map((c) => ({ clip: c, target: el, relation: c.pseudoElement ? `pseudo ${c.pseudoElement}` : "on", depth: 0 }))
74
+ if (pseudo) on = on.filter((x) => (x.clip.pseudoElement || null) === pseudo)
75
+ const ancestors = []
76
+ for (let n = el.parentElement, d = 1; n && n.tagName !== "BODY" && n.tagName !== "HTML" && d <= 4; n = n.parentElement, d++) {
77
+ for (const c of ownClips(n)) if (!c.pseudoElement && movesIt(c)) ancestors.push({ clip: c, target: n, relation: "ancestor (moves it)", depth: d })
78
+ }
79
+ const mine = new Set(ownClips(el))
80
+ const inside = clipsOfElement(el).filter((c) => !mine.has(c))
81
+ return { on, ancestors, inside }
82
+ }
83
+
84
+ const clipEndOf = (c) => (c.end == null ? (D.last ? Math.max(D.last.end, D.last.now) : c.start) : c.end)
85
+ const runningAt = (c, t) => c.start <= t + 0.5 && t <= clipEndOf(c) + 0.5
86
+ const overlapsRange = (c, r) => c.start <= r.to && clipEndOf(c) >= r.from
87
+
88
+ // The primary animation at t (or over a range): the element's own running one
89
+ // (latest start wins), else the nearest ancestor's that moves it.
90
+ function primaryEntry(groups, t, range) {
91
+ const hits = (list) => list.filter((x) => (range ? overlapsRange(x.clip, range) : runningAt(x.clip, t)))
92
+ const on = hits(groups.on).sort((a, b) => b.clip.start - a.clip.start)
93
+ if (on.length) return on[0]
94
+ const up = hits(groups.ancestors).sort((a, b) => a.depth - b.depth || b.clip.start - a.clip.start)
95
+ if (up.length) return up[0]
96
+ // Nothing running: the element's own animation that ran last before the moment ("at the end it should...").
97
+ const before = groups.on.filter((x) => clipEndOf(x.clip) <= (range ? range.from : t) + 0.5).sort((a, b) => clipEndOf(b.clip) - clipEndOf(a.clip))
98
+ return before[0] || null
99
+ }
100
+
101
+ // ---- an animation's model: keyframes and timing ------------------------------------------
102
+ // The recording's clip has its first and last keyframe (newer runtimes keep
103
+ // all of them, clip.kf); the Animation itself, while the moment on show has it,
104
+ // has everything. Read once and kept per clip.
105
+
106
+ const animModels = new Map() // `${branchId}:${clipId}` → anims[] entry without a point
107
+ function liveAnimOf(clip, target) {
108
+ if (!target) return null
109
+ let list = []
110
+ try {
111
+ list = target.getAnimations({ subtree: !!clip.pseudoElement })
112
+ } catch {
113
+ return null
114
+ }
115
+ const want = clip.pseudoElement || null
116
+ const props = new Set(clipProps(clip))
117
+ let best = null
118
+ for (const a of list) {
119
+ const fx = /** @type {any} */ (a.effect)
120
+ if (!fx || fx.target !== target || (fx.pseudoElement || null) !== want) continue
121
+ let score = 0
122
+ if (clip.kind === "css-animation" && a.animationName === clip.label) score += 4
123
+ if (clip.kind === "transition" && a.transitionProperty === clip.property) score += 4
124
+ try {
125
+ const kp = fx.getKeyframes().flatMap((k) => Object.keys(k).filter((p) => !KF_META.includes(p)))
126
+ if (kp.some((p) => props.has(p))) score += 2
127
+ const tm = fx.getTiming()
128
+ const dur = clip.dur != null ? clip.dur : clip.end != null ? (clip.end - clip.start - (clip.delay || 0)) / (Number(clip.iterations) || 1) : null
129
+ if (dur != null && Math.abs(Number(tm.duration) - dur) < 3) score += 1
130
+ } catch {}
131
+ if (score >= 2 && (!best || score > best.score)) best = { a, score }
132
+ }
133
+ return best ? best.a : null
134
+ }
135
+ function kfValues(k) {
136
+ const out = {}
137
+ let n = 0
138
+ for (const p of Object.keys(k)) {
139
+ if (KF_META.includes(p) || n >= 8 || k[p] == null || k[p] === "") continue
140
+ out[p] = String(k[p]).slice(0, 120)
141
+ n++
142
+ }
143
+ return out
144
+ }
145
+ function modelFromLive(clip, a) {
146
+ const fx = /** @type {any} */ (a.effect)
147
+ const tm = fx.getTiming()
148
+ let rate = 1
149
+ try {
150
+ rate = nativeAnim.playbackRate.get.call(a) || 1
151
+ } catch {}
152
+ const base = NT.animFromClip(clip)
153
+ const delay = Number(tm.delay) || 0
154
+ const keyframes = fx
155
+ .getKeyframes()
156
+ .slice(0, 12)
157
+ .map((k) => ({ offset: r3(k.computedOffset != null ? k.computedOffset : k.offset), easing: k.easing || "linear", values: kfValues(k) }))
158
+ return {
159
+ ...base,
160
+ timing: {
161
+ delay,
162
+ duration: typeof tm.duration === "number" ? tm.duration : 0,
163
+ endDelay: Number(tm.endDelay) || 0,
164
+ iterations: tm.iterations === Infinity ? "infinite" : tm.iterations,
165
+ direction: tm.direction || "normal",
166
+ fill: tm.fill || "none",
167
+ easing: tm.easing || "linear",
168
+ playbackRate: rate,
169
+ start: clip.start,
170
+ activeStart: clip.start + delay / rate,
171
+ end: clip.end == null ? null : clip.end,
172
+ },
173
+ keyframes,
174
+ approx: false,
175
+ }
176
+ }
177
+ // Motion (framer-motion) keyframes: the motion element's animate / transition
178
+ // props have what its inline style writes don't: every keyframe, `times`,
179
+ // the ease of each segment, the duration. Motion eases each segment on its
180
+ // own (one ease = that ease on every segment), so this is a model with
181
+ // per-keyframe easing and a linear effect, like WAAPI's. Springs, variants
182
+ // and functions aren't read (null).
183
+ const MOTION_EASE = {
184
+ linear: "linear",
185
+ easeIn: "cubic-bezier(0.42, 0, 1, 1)",
186
+ easeOut: "cubic-bezier(0, 0, 0.58, 1)",
187
+ easeInOut: "cubic-bezier(0.42, 0, 0.58, 1)",
188
+ circIn: "cubic-bezier(0, 0.65, 0.55, 1)",
189
+ circOut: "cubic-bezier(0.55, 0, 1, 0.45)",
190
+ backIn: "cubic-bezier(0.31, 0.01, 0.66, -0.59)",
191
+ backOut: "cubic-bezier(0.33, 1.53, 0.69, 0.99)",
192
+ }
193
+ const motionEase = (e) => (Array.isArray(e) && e.length === 4 && e.every((v) => typeof v === "number") ? `cubic-bezier(${e.join(", ")})` : typeof e === "string" ? MOTION_EASE[e] || null : null)
194
+ // A Motion value read off an inline style: x/y from translate, scale, rotate (deg), opacity.
195
+ function motionValueOf(key, inline) {
196
+ const tr = String((inline && inline.transform) || "")
197
+ const n = (re) => {
198
+ const m = re.exec(tr)
199
+ return m ? Number(m[1]) : null
200
+ }
201
+ if (key === "x") return n(/translateX\((-?[\d.]+)px\)/) ?? n(/translate(?:3d)?\((-?[\d.]+)px/)
202
+ if (key === "y") return n(/translateY\((-?[\d.]+)px\)/) ?? n(/translate(?:3d)?\([^,]+,\s*(-?[\d.]+)px/)
203
+ if (key === "scale") return n(/scale\((-?[\d.]+)\)/)
204
+ if (key === "rotate") return n(/rotate\((-?[\d.]+)deg\)/)
205
+ if (key === "opacity" && inline && inline.opacity != null && inline.opacity !== "") return Number(inline.opacity)
206
+ return null
207
+ }
208
+ function motionModel(clip, el) {
209
+ if (clip.kind !== "js" || !el) return null
210
+ let p = null
211
+ try {
212
+ for (let f = fiberOf(el), i = 0; f && i < 4 && !p; f = f.return, i++) {
213
+ const q = f.memoizedProps || {}
214
+ if (q.animate && typeof q.animate === "object" && !Array.isArray(q.animate)) p = q
215
+ }
216
+ } catch {}
217
+ if (!p) return null
218
+ const tr = p.transition && typeof p.transition === "object" ? p.transition : {}
219
+ const keys = Object.keys(p.animate).filter((k) => Array.isArray(p.animate[k]) && p.animate[k].length >= 2 && p.animate[k].every((v) => typeof v === "number" || /^-?[\d.]+(px|deg)?$/.test(String(v))))
220
+ if (!keys.length) return null
221
+ const tOf = (k) => (tr[k] && typeof tr[k] === "object" ? { ...tr, ...tr[k] } : tr)
222
+ const t0 = tOf(keys[0])
223
+ const spring = (t) => t.type === "spring" || (t.duration == null && (t.stiffness != null || t.damping != null || t.bounce != null))
224
+ if (spring(t0)) return null
225
+ const same = (t) => JSON.stringify([t.duration, t.delay, t.times, t.ease, t.repeat]) === JSON.stringify([t0.duration, t0.delay, t0.times, t0.ease, t0.repeat])
226
+ const use = keys.filter((k) => p.animate[k].length === p.animate[keys[0]].length && same(tOf(k)))
227
+ const n = p.animate[keys[0]].length
228
+ const times = Array.isArray(t0.times) && t0.times.length === n ? t0.times.map(Number) : Array.from({ length: n }, (_, i) => i / (n - 1))
229
+ const eases = Array.isArray(t0.ease) && !(t0.ease.length === 4 && t0.ease.every((v) => typeof v === "number")) ? t0.ease.map(motionEase) : Array.from({ length: n - 1 }, () => motionEase(t0.ease == null ? "easeInOut" : t0.ease))
230
+ if (eases.some((e) => !e)) return null
231
+ const duration = (t0.duration == null ? 0.8 : Number(t0.duration)) * 1000
232
+ const delay = (Number(t0.delay) || 0) * 1000
233
+ const keyframes = times.map((o, i) => ({ offset: r3(o), easing: i < n - 1 ? eases[i] || "linear" : "linear", values: Object.fromEntries(use.map((k) => [k, String(parseFloat(p.animate[k][i]))])) }))
234
+ const iterations = t0.repeat ? (t0.repeat === Infinity ? "infinite" : Number(t0.repeat) + 1) : 1
235
+ const base = NT.animFromClip(clip)
236
+ const m = {
237
+ ...base,
238
+ lib: "motion",
239
+ name: `${use.join(", ")} keyframes`,
240
+ timing: { delay, duration, endDelay: 0, iterations, direction: t0.repeatType === "reverse" || t0.repeatType === "mirror" ? "alternate" : "normal", fill: "both", easing: "linear", playbackRate: 1, start: clip.start, activeStart: clip.start, end: clip.end == null ? null : clip.end },
241
+ keyframes,
242
+ motionKeyframes: true,
243
+ leadMs: 0,
244
+ approx: false,
245
+ }
246
+ // Where local 0 is: Motion writes nothing at 0, so the first recorded write
247
+ // is a little in; its value says how far (found on the first segment).
248
+ const k = use[0]
249
+ const v1 = motionValueOf(k, clip.first)
250
+ const a = Number(keyframes[0].values[k])
251
+ const b = Number(keyframes[1].values[k])
252
+ let lead = 0
253
+ if (v1 != null && b !== a) {
254
+ const f = NT.easingFn(keyframes[0].easing)
255
+ const want = (v1 - a) / (b - a)
256
+ let lo = 0
257
+ let hi = 1
258
+ for (let i = 0; i < 30; i++) {
259
+ const mid = (lo + hi) / 2
260
+ if (f(mid) < want) lo = mid
261
+ else hi = mid
262
+ }
263
+ lead = ((lo + hi) / 2) * (times[1] - times[0]) * duration
264
+ if (!(lead >= 0 && lead < 3 * FRAME_MS)) lead = 0
265
+ }
266
+ m.timing.activeStart = clip.start - lead
267
+ m.timing.start = m.timing.activeStart - delay
268
+ m.leadMs = Math.round(lead * 10) / 10
269
+ return m
270
+ }
271
+
272
+ function modelOf(clip, target) {
273
+ const key = `${D.activeId}:${clip.id}`
274
+ const have = animModels.get(key)
275
+ if (have && !have.approx) return have
276
+ let m = null
277
+ if (clip.kind === "js") m = motionModel(clip, target)
278
+ if (m) {
279
+ animModels.set(key, m)
280
+ libOfElement(m, target)
281
+ madeBy(m, clip)
282
+ return m
283
+ }
284
+ if (clip.kf && clip.timing) m = NT.animFromClip(clip)
285
+ else {
286
+ const live = liveAnimOf(clip, target)
287
+ if (live) {
288
+ try {
289
+ m = modelFromLive(clip, live)
290
+ lookUpDefined(m, live)
291
+ } catch {}
292
+ }
293
+ }
294
+ if (m) {
295
+ animModels.set(key, m)
296
+ libOfElement(m, target)
297
+ madeBy(m, clip)
298
+ return m
299
+ }
300
+ if (have) return have
301
+ const guess = NT.animFromClip(clip)
302
+ animModels.set(key, guess)
303
+ libOfElement(guess, target)
304
+ madeBy(guess, clip)
305
+ return guess
306
+ }
307
+
308
+ // element.animate()'s stack (clip.stack, from the runtime), through source
309
+ // maps: the first frame in the app's own code is where it is defined, and a
310
+ // library on the way (Motion, GSAP...) names what made it.
311
+ /** @type {[string, RegExp][]} */
312
+ const LIBS = [
313
+ ["motion", /framer-motion|motion-dom|motion-utils|node_modules\/motion\//],
314
+ ["gsap", /node_modules\/gsap\b|\bgsap[\w.-]*\.js/],
315
+ ["anime", /animejs|anime\.es/],
316
+ ["react-spring", /@react-spring|react-spring/],
317
+ ]
318
+ const libOf = (s) => (LIBS.find(([, re]) => re.test(s)) || [])[0] || null
319
+ // What the element itself says about the library moving it: GSAP keeps a cache
320
+ // on its targets (_gsap); a Motion element's component (motion.div) holds the
321
+ // animate / transition props it was given.
322
+ function libOfElement(m, el) {
323
+ if (!el || m.libChecked) return
324
+ m.libChecked = true
325
+ try {
326
+ if (/** @type {any} */ (el)._gsap) m.lib = m.lib || "gsap"
327
+ for (let f = fiberOf(el), i = 0; f && i < 4; f = f.return, i++) {
328
+ const name = f.type && (f.type.displayName || f.type.name || (f.type.render && f.type.render.displayName))
329
+ const p = f.memoizedProps || {}
330
+ if (!/^motion\./.test(String(name || "")) && !(i > 0 && (p.animate || p.initial || p.whileInView) && p.transition !== undefined)) continue
331
+ m.lib = m.lib || "motion"
332
+ const json = (v) => {
333
+ try {
334
+ return JSON.stringify(v, (k, x) => (typeof x === "function" ? "[function]" : x))
335
+ } catch {
336
+ return null
337
+ }
338
+ }
339
+ const props = {}
340
+ for (const k of ["initial", "animate", "transition", "whileInView", "variants"]) if (p[k] !== undefined) props[k] = String(json(p[k]) || "").slice(0, 300)
341
+ m.motionProps = { component: String(name || "motion"), props }
342
+ break
343
+ }
344
+ } catch {}
345
+ }
346
+ // A library that drives the element from its own loop leaves no line of the
347
+ // app's on the stack: the element's own JSX (mapped by the time the note is
348
+ // saved) is where its motion is written, or its component for GSAP.
349
+ function definedByElement(a, desc) {
350
+ const src = desc && desc.source
351
+ if (a.defined || !src || !src.file || src.compiled) return
352
+ const comp = desc.components && desc.components[0]
353
+ if (a.lib === "motion") a.defined = { file: src.file, line: src.line || null, what: "the motion element's props (animate / transition)" }
354
+ else if (a.lib === "gsap") a.defined = { file: src.file, line: null, what: `in ${comp || "the component that renders it"}: its gsap.to / from / timeline calls on this element` }
355
+ }
356
+
357
+ async function madeBy(m, clip) {
358
+ if (m.madeBy || !Array.isArray(clip.stack) || !clip.stack.length || (m.kind !== "waapi" && m.kind !== "js")) return
359
+ m.madeBy = true
360
+ for (const fr of clip.stack) {
361
+ let file = null
362
+ let line = null
363
+ const map = await loadMap(fr.url).catch(() => null)
364
+ const pos = map && originalPosition(map, fr.line, fr.col)
365
+ if (pos) {
366
+ file = cleanSource(pos.source, map)
367
+ line = pos.line
368
+ }
369
+ const lib = libOf(file || "") || libOf(fr.url)
370
+ if (lib) {
371
+ if (!m.lib) m.lib = lib
372
+ continue
373
+ }
374
+ if (!file || libraryFile(file)) continue
375
+ m.defined = { file: shortPath(file), line, what: m.kind === "js" ? (m.lib ? `${m.lib}, from here` : "the first style write") : m.lib ? `${m.lib} (it calls element.animate())` : "element.animate()" }
376
+ break
377
+ }
378
+ }
379
+ // Where a CSS animation is written (the runtime reads the stylesheet's source map).
380
+ function lookUpDefined(m, live) {
381
+ const pt = D.PT
382
+ if (!pt || typeof pt.cssSourceFor !== "function" || m.defined || !(m.kind === "css-animation" || m.kind === "css-transition")) return
383
+ Promise.resolve()
384
+ .then(() => pt.cssSourceFor(live))
385
+ .then((r) => {
386
+ if (!r) return
387
+ // A stylesheet's URL is a path from the dev server's root: the project's own file.
388
+ const where = (f) => (f ? shortPath(f) : "<style> in the page")
389
+ if (r.keyframes && r.keyframes.line) m.defined = { file: where(r.keyframes.file || r.file), line: r.keyframes.line, what: `@keyframes ${r.keyframes.name}` }
390
+ else if (r.line) m.defined = { file: where(r.file), line: r.line, what: m.kind === "css-transition" ? "transition rule" : "rule" }
391
+ if (r.line) m.rule = { file: where(r.file), line: r.line, selector: r.selector || null }
392
+ })
393
+ .catch(() => {})
394
+ }
395
+
396
+ // ---- values and boxes at any moment of one animation ----------------------------------------
397
+
398
+ /** The element's box: on the page, in the viewport, its transform taken apart. */
399
+ function geometryOf(el) {
400
+ const W = el.ownerDocument.defaultView
401
+ const r = el.getBoundingClientRect()
402
+ const cs = W.getComputedStyle(el)
403
+ const sx = W.scrollX
404
+ const sy = W.scrollY
405
+ const tr = cs.transform
406
+ let dec = { tx: 0, ty: 0, sx: 1, sy: 1, rotate: 0 }
407
+ if (tr && tr !== "none") {
408
+ try {
409
+ const m = new DOMMatrix(tr)
410
+ dec = { tx: r1(m.e), ty: r1(m.f), sx: r3(Math.hypot(m.a, m.b)), sy: r3(Math.hypot(m.c, m.d)), rotate: r1((Math.atan2(m.b, m.a) * 180) / Math.PI) }
411
+ } catch {}
412
+ }
413
+ return {
414
+ page: { x: r1(r.x + sx), y: r1(r.y + sy), w: r1(r.width), h: r1(r.height) },
415
+ viewport: { x: r1(r.x), y: r1(r.y), w: r1(r.width), h: r1(r.height) },
416
+ view: { w: W.innerWidth, h: W.innerHeight },
417
+ scroll: { x: Math.round(sx), y: Math.round(sy) },
418
+ transform: tr,
419
+ decomposed: dec,
420
+ origin: cs.transformOrigin,
421
+ opacity: Number(cs.opacity),
422
+ }
423
+ }
424
+
425
+ // The animation's values (and the element's box) at recording times Ts: the
426
+ // same keyframes and timing, as one extra animation on its element held at
427
+ // each time, read, and taken off again, all in one task (nothing paints).
428
+ // It sits above the app's own (the newest script animation wins), so what is
429
+ // read is this animation at that time; ancestors stay as the moment on show.
430
+ function sampleModel(model, el, target, pseudo, Ts) {
431
+ const out = []
432
+ if (!target || !Ts.length) return out
433
+ const W = /** @type {any} */ (target.ownerDocument.defaultView)
434
+ const t = model.timing || {}
435
+ const props = [...new Set((model.keyframes || []).flatMap((k) => Object.keys(k.values || {})))].slice(0, 8)
436
+ let tmp = null
437
+ try {
438
+ const frames = (model.keyframes || []).map((k) => ({ offset: k.offset, easing: k.easing || "linear", ...k.values }))
439
+ const fx = new W.KeyframeEffect(target, frames, {
440
+ duration: Number(t.duration) || 0,
441
+ delay: Number(t.delay) || 0,
442
+ endDelay: Number(t.endDelay) || 0,
443
+ iterations: t.iterations === "infinite" ? Infinity : Number(t.iterations) || 1,
444
+ direction: t.direction || "normal",
445
+ easing: t.easing || "linear",
446
+ fill: "both",
447
+ ...(pseudo ? { pseudoElement: pseudo } : {}),
448
+ })
449
+ tmp = new W.Animation(fx, W.document.timeline)
450
+ } catch {
451
+ tmp = null
452
+ }
453
+ const rate = Number(t.playbackRate) || 1
454
+ const t0 = performance.now()
455
+ // Script-driven motion: no keyframes to hold at a time; only the moment on show can be read.
456
+ const shownAt = model.kind === "js" && D.last ? shownTime(D.last) : null
457
+ if (model.kind === "js" && tmp) {
458
+ tmp = null
459
+ }
460
+ try {
461
+ for (const T of Ts) {
462
+ if (performance.now() - t0 > SAMPLE_BUDGET_MS) break
463
+ // Motion keyframes read off its props: the values come from them; the box only at the moment on show.
464
+ if (model.motionKeyframes) {
465
+ const p = NT.pointOf(model, T)
466
+ out.push({ T: r1(T), local: p.local, progress: p.progress, values: NT.valuesAt(model, T), geometry: shownAt != null && Math.abs(T - shownAt) <= 1 ? geometryOf(el) : null })
467
+ continue
468
+ }
469
+ if (shownAt != null && Math.abs(T - shownAt) > 1) {
470
+ const p = NT.pointOf(model, T)
471
+ out.push({ T: r1(T), local: p.local, progress: p.progress, values: {}, geometry: null })
472
+ continue
473
+ }
474
+ if (tmp) nativeAnim.currentTime.set.call(tmp, (T - (Number(t.start) || 0)) * rate)
475
+ const cs = W.getComputedStyle(target, pseudo || null)
476
+ const values = {}
477
+ for (const p of props) values[p] = cs.getPropertyValue(kebabProp(p)).slice(0, 120)
478
+ const p = NT.pointOf(model, T)
479
+ out.push({ T: r1(T), local: p.local, progress: p.progress, values, geometry: geometryOf(el) })
480
+ }
481
+ } finally {
482
+ if (tmp)
483
+ try {
484
+ nativeAnim.cancel.call(tmp)
485
+ } catch {}
486
+ }
487
+ return out
488
+ }
489
+
490
+ // A point on the animation's clock with the values there and one frame either side.
491
+ function fullPoint(model, el, target, pseudo, T) {
492
+ const p = NT.pointOf(model, T)
493
+ const s = sampleModel(model, el, target, pseudo, [T - FRAME_MS, T, T + FRAME_MS])
494
+ if (s.length === 3 && model.motionKeyframes) {
495
+ p.values = s[1].values
496
+ p.frame = { before: s[0].values, after: s[2].values }
497
+ p.geometry = s[1].geometry || (D.last && Math.abs(shownTime(D.last) - T) <= 1 ? geometryOf(el) : null)
498
+ } else if (s.length === 3 && model.kind === "js") {
499
+ p.values = s[1].values
500
+ p.geometry = s[1].geometry
501
+ } else if (s.length === 3) {
502
+ p.values = s[1].values
503
+ p.frame = { before: s[0].values, after: s[2].values }
504
+ p.geometry = s[1].geometry
505
+ } else p.geometry = geometryOf(el)
506
+ return p
507
+ }
508
+
509
+ // ---- the focus: the element the composer is about ---------------------------------------------
510
+
511
+ function focusOn(el, layer, t) {
512
+ if (!el || !el.isConnected) return (D.focus = null)
513
+ const pseudo = layer && layer.pseudo ? layer.pseudo : null
514
+ const groups = animGroups(el, pseudo)
515
+ const entries = [...groups.on, ...groups.ancestors]
516
+ const label = `<${shortLabel(el)}>${pseudo || ""}`
517
+ let selector = null
518
+ try {
519
+ selector = selectorInfo(el).selector
520
+ } catch {}
521
+ D.focus = { el, pseudo, label, selector, groups, entries, open: null, range: null, pin: null, touched: false, spark: null, hoverT: null, edges: null }
522
+ // A range dragged on the track before the element was picked comes with it.
523
+ if (D.range) {
524
+ D.focus.range = { ...D.range }
525
+ D.range = null
526
+ const hit = entries.find((x) => overlapsRange(x.clip, D.focus.range))
527
+ if (hit) D.focus.open = openEntry(hit)
528
+ } else {
529
+ const p = primaryEntry(groups, t, null)
530
+ if (p) modelOf(p.clip, p.target)
531
+ }
532
+ return D.focus
533
+ }
534
+ // Going to a moment swaps in a new frame: the focused element (and the
535
+ // targets of its animations) are found again in the document on show, with
536
+ // the open clip, the range and the point kept.
537
+ function syncFocusEl() {
538
+ const f = D.focus
539
+ if (!f || !D.frame) return f
540
+ let doc = null
541
+ try {
542
+ doc = D.frame.contentDocument
543
+ } catch {}
544
+ if (!doc || (f.el && f.el.ownerDocument === doc)) return f
545
+ const sel = (draft && draft.el && draft.el.selector) || (f.note && f.note.el && f.note.el.selector) || f.selector
546
+ let el = null
547
+ try {
548
+ el = sel ? findEl(sel, doc) : null
549
+ } catch {}
550
+ if (!el) return f
551
+ const groups = animGroups(el, f.pseudo)
552
+ const entries = [...groups.on, ...groups.ancestors]
553
+ f.el = el
554
+ f.groups = groups
555
+ f.entries = entries
556
+ f.spark = null
557
+ f.edges = null
558
+ if (f.open) {
559
+ const x = entries.find((e) => e.clip.id === f.open.clip.id)
560
+ if (x) f.open = { ...f.open, clip: x.clip, target: x.target, relation: x.relation }
561
+ else if (f.open.target && f.open.target.ownerDocument !== doc) f.open = { ...f.open, target: el }
562
+ }
563
+ return f
564
+ }
565
+ function openEntry(x) {
566
+ return { clip: x.clip, target: x.target, relation: x.relation, model: modelOf(x.clip, x.target) }
567
+ }
568
+ function clearFocus() {
569
+ D.focus = null
570
+ D.focusDrag = null
571
+ }
572
+
573
+ // Opening a clip fits the view to it and, if the playhead is outside it, goes
574
+ // to its start, where its Animation can be read.
575
+ function openFocusClip(clip) {
576
+ const f = D.focus
577
+ if (!f) return
578
+ const x = f.entries.find((e) => e.clip === clip || e.clip.id === clip.id) || { clip, target: f.el, relation: "on" }
579
+ // Closing it goes back to the view it was opened from.
580
+ if (!f.open) f.prevView = { from: D.view.from, to: D.view.to }
581
+ f.open = openEntry(x)
582
+ f.spark = null
583
+ const end = clipEndOf(clip)
584
+ const pad = Math.max((end - clip.start) * 0.15, 10)
585
+ setView(clip.start - pad, end + pad)
586
+ const s = D.last
587
+ if (s && !runningAt(clip, shownTime(s))) {
588
+ const at = Math.min(f.open.model.timing.activeStart + 1, end)
589
+ f.pin = null
590
+ goTo(at)
591
+ }
592
+ }
593
+ function closeFocusClip() {
594
+ const f = D.focus
595
+ if (!f || !f.open) return false
596
+ f.open = null
597
+ f.range = null
598
+ f.spark = null
599
+ if (f.prevView) setView(f.prevView.from, f.prevView.to)
600
+ else fitAll()
601
+ f.prevView = null
602
+ return true
603
+ }
604
+ function clearRange() {
605
+ if (!D.range) return false
606
+ D.range = null
607
+ return true
608
+ }
609
+
610
+ // The model of the open clip, read again until the real one is in (the
611
+ // Animation is there once the playhead is inside the clip).
612
+ function openModel() {
613
+ syncFocusEl()
614
+ const o = D.focus && D.focus.open
615
+ if (!o) return null
616
+ if (o.model.approx) {
617
+ const m = modelOf(o.clip, o.target)
618
+ if (m !== o.model) {
619
+ o.model = m
620
+ D.focus.spark = null
621
+ }
622
+ }
623
+ return o.model
624
+ }
625
+
626
+ // ---- the element row on the track -------------------------------------------------------------
627
+
628
+ // A short dock keeps the lanes readable: the row gives up its sparklines first.
629
+ function focusHeight(g) {
630
+ const f = D.focus
631
+ if (!f) return 0
632
+ if (!f.open) return FOCUS_CLOSED_H
633
+ const want = FOCUS_RULER_H + FOCUS_CAPSULE_H + sparkProps().length * SPARK_H + 6
634
+ const room = g ? g.h - RULER - 4 - MIN_PITCH * Math.max(1, D.branches.length) : want
635
+ return Math.max(FOCUS_RULER_H + FOCUS_CAPSULE_H, Math.min(want, room))
636
+ }
637
+ const focusKey = () => {
638
+ const f = D.focus
639
+ if (!f) return ""
640
+ const m = f.open && f.open.model
641
+ return [f.label, f.entries.length, f.open ? f.open.clip.id : "", m && m.approx ? 1 : 0, f.range ? `${f.range.from.toFixed(1)}-${f.range.to.toFixed(1)}` : "", f.pin, f.spark ? f.spark.n : 0, f.hoverT != null ? f.hoverT.toFixed(0) : "", f.hoverClip ? f.hoverClip.id : ""].join(",")
642
+ }
643
+
644
+ // Up to three properties that change most, for sparklines.
645
+ function sparkProps() {
646
+ const sp = D.focus && D.focus.spark
647
+ return sp ? sp.props : []
648
+ }
649
+ function ensureSpark() {
650
+ const f = D.focus
651
+ const o = f && f.open
652
+ if (!o || f.spark || !D.last || D.last.playing || D.last.seeking) return
653
+ const m = openModel()
654
+ if (m.kind === "js") return void (f.spark = { Ts: [], samples: [], props: [], n: 0 })
655
+ const t = m.timing
656
+ const dur = Number(t.duration) || 0
657
+ const iters = t.iterations === "infinite" ? 1 : Math.min(Number(t.iterations) || 1, 3)
658
+ const span = dur * iters
659
+ const Ts = []
660
+ for (let i = 0; i < SPARK_N; i++) Ts.push(t.activeStart + ((span * i) / (SPARK_N - 1)) / (Number(t.playbackRate) || 1))
661
+ const samples = sampleModel(m, f.el, o.target, o.clip.pseudoElement || null, Ts)
662
+ const series = { tx: [], ty: [], scale: [], rotate: [], opacity: [], width: [], height: [] }
663
+ for (const s of samples) {
664
+ const g = s.geometry
665
+ series.tx.push(g.page.x)
666
+ series.ty.push(g.page.y)
667
+ series.scale.push(g.decomposed.sx)
668
+ series.rotate.push(g.decomposed.rotate)
669
+ series.opacity.push(g.opacity)
670
+ series.width.push(g.page.w)
671
+ series.height.push(g.page.h)
672
+ }
673
+ const spread = (v) => (v.length ? Math.max(...v) - Math.min(...v) : 0)
674
+ const norm = { tx: 1, ty: 1, scale: 0.01, rotate: 1, opacity: 0.01, width: 1, height: 1 }
675
+ const props = Object.keys(series)
676
+ .map((k) => ({ prop: k, values: series[k], spread: spread(series[k]) / norm[k] }))
677
+ .filter((x) => x.spread >= 1)
678
+ .sort((a, b) => b.spread - a.spread)
679
+ .slice(0, 3)
680
+ f.spark = { Ts: samples.map((s) => s.T), samples, props, n: samples.length }
681
+ }
682
+
683
+ // Snap points on an open clip: keyframe ticks, and 10ms steps of local time.
684
+ function focusTicks(m) {
685
+ return (m.keyframes || []).map((k) => {
686
+ const local = NT.localOfOffset(m, k.offset)
687
+ return { offset: k.offset, local, t: NT.recordingOf(m, local) }
688
+ })
689
+ }
690
+ function snapFocus(t, m, free) {
691
+ const rate = Number(m.timing.playbackRate) || 1
692
+ const dur = Number(m.timing.duration) || 0
693
+ const iters = m.timing.iterations === "infinite" ? Infinity : Number(m.timing.iterations) || 1
694
+ const lo = m.timing.activeStart
695
+ const hi = Number.isFinite(iters) ? lo + (dur * iters) / rate : clipEndOf({ start: m.timing.start, end: m.timing.end })
696
+ t = clamp(t, Math.min(lo, m.timing.start), Math.max(hi, lo))
697
+ if (free) return t
698
+ const ppm = pxPerMs(geom())
699
+ for (const k of focusTicks(m)) if (Math.abs(k.t - t) * ppm <= SNAP_PX) return k.t
700
+ const local = Math.round(((t - lo) * rate) / 10) * 10
701
+ return lo + local / rate
702
+ }
703
+
704
+ function drawFocusRow(g, s) {
705
+ const f = D.focus
706
+ const R = D.focusRow
707
+ if (!f || !R) return null
708
+ const out = { y0: R.y0, h: R.h, open: !!f.open, capsules: [], insides: [], ticks: [], ruler: [], band: null, empty: false }
709
+ ctx.save()
710
+ ctx.fillStyle = "rgba(253,230,138,0.035)"
711
+ ctx.fillRect(0, R.y0, g.w, R.h)
712
+ ctx.fillStyle = INK.hair
713
+ ctx.fillRect(0, R.y0, g.w, 1)
714
+ ctx.font = '500 10px Geist, ui-sans-serif, system-ui, sans-serif'
715
+ ctx.textBaseline = "middle"
716
+ if (!f.open) {
717
+ const y = R.y0 + R.h / 2
718
+ if (!f.entries.length) {
719
+ out.empty = true
720
+ let x = PAD_L + 2
721
+ const lead = `Nothing animates on ${f.label}`
722
+ ctx.fillStyle = INK.dim
723
+ ctx.fillText(lead, x, y)
724
+ x += ctx.measureText(lead).width + 10
725
+ const groups = new Map()
726
+ for (const c of f.groups.inside) {
727
+ const k = (c.selector || "").split(" > ").pop().replace(/:nth-of-type\(\d+\)/g, "")
728
+ const gk = groups.get(k) || { k, clips: [] }
729
+ gk.clips.push(c)
730
+ groups.set(k, gk)
731
+ }
732
+ if (groups.size) {
733
+ ctx.fillStyle = INK.faint
734
+ ctx.fillText("Inside:", x, y)
735
+ x += ctx.measureText("Inside:").width + 6
736
+ for (const gk of [...groups.values()].slice(0, 4)) {
737
+ const text = `${gk.k}${gk.clips.length > 1 ? ` ×${gk.clips.length}` : ""}`
738
+ const w = ctx.measureText(text).width
739
+ ctx.fillStyle = INK.lens
740
+ ctx.fillText(text, x, y)
741
+ out.insides.push({ x0: x, x1: x + w, y, clip: gk.clips[0] })
742
+ x += w + 10
743
+ }
744
+ }
745
+ } else {
746
+ // Clips that overlap (a transform and an opacity transition started
747
+ // together) stack in up to 3 thinner rows, so each can be opened.
748
+ const shown = f.entries.filter((e) => clipEndOf(e.clip) >= D.view.from && e.clip.start <= D.view.to).map((e) => ({ e, x0: xOf(e.clip.start, g), x1: 0, row: 0 }))
749
+ const ends = []
750
+ for (const k of shown.sort((a, b) => a.x0 - b.x0)) {
751
+ k.x1 = Math.max(k.x0 + 6, xOf(clipEndOf(k.e.clip), g))
752
+ let r = ends.findIndex((x) => x < k.x0 - 2)
753
+ if (r < 0) r = ends.length < 3 ? ends.length : ends.indexOf(Math.min(...ends))
754
+ ends[r] = k.x1
755
+ k.row = r
756
+ }
757
+ const rows = Math.max(1, ends.length)
758
+ const ch = rows === 1 ? 14 : Math.max(6, Math.floor((R.h - 4) / rows) - 2)
759
+ for (const k of shown) {
760
+ const c = k.e.clip
761
+ const cy = rows === 1 ? y : R.y0 + 3 + k.row * (ch + 2) + ch / 2
762
+ ctx.fillStyle = k.e.relation === "ancestor (moves it)" ? "rgba(253,230,138,0.55)" : INK.lens
763
+ roundRect(k.x0, cy - ch / 2, k.x1 - k.x0, ch, ch / 2)
764
+ ctx.fill()
765
+ const label = `${clipName(c)} · ${msWord(clipEndOf(c) - c.start)}`
766
+ if (ch >= 10 && ctx.measureText(label).width + 12 <= k.x1 - k.x0) {
767
+ ctx.fillStyle = INK.page
768
+ ctx.fillText(label, k.x0 + 6, cy + 0.5)
769
+ }
770
+ out.capsules.push({ clip: c, x0: k.x0, x1: k.x1, y: cy, h: ch })
771
+ }
772
+ }
773
+ ctx.restore()
774
+ return out
775
+ }
776
+ const m = openModel()
777
+ const t = m.timing
778
+ const rate = Number(t.playbackRate) || 1
779
+ const dur = Number(t.duration) || 0
780
+ const iters = t.iterations === "infinite" ? Infinity : Number(t.iterations) || 1
781
+ const yR = R.y0 + FOCUS_RULER_H
782
+ const yc = yR + FOCUS_CAPSULE_H / 2
783
+ const xs = xOf(t.start, g)
784
+ const x0 = xOf(t.activeStart, g)
785
+ const endT = clipEndOf(f.open.clip)
786
+ const x1 = Math.max(x0 + 4, xOf(endT, g))
787
+ // The delay, hatched, left of local 0.
788
+ if (x0 - xs > 1) {
789
+ ctx.save()
790
+ ctx.beginPath()
791
+ ctx.rect(xs, yc - 4, x0 - xs, 8)
792
+ ctx.clip()
793
+ ctx.strokeStyle = INK.faint
794
+ ctx.lineWidth = 1
795
+ for (let x = xs - 8; x < x0 + 8; x += 4) {
796
+ ctx.beginPath()
797
+ ctx.moveTo(x, yc + 4)
798
+ ctx.lineTo(x + 8, yc - 4)
799
+ ctx.stroke()
800
+ }
801
+ ctx.restore()
802
+ out.delay = { x0: xs, x1: x0 }
803
+ }
804
+ ctx.fillStyle = INK.lens
805
+ roundRect(x0, yc - 4, x1 - x0, 8, 4)
806
+ ctx.fill()
807
+ out.clip = { x0, x1, y: yc }
808
+ // The local ruler: 0 at the end of the delay, in ms of the animation's own clock.
809
+ const ppmLocal = pxPerMs(g) / rate
810
+ const major = STEPS.find((st) => st * ppmLocal >= 56) || STEPS[STEPS.length - 1]
811
+ const minor = STEPS.slice().reverse().find((st) => st < major && major % st === 0 && st * ppmLocal >= 7) || major
812
+ const span = Number.isFinite(iters) ? dur * iters : Math.max(0, (endT - t.activeStart) * rate)
813
+ ctx.font = MONO
814
+ ctx.textBaseline = "alphabetic"
815
+ const word = (ms) => (ms >= 1000 ? `${+(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`)
816
+ for (let ms = 0, n = 0; ms <= span + 0.01 && n < 400; ms += minor, n++) {
817
+ const x = Math.round(xOf(t.activeStart + ms / rate, g)) + 0.5
818
+ if (x < -2 || x > g.w + 2) continue
819
+ const isMajor = Math.round(ms) % major === 0
820
+ ctx.fillStyle = isMajor ? INK.dim : INK.faint
821
+ ctx.fillRect(x - 0.5, yR - (isMajor ? 5 : 3), 1, isMajor ? 4 : 2)
822
+ if (isMajor) {
823
+ const text = ms === 0 ? "0" : word(ms)
824
+ const tw = ctx.measureText(text).width
825
+ if (x + 2 + tw < g.w) ctx.fillText(text, x + 2, yR - 5)
826
+ out.ruler.push(text)
827
+ }
828
+ }
829
+ // The end of the animation's own clock, labelled (unless a step already is).
830
+ if (Number.isFinite(span) && span > 0) {
831
+ const xe = Math.round(xOf(t.activeStart + span / rate, g)) + 0.5
832
+ const text = word(dur)
833
+ if (!out.ruler.includes(text)) {
834
+ ctx.fillStyle = INK.dim
835
+ ctx.fillRect(xe - 0.5, yR - 5, 1, 4)
836
+ if (xe + 2 + ctx.measureText(text).width < g.w) ctx.fillText(text, xe + 2, yR - 5)
837
+ out.ruler.push(text)
838
+ }
839
+ }
840
+ // Iteration boundaries of a loop.
841
+ if (dur > 0 && iters > 1) {
842
+ ctx.fillStyle = INK.faint
843
+ for (let i = 1; i < Math.min(Number.isFinite(iters) ? iters : 400, 400); i++) {
844
+ const T = t.activeStart + (i * dur) / rate
845
+ if (T > endT) break
846
+ ctx.fillRect(Math.round(xOf(T, g)), yc - 7, 1, 14)
847
+ }
848
+ }
849
+ // Keyframe ticks, where each keyframe is reached.
850
+ ctx.fillStyle = INK.white
851
+ for (const k of focusTicks(m)) {
852
+ const x = xOf(k.t, g)
853
+ ctx.beginPath()
854
+ ctx.moveTo(x, yc - 4)
855
+ ctx.lineTo(x + 4, yc)
856
+ ctx.lineTo(x, yc + 4)
857
+ ctx.lineTo(x - 4, yc)
858
+ ctx.closePath()
859
+ ctx.fill()
860
+ out.ticks.push({ offset: k.offset, local: Math.round(k.local), x: r1(x) })
861
+ }
862
+ // Sparklines: what moves, in coordinates.
863
+ ensureSpark()
864
+ const sp = D.focus.spark
865
+ if (sp) {
866
+ sp.props.forEach((pr, i) => {
867
+ const top = yR + FOCUS_CAPSULE_H + 2 + i * SPARK_H
868
+ if (top + SPARK_H > R.y0 + R.h + 1) return
869
+ const lo = Math.min(...pr.values)
870
+ const hi = Math.max(...pr.values)
871
+ ctx.strokeStyle = INK.dim
872
+ ctx.lineWidth = 1
873
+ ctx.beginPath()
874
+ pr.values.forEach((v, j) => {
875
+ const x = xOf(sp.Ts[j], g)
876
+ const y = top + SPARK_H - 2 - ((v - lo) / Math.max(hi - lo, 1e-6)) * (SPARK_H - 3)
877
+ j ? ctx.lineTo(x, y) : ctx.moveTo(x, y)
878
+ })
879
+ ctx.stroke()
880
+ ctx.fillStyle = INK.faint
881
+ ctx.font = '500 8px "Geist Mono", ui-monospace, Menlo, monospace'
882
+ ctx.textBaseline = "middle"
883
+ ctx.fillText(pr.prop === "tx" ? "x" : pr.prop === "ty" ? "y" : pr.prop, Math.max(2, x1 + 4), top + SPARK_H / 2)
884
+ })
885
+ out.sparks = sp.props.map((p) => p.prop)
886
+ }
887
+ // The range, as a band with its handles and label.
888
+ if (f.range) {
889
+ const a = xOf(f.range.from, g)
890
+ const b = Math.max(a + 1, xOf(f.range.to, g))
891
+ ctx.fillStyle = hexAlpha(colorOf(activeBranch()), 0.18)
892
+ ctx.fillRect(a, R.y0 + 1, b - a, R.h - 1)
893
+ ctx.fillStyle = colorOf(activeBranch())
894
+ ctx.fillRect(a - 1, R.y0 + 1, 2, R.h - 1)
895
+ ctx.fillRect(b - 1, R.y0 + 1, 2, R.h - 1)
896
+ const label = rangeLabel(f.range, m)
897
+ ctx.font = '500 9px "Geist Mono", ui-monospace, Menlo, monospace'
898
+ ctx.textBaseline = "middle"
899
+ const lw = ctx.measureText(label).width
900
+ const lx = clamp(a + 4, 2, g.w - lw - 4)
901
+ ctx.fillStyle = INK.page
902
+ roundRect(lx - 3, R.y0 + R.h - 13, lw + 6, 11, 3)
903
+ ctx.fill()
904
+ ctx.fillStyle = INK.white
905
+ ctx.fillText(label, lx, R.y0 + R.h - 7.5)
906
+ out.band = { x0: a, x1: b, label }
907
+ }
908
+ if (f.pin != null && !f.range) {
909
+ const x = Math.round(xOf(f.pin, g)) + 0.5
910
+ ctx.fillStyle = NOTE_FILL.pending
911
+ ctx.fillRect(x - 0.5, R.y0 + 1, 1, R.h - 1)
912
+ out.pin = x
913
+ }
914
+ ctx.restore()
915
+ return out
916
+ }
917
+ function hexAlpha(hex, a) {
918
+ const n = parseInt(hex.slice(1), 16)
919
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
920
+ }
921
+ // "200–400ms · 10–20% of ticker"
922
+ function rangeLabel(r, m) {
923
+ const a = NT.pointOf(m, r.from)
924
+ const b = NT.pointOf(m, r.to)
925
+ const pc = (p) => Math.round(p.progress * 100)
926
+ return `${Math.round(a.local)}–${Math.round(b.local)}ms · ${pc(a)}–${pc(b)}% of ${m.name}`
927
+ }
928
+
929
+ // What's under a point of the element row (hitAt asks first).
930
+ function focusHit(x, y) {
931
+ const sc = D.scene && D.scene.focus
932
+ if (!sc || y < sc.y0 || y > sc.y0 + sc.h) return null
933
+ if (!sc.open) {
934
+ const hits = sc.capsules.filter((c) => x >= c.x0 - 2 && x <= c.x1 + 2)
935
+ if (hits.length) {
936
+ const c = hits.reduce((a, b) => (Math.abs(b.y - y) < Math.abs(a.y - y) ? b : a))
937
+ return { kind: "focus-clip", clip: c.clip }
938
+ }
939
+ for (const i of sc.insides) if (x >= i.x0 - 2 && x <= i.x1 + 2) return { kind: "focus-inside", clip: i.clip }
940
+ return { kind: "focus-none" }
941
+ }
942
+ if (sc.band) {
943
+ if (Math.abs(x - sc.band.x0) <= 4) return { kind: "range-edge", side: "from" }
944
+ if (Math.abs(x - sc.band.x1) <= 4) return { kind: "range-edge", side: "to" }
945
+ }
946
+ return { kind: "focus-row", t: timeAt(x + geom().left) }
947
+ }
948
+
949
+ // ---- pointer and keys on the element row -------------------------------------------------------
950
+
951
+ // Returns true when the press was the element row's.
952
+ function focusPointerDown(e, hit) {
953
+ const f = D.focus
954
+ if (!f || !hit) return false
955
+ D.focusHold = true
956
+ if (hit.kind === "focus-none") return true
957
+ if (hit.kind === "focus-clip") {
958
+ openFocusClip(hit.clip)
959
+ return true
960
+ }
961
+ if (hit.kind === "focus-inside") {
962
+ let el = null
963
+ try {
964
+ el = D.frame.contentDocument.querySelector(String(hit.clip.selector || "").replace(/::?[\w-]+$/, ""))
965
+ } catch {}
966
+ if (el && draft) {
967
+ const t = draft.t
968
+ draft.el = Object.assign(describe(el), { at: { dx: 0.5, dy: 0.5 } })
969
+ focusOn(el, null, t)
970
+ refreshComposer()
971
+ }
972
+ return true
973
+ }
974
+ const m = openModel()
975
+ if (!m) return false
976
+ track.setPointerCapture(e.pointerId)
977
+ const s = D.last
978
+ if (s && s.playing) D.PT.pause()
979
+ if (hit.kind === "range-edge") {
980
+ D.focusDrag = { x0: e.clientX, moved: true, edge: hit.side, anchor: hit.side === "from" ? f.range.to : f.range.from }
981
+ return true
982
+ }
983
+ const t0 = snapFocus(hit.t, m, e.altKey)
984
+ D.focusDrag = { x0: e.clientX, moved: false, edge: null, anchor: t0 }
985
+ return true
986
+ }
987
+ function focusPointerMove(e) {
988
+ const d = D.focusDrag
989
+ const f = D.focus
990
+ if (!d || !f || !f.open) return false
991
+ if (!d.moved && Math.abs(e.clientX - d.x0) < DEAD_PX) return true
992
+ d.moved = true
993
+ const m = openModel()
994
+ const t = snapFocus(timeAt(e.clientX), m, e.altKey)
995
+ const a = d.anchor
996
+ f.range = { from: Math.min(a, t), to: Math.max(a, t) }
997
+ f.pin = null
998
+ scrubSoon(f.range.from)
999
+ refreshComposer()
1000
+ return true
1001
+ }
1002
+ function focusPointerUp() {
1003
+ const d = D.focusDrag
1004
+ const f = D.focus
1005
+ if (!d) return false
1006
+ D.focusDrag = null
1007
+ if (!f || !f.open) return true
1008
+ if (!d.moved) {
1009
+ f.range = null
1010
+ f.pin = d.anchor
1011
+ goTo(d.anchor)
1012
+ } else if (f.range) {
1013
+ if (f.range.to - f.range.from < 1) f.range = null
1014
+ else goTo(f.range.from)
1015
+ }
1016
+ f.touched = true
1017
+ refreshComposer()
1018
+ return true
1019
+ }
1020
+
1021
+ // ← → on an open clip: a frame of its local time; with Shift, keyframe to keyframe.
1022
+ function focusStep(dir, toTick) {
1023
+ const f = D.focus
1024
+ if (!f || !f.open || !D.last) return false
1025
+ const m = openModel()
1026
+ const s = D.last
1027
+ if (s.playing) D.PT.pause()
1028
+ const cur = f.pin != null ? f.pin : shownTime(s)
1029
+ let t
1030
+ if (toTick) {
1031
+ const ts = focusTicks(m).map((k) => k.t)
1032
+ t = dir > 0 ? ts.find((x) => x > cur + 0.5) : ts.reverse().find((x) => x < cur - 0.5)
1033
+ if (t == null) return true
1034
+ } else t = cur + (dir * FRAME_MS) / (Number(m.timing.playbackRate) || 1)
1035
+ const [lo, hi] = bounds(s)
1036
+ t = clamp(t, lo, hi)
1037
+ f.pin = t
1038
+ f.range = null
1039
+ D.keyT = t
1040
+ scrubTo(t)
1041
+ keepInView(t)
1042
+ clearTimeout(keyTimer)
1043
+ keyTimer = setTimeout(() => {
1044
+ D.keyT = null
1045
+ goTo(t)
1046
+ }, 350)
1047
+ refreshComposer()
1048
+ return true
1049
+ }
1050
+
1051
+ // Shift+drag on the track: a range of recording time, before an element is picked.
1052
+ function rangePointerDown(e, s) {
1053
+ track.setPointerCapture(e.pointerId)
1054
+ if (s.playing) D.PT.pause()
1055
+ const [lo, hi] = bounds(s)
1056
+ const t = clamp(snapped(timeAt(e.clientX), s, e.altKey), lo, hi)
1057
+ D.snapT = null
1058
+ D.rangeDrag = { anchor: t, x0: e.clientX }
1059
+ D.range = null
1060
+ }
1061
+ function rangePointerMove(e) {
1062
+ const d = D.rangeDrag
1063
+ const s = D.last
1064
+ if (!d || !s) return false
1065
+ if (Math.abs(e.clientX - d.x0) < DEAD_PX && !D.range) return true
1066
+ const [lo, hi] = bounds(s)
1067
+ const t = clamp(snapped(timeAt(e.clientX), s, e.altKey), lo, hi)
1068
+ D.snapT = null
1069
+ D.range = { from: Math.min(d.anchor, t), to: Math.max(d.anchor, t) }
1070
+ return true
1071
+ }
1072
+ function rangePointerUp() {
1073
+ const d = D.rangeDrag
1074
+ if (!d) return false
1075
+ D.rangeDrag = null
1076
+ if (D.range && D.range.to - D.range.from >= 1) {
1077
+ goTo(D.range.from)
1078
+ flash("Range selected: ⌘-click an element to comment on it", { warn: false })
1079
+ } else D.range = null
1080
+ return true
1081
+ }
1082
+ function drawRecordingRange(g) {
1083
+ const r = D.range
1084
+ if (!r) return null
1085
+ const a = xOf(r.from, g)
1086
+ const b = Math.max(a + 1, xOf(r.to, g))
1087
+ ctx.save()
1088
+ ctx.fillStyle = hexAlpha(colorOf(activeBranch()), 0.12)
1089
+ ctx.fillRect(a, RULER, b - a, g.h - RULER)
1090
+ ctx.fillStyle = colorOf(activeBranch())
1091
+ ctx.fillRect(a - 0.5, RULER, 1, g.h - RULER)
1092
+ ctx.fillRect(b - 0.5, RULER, 1, g.h - RULER)
1093
+ ctx.restore()
1094
+ return { x0: a, x1: b }
1095
+ }
1096
+
1097
+ // ---- the readout: where the playhead is on the open clip's own clock ---------------------------
1098
+
1099
+ let readoutMemo = { key: "", text: "" }
1100
+ function focusReadout(s, shownT) {
1101
+ const f = D.focus
1102
+ if (f && !f.open && f.hoverClip && !s.playing) return `${clipName(f.hoverClip)}${f.hoverClip.kind === "transition" ? " transition" : ""} · ${msWord(clipEndOf(f.hoverClip) - f.hoverClip.start)} · click to open it on its own clock`
1103
+ if (!f || !f.open || s.playing) return ""
1104
+ const m = openModel()
1105
+ const T = f.hoverT != null ? f.hoverT : f.pin != null ? f.pin : shownT
1106
+ const end = clipEndOf(f.open.clip)
1107
+ if (T < m.timing.start - 0.5 || T > end + 0.5) return ""
1108
+ const key = `${f.open.clip.id}:${T.toFixed(1)}:${m.approx}:${f.hoverT != null}`
1109
+ if (readoutMemo.key === key) return readoutMemo.text
1110
+ const p = NT.pointOf(m, T)
1111
+ const dur = Math.round(Number(m.timing.duration) || 0)
1112
+ const bits = [m.name]
1113
+ if (m.kind === "scroll-driven") bits.push("scroll-driven", `${Math.round(p.progress * 100)}%`)
1114
+ else if (p.phase === "delay") bits.push(`in delay, ${Math.round(-p.local)}ms before 0`)
1115
+ else {
1116
+ if (p.iteration > 0) bits.push(`iteration ${p.iteration + 1}`)
1117
+ const iterLocal = Math.round(p.local - p.iteration * dur)
1118
+ bits.push(`${iterLocal}ms of ${dur}`, `${Math.round(p.progress * 100)}%`)
1119
+ if (p.segment) bits.push(`seg ${Math.round(p.segment.fromOffset * 100)}→${Math.round(p.segment.toOffset * 100)}% ${p.segment.easing}`)
1120
+ }
1121
+ // The box, read off the moment on show (the playhead's), not a sample.
1122
+ if (f.hoverT == null && Math.abs(T - shownT) < 1 && f.el && f.el.isConnected) {
1123
+ try {
1124
+ const g = geometryOf(f.el)
1125
+ const first = f.spark && f.spark.samples[0] && f.spark.samples[0].geometry
1126
+ const dx = first ? Math.round(g.page.x - first.page.x) : 0
1127
+ const dy = first ? Math.round(g.page.y - first.page.y) : 0
1128
+ bits.push(`x ${Math.round(g.page.x)} y ${Math.round(g.page.y)}${first && (dx || dy) ? ` (Δ${dx >= 0 ? "+" : ""}${dx}, ${dy >= 0 ? "+" : "−"}${Math.abs(dy)})` : ""}`, `${Math.round(g.page.w)}×${Math.round(g.page.h)}`)
1129
+ if (g.opacity !== 1) bits.push(`opacity ${+g.opacity.toFixed(2)}`)
1130
+ } catch {}
1131
+ }
1132
+ readoutMemo = { key, text: bits.join(" · ") }
1133
+ return readoutMemo.text
1134
+ }
1135
+
1136
+ // ---- the path on the app: where the open clip moves the element --------------------------------
1137
+
1138
+ const pathSvg = $("#wb-path")
1139
+ let pathKey = ""
1140
+ function renderFocusOverlay(s) {
1141
+ const f = syncFocusEl()
1142
+ if (f && f.open && !f.spark) ensureSpark()
1143
+ const sp = f && f.open && f.spark
1144
+ const show = !!(sp && sp.samples.length > 1 && !s.playing && !s.seeking && D.frame)
1145
+ if (!show) {
1146
+ if (!pathSvg.hasAttribute("hidden")) pathSvg.setAttribute("hidden", "")
1147
+ pathKey = ""
1148
+ return
1149
+ }
1150
+ const fr = D.frame.getBoundingClientRect()
1151
+ const key = `${f.open.clip.id}:${sp.n}:${fr.left}:${fr.top}:${f.range ? f.range.from + "-" + f.range.to : ""}`
1152
+ // (An <svg> has no `hidden` property: the attribute is set and removed.)
1153
+ if (key === pathKey && !pathSvg.hasAttribute("hidden")) return
1154
+ pathKey = key
1155
+ const pts = sp.samples.map((x) => ({ T: x.T, x: fr.left + x.geometry.viewport.x + x.geometry.viewport.w / 2, y: fr.top + x.geometry.viewport.y + x.geometry.viewport.h / 2, g: x.geometry }))
1156
+ const box = (g, cls, label) => {
1157
+ const v = g.viewport
1158
+ return `<rect class="${cls}" x="${fr.left + v.x}" y="${fr.top + v.y}" width="${Math.max(v.w, 1)}" height="${Math.max(v.h, 1)}"/>${label ? `<text x="${fr.left + v.x}" y="${fr.top + v.y - 4}">${esc(label)}</text>` : ""}`
1159
+ }
1160
+ const m = f.open.model
1161
+ const ticks = focusTicks(m)
1162
+ .map((k) => pts.reduce((best, p) => (!best || Math.abs(p.T - k.t) < Math.abs(best.T - k.t) ? p : best), null))
1163
+ .filter(Boolean)
1164
+ let html = box(pts[0].g, "edge", "") + box(pts[pts.length - 1].g, "edge", "")
1165
+ html += `<polyline class="path" points="${pts.map((p) => `${r1(p.x)},${r1(p.y)}`).join(" ")}"/>`
1166
+ html += ticks.map((p) => `<circle class="tick" cx="${r1(p.x)}" cy="${r1(p.y)}" r="3"/>`).join("")
1167
+ if (f.range) {
1168
+ const inR = pts.filter((p) => p.T >= f.range.from - 1 && p.T <= f.range.to + 1)
1169
+ if (inR.length > 1) html += `<polyline class="path on" points="${inR.map((p) => `${r1(p.x)},${r1(p.y)}`).join(" ")}"/>`
1170
+ const edges = f.edges && f.edges.key === `${f.range.from}-${f.range.to}` ? f.edges : (f.edges = { key: `${f.range.from}-${f.range.to}`, list: sampleModel(m, f.el, f.open.target, f.open.clip.pseudoElement || null, [f.range.from, f.range.to]) })
1171
+ for (const e of edges.list) html += box(e.geometry, "edge on", `${Math.round(e.local)}ms`)
1172
+ }
1173
+ pathSvg.innerHTML = html
1174
+ pathSvg.dataset.points = String(pts.length)
1175
+ pathSvg.removeAttribute("hidden")
1176
+ }
1177
+
1178
+ // ---- the composer and the note ---------------------------------------------------------------------
1179
+
1180
+ // The composer's line about where the note is: "at 100ms (20%) of fadeUp on <h1.title>".
1181
+ function composerAt() {
1182
+ if (!draft || !D.focus) return ""
1183
+ const brief = notePayload(draft, { brief: true })
1184
+ return NT.atPhrase({ ...brief, el: draft.el }) || (D.focus.range ? `${fmt(D.focus.range.from - (D.last ? D.last.start : 0))} → ${fmt(D.focus.range.to - (D.last ? D.last.start : 0))} (recording) on ${D.focus.label}` : "")
1185
+ }
1186
+ function refreshComposer() {
1187
+ if (!draft) return
1188
+ // The moment in the header follows a point or range picked on the element's row.
1189
+ const time = card.querySelector(".note-meta .time")
1190
+ if (time && D.focus && (D.focus.pin != null || D.focus.range)) time.textContent = fmt((D.focus.range ? D.focus.range.from : D.focus.pin) - (D.last ? D.last.start : 0))
1191
+ const at = card.querySelector(".note-at")
1192
+ if (at) {
1193
+ const text = composerAt()
1194
+ at.textContent = text
1195
+ at.hidden = !text
1196
+ }
1197
+ updateAsked()
1198
+ }
1199
+ // Numbers typed in the note, read on the primary animation's own clock (a
1200
+ // chip says how; clicking it switches to recording time).
1201
+ function updateAsked() {
1202
+ const chip = card.querySelector(".note-asked")
1203
+ const ta = /** @type {HTMLTextAreaElement | null} */ (card.querySelector("textarea"))
1204
+ if (!chip || !ta || !draft) return
1205
+ const brief = notePayload(draft, { brief: true })
1206
+ const primary = NT.primaryOf(brief)
1207
+ const asked = NT.readAsked(ta.value, draft.askedReading === "recording" ? null : primary)
1208
+ draft.asked = asked
1209
+ if (!asked) {
1210
+ chip.hidden = true
1211
+ return
1212
+ }
1213
+ const dragged = D.focus && D.focus.range && D.focus.open
1214
+ const words = asked.reading === "local" && primary ? `${primary.name}'s own time${Number(primary.timing.delay) ? ` (after its ${Math.round(primary.timing.delay)}ms delay)` : ""}` : "recording time from the note's moment"
1215
+ chip.textContent = dragged ? `The dragged range is used; "${asked.text}" is kept as text` : `Reads "${asked.text}" as ${words}`
1216
+ chip.hidden = false
1217
+ }
1218
+
1219
+ /**
1220
+ * The note's animation fields (CONTRACT.md "Note"): t, range, target, anims,
1221
+ * inside, asked. `brief` skips the samples (for the composer's own lines).
1222
+ */
1223
+ function notePayload(d, { brief = false } = {}) {
1224
+ const f = syncFocusEl()
1225
+ const el = f && f.el
1226
+ const s = D.last
1227
+ let t = d.t
1228
+ if (f && f.range) t = f.range.from
1229
+ else if (f && f.pin != null) t = f.pin
1230
+ else if (f && f.touched && s) t = shownTime(s)
1231
+ const range = f && f.range ? { from: f.range.from, to: f.range.to } : null
1232
+ const out = { t, range, target: null, anims: [], inside: [], asked: d.asked || null }
1233
+ if (!el || !el.isConnected) return out
1234
+ const groups = f.groups
1235
+ const chosen = f.open ? f.entries.find((x) => x.clip === f.open.clip) || { clip: f.open.clip, target: f.open.target, relation: f.open.relation } : null
1236
+ const primary = chosen || primaryEntry(groups, t, range)
1237
+ const list = primary ? [primary] : []
1238
+ for (const x of [...groups.on, ...groups.ancestors]) {
1239
+ if (list.length >= MAX_ANIMS) break
1240
+ if (list.includes(x) || (primary && x.clip === primary.clip)) continue
1241
+ if (range ? overlapsRange(x.clip, range) : runningAt(x.clip, t)) list.push(x)
1242
+ }
1243
+ out.anims = list.map((x, i) => {
1244
+ const m = modelOf(x.clip, x.target)
1245
+ const pseudo = x.clip.pseudoElement || null
1246
+ const a = { ...m, relation: x.relation, primary: i === 0, selector: x.clip.selector || null }
1247
+ delete a.madeBy
1248
+ delete a.libChecked
1249
+ if (x.relation === "on") definedByElement(a, d.el)
1250
+ // One @keyframes on several elements: editing it changes them all.
1251
+ if (a.kind === "css-animation") {
1252
+ const seen = new Set([x.clip.selector])
1253
+ const shared = []
1254
+ for (const c of timeline().clips) {
1255
+ if (c.kind !== "css-animation" || c.label !== x.clip.label || seen.has(c.selector) || shared.length >= 8) continue
1256
+ seen.add(c.selector)
1257
+ // The element's own short selector, if it's on the page (the clip's is the full path).
1258
+ let sel = c.selector || null
1259
+ try {
1260
+ const other = D.frame.contentDocument.querySelector(String(c.selector || "").replace(/::?[\w-]+$/, ""))
1261
+ if (other) sel = selectorInfo(other).selector
1262
+ } catch {}
1263
+ shared.push({ id: c.id, selector: sel })
1264
+ }
1265
+ if (shared.length) a.shared = shared
1266
+ }
1267
+ if (brief) {
1268
+ if (range) {
1269
+ a.from = NT.pointOf(m, range.from)
1270
+ a.to = NT.pointOf(m, range.to)
1271
+ } else a.at = NT.pointOf(m, t)
1272
+ return a
1273
+ }
1274
+ if (range) {
1275
+ const lo = Math.max(range.from, m.timing.start)
1276
+ const hi = Math.min(range.to, clipEndOf(x.clip))
1277
+ a.from = fullPoint(m, el, x.target, pseudo, lo)
1278
+ a.to = fullPoint(m, el, x.target, pseudo, hi)
1279
+ a.openEnd = Math.abs(hi - clipEndOf(x.clip)) < 1
1280
+ a.keyframesInside = (m.keyframes || []).map((k) => k.offset).filter((o) => o > a.from.eased + 1e-4 && o < a.to.eased - 1e-4)
1281
+ const Ts = []
1282
+ for (let k = 0; k < RANGE_SAMPLES; k++) Ts.push(lo + ((hi - lo) * k) / (RANGE_SAMPLES - 1))
1283
+ a.samples = sampleModel(m, el, x.target, pseudo, Ts)
1284
+ } else a.at = fullPoint(m, el, x.target, pseudo, t)
1285
+ delete a.approx
1286
+ if (m.approx) a.approx = true
1287
+ return a
1288
+ })
1289
+ const insideSeen = new Set()
1290
+ for (const c of groups.inside) {
1291
+ const k = `${c.selector}:${c.label}`
1292
+ if (insideSeen.has(k) || out.inside.length >= 8) continue
1293
+ insideSeen.add(k)
1294
+ out.inside.push({ id: c.id, selector: c.selector || null, name: clipName(c) })
1295
+ }
1296
+ if (!brief) out.target = targetOf(el, d, out.anims)
1297
+ return out
1298
+ }
1299
+
1300
+ function targetOf(el, d, anims) {
1301
+ const doc = el.ownerDocument
1302
+ const desc = d.el || {}
1303
+ let matches = null
1304
+ let index = null
1305
+ try {
1306
+ const all = [...doc.querySelectorAll(desc.selector)]
1307
+ matches = all.length
1308
+ index = all.indexOf(el) + 1 || null
1309
+ } catch {}
1310
+ const onClip = anims.find((a) => a.relation === "on")
1311
+ const clip = onClip && timeline().clips.find((c) => c.id === onClip.id)
1312
+ const picked = d.picked || { via: "topmost", skipped: [] }
1313
+ return {
1314
+ path: clip && clip.path ? clip.path : null,
1315
+ selector: desc.selector,
1316
+ matches,
1317
+ ...(index && matches > 1 ? { index } : {}),
1318
+ hint: [desc.text ? `"${desc.text.slice(0, 40)}"` : null, desc.components && desc.components[0], desc.source && desc.source.file ? `${desc.source.file}${desc.source.line ? ":" + desc.source.line : ""}` : null].filter(Boolean).join(" · "),
1319
+ tag: desc.label,
1320
+ text: desc.text || "",
1321
+ role: el.getAttribute("role") || null,
1322
+ ariaLabel: el.getAttribute("aria-label") || null,
1323
+ picked,
1324
+ source: desc.source ? { ...desc.source, confidence: desc.source.confidence || (desc.source.compiled ? "unknown" : "exact") } : null,
1325
+ geometry: geometryOf(el),
1326
+ }
1327
+ }
1328
+
1329
+ // A saved note shown again: its element row, opened clip and range come back.
1330
+ function focusFromNote(n) {
1331
+ const a = n.anims && (n.anims.find((x) => x.primary) || n.anims[0])
1332
+ let el = null
1333
+ try {
1334
+ el = D.frame.contentDocument.querySelector(n.el.selector)
1335
+ } catch {}
1336
+ if (!el) return clearFocus()
1337
+ focusOn(el, n.el.pseudo ? { pseudo: n.el.pseudo.pseudoElement } : null, n.t)
1338
+ const f = D.focus
1339
+ if (!f) return
1340
+ if (a) {
1341
+ const x = f.entries.find((e) => e.clip.id === a.id)
1342
+ if (x) f.open = openEntry(x)
1343
+ }
1344
+ if (n.range) f.range = { ...n.range }
1345
+ else if (f.open) f.pin = n.t
1346
+ }
1347
+
1348
+ // A double-click on an open clip's row closes it.
1349
+ track.addEventListener("dblclick", (e) => {
1350
+ const g = geom()
1351
+ const hit = focusHit(e.clientX - g.left, e.clientY - g.top)
1352
+ if (hit && (hit.kind === "focus-row" || hit.kind === "range-edge")) closeFocusClip()
1353
+ })