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.
- package/CHANGELOG.md +76 -1
- package/README.md +141 -7
- package/bin/retake.js +14 -1
- package/package.json +6 -1
- package/src/core.js +14 -1
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +6 -2
- package/src/runtime/10-animations.js +9 -0
- package/src/runtime/60-preview.js +3 -1
- package/src/runtime/62-motion.js +106 -0
- package/src/runtime/65-timeline.js +39 -0
- package/src/runtime/67-csssource.js +73 -1
- package/src/runtime/70-boot.js +9 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +213 -16
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/10-dock.js +27 -5
- package/src/shell/15-session.js +11 -5
- package/src/shell/20-timeline.js +30 -5
- package/src/shell/25-input.js +25 -2
- package/src/shell/30-notes.js +704 -110
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +24 -0
- package/src/shell/shell.html +1 -0
- package/types/client.d.ts +7 -0
- package/types/index.d.ts +2 -0
- package/types/next.d.ts +50 -0
|
@@ -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
|
+
})
|