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