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,734 @@
|
|
|
1
|
+
// The timeline, drawn on one canvas. A ruler on top; under it one lane per
|
|
2
|
+
// timeline, each in its own colour. A timeline made by Control-clicking the
|
|
3
|
+
// track grows out of its parent's lane at the moment it split, along a short
|
|
4
|
+
// curve. The active lane is in full colour (dimmer over its recorded future);
|
|
5
|
+
// the others are the same hue, faint, and show only their line.
|
|
6
|
+
//
|
|
7
|
+
// What's drawn on the active lane, in three layers:
|
|
8
|
+
// 1. your actions: small blue marks (clicks, keys, submits, routes); a burst
|
|
9
|
+
// of typing is one short blue bar.
|
|
10
|
+
// 2. a quiet waveform of how much the screen changed (timeline().activity),
|
|
11
|
+
// just under the lane: steady ambient motion is nearly flat, real changes
|
|
12
|
+
// are bumps.
|
|
13
|
+
// 3. on demand, while ⌘ is held over an element in the app: that element's
|
|
14
|
+
// own animations, as bright capsules ("lens"); everything else dims.
|
|
15
|
+
//
|
|
16
|
+
// The view is a window of time [from, to], never starting before the
|
|
17
|
+
// recording. While playing, it follows: the playhead stays near the right
|
|
18
|
+
// edge and the track scrolls with time. Zooming or scrolling by hand stops
|
|
19
|
+
// that ("Live →" brings it back). Paused, nothing moves by itself.
|
|
20
|
+
// Drawing only happens when something visible changed.
|
|
21
|
+
|
|
22
|
+
const PAD_L = 12
|
|
23
|
+
const PAD_R = 18
|
|
24
|
+
const RULER = 20 // ruler height
|
|
25
|
+
const LANE = 32 // row height
|
|
26
|
+
const MIN_PITCH = 10 // lanes closer than this can't be told apart
|
|
27
|
+
const BEND = 20 // how wide a fork's S-curve is
|
|
28
|
+
const STUB = 24 // a new, empty timeline: a short line after its curve
|
|
29
|
+
const GROW_MS = 250
|
|
30
|
+
const FOLLOW_AT = 0.85 // where the playhead sits while following
|
|
31
|
+
const WAVE_H = 10 // waveform height under the active lane
|
|
32
|
+
|
|
33
|
+
// One colour per timeline, by id: distinct on dark glass, none of them the
|
|
34
|
+
// blue of actions or the amber of notes.
|
|
35
|
+
// (OKLCH L 0.78, C 0.12: the same perceived brightness on dark glass.)
|
|
36
|
+
const TL_COLORS = ["#bda8fc", "#3bcfcf", "#f197c2", "#85cc87", "#efa464", "#fb9797"]
|
|
37
|
+
const colorOf = (b) => TL_COLORS[((b ? b.id : 1) - 1) % TL_COLORS.length]
|
|
38
|
+
const INK = { blue: "#60a5fa", white: "#ffffff", dim: "rgba(255,255,255,0.55)", faint: "rgba(255,255,255,0.24)", hair: "rgba(255,255,255,0.08)", page: "#16161a", lens: "#fde68a" }
|
|
39
|
+
const NOTE_FILL = { pending: "#ffb224", acknowledged: "#60a5fa", resolved: "#4cc38a", dismissed: "rgba(255,255,255,0.4)" }
|
|
40
|
+
const MONO = '10px "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace'
|
|
41
|
+
|
|
42
|
+
const tip = $("#wb-tip")
|
|
43
|
+
const gutter = $(".gutter")
|
|
44
|
+
const liveBtn = $(".live-btn")
|
|
45
|
+
const readoutT = $(".readout .t")
|
|
46
|
+
// Minutes and seconds, then the hundredths dimmer.
|
|
47
|
+
readoutT.innerHTML = '<span class="ms"></span><span class="cs"></span>'
|
|
48
|
+
const [readoutMs, readoutCs] = readoutT.children
|
|
49
|
+
let readoutText = ""
|
|
50
|
+
const readoutPhase = $(".readout .phase")
|
|
51
|
+
const hintEl = $(".hint")
|
|
52
|
+
const playBtn = $('[data-a="play"]')
|
|
53
|
+
const ctx = cv.getContext("2d")
|
|
54
|
+
|
|
55
|
+
// ---- the view ---------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
// fit: show everything; followSpan: the window's width while following;
|
|
58
|
+
// detached: the user moved the view while it was following.
|
|
59
|
+
D.view = { from: 0, to: 1000, fit: true, target: null, at: 0, followSpan: 20000, detached: false, wasPlaying: false }
|
|
60
|
+
let sceneKey = ""
|
|
61
|
+
// The active row's band crossfades when the active timeline changes.
|
|
62
|
+
const BAND_MS = 150
|
|
63
|
+
const band = { id: null, prev: null, since: -1e9 }
|
|
64
|
+
let pendingFork = null
|
|
65
|
+
let flashUntil = 0
|
|
66
|
+
|
|
67
|
+
// Everything recorded, on every timeline, from its first moment.
|
|
68
|
+
function fullRange(s) {
|
|
69
|
+
let end = s.end
|
|
70
|
+
for (const b of D.branches) end = Math.max(end, b.id === D.activeId ? Math.max(b.end, s.end) : b.end)
|
|
71
|
+
const len = Math.max(end - s.start, 1000)
|
|
72
|
+
return { from: s.start, to: s.start + len * 1.03 }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// While following: the playhead at FOLLOW_AT of the width, or, early on, the
|
|
76
|
+
// recording from its start.
|
|
77
|
+
function followRange(s, now) {
|
|
78
|
+
// Early on the window only grows to fit (the playhead at FOLLOW_AT of it);
|
|
79
|
+
// once it reaches the chosen width, the track scrolls.
|
|
80
|
+
const span = clamp((now - s.start) / FOLLOW_AT, 2000, D.view.followSpan)
|
|
81
|
+
let to = now + span * (1 - FOLLOW_AT)
|
|
82
|
+
let from = to - span
|
|
83
|
+
if (from < s.start) {
|
|
84
|
+
from = s.start
|
|
85
|
+
to = from + span
|
|
86
|
+
}
|
|
87
|
+
return { from, to }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The start of the recording is pinned: the view never begins before it.
|
|
91
|
+
function pinStart(v, s) {
|
|
92
|
+
if (!s || v.from >= s.start) return v
|
|
93
|
+
return { from: s.start, to: s.start + (v.to - v.from) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The user set the view (zoom, pan, fit a clip): stop following.
|
|
97
|
+
function setView(from, to, { animate = true } = {}) {
|
|
98
|
+
const span = Math.max(to - from, 20)
|
|
99
|
+
const mid = (from + to) / 2
|
|
100
|
+
const r = pinStart({ from: mid - span / 2, to: mid + span / 2 }, D.last)
|
|
101
|
+
D.view.fit = false
|
|
102
|
+
D.view.followSpan = r.to - r.from
|
|
103
|
+
if (D.last && D.last.playing) D.view.detached = true
|
|
104
|
+
if (animate) D.view.target = r
|
|
105
|
+
else {
|
|
106
|
+
D.view.from = r.from
|
|
107
|
+
D.view.to = r.to
|
|
108
|
+
D.view.target = null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function fitAll() {
|
|
112
|
+
D.view.fit = true
|
|
113
|
+
D.view.detached = false
|
|
114
|
+
if (D.last) D.view.followSpan = Math.max(4000, fullRange(D.last).to - D.last.start)
|
|
115
|
+
}
|
|
116
|
+
function fitRange(a, b) {
|
|
117
|
+
const pad = Math.max((b - a) * 0.15, 10)
|
|
118
|
+
setView(a - pad, b + pad)
|
|
119
|
+
}
|
|
120
|
+
function followLive() {
|
|
121
|
+
D.view.detached = false
|
|
122
|
+
D.view.fit = false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Where the view is going this frame, then eased toward it in ~200ms (a
|
|
126
|
+
// followed view moves with time exactly, so it stays smooth).
|
|
127
|
+
function stepView(s) {
|
|
128
|
+
const now = performance.now()
|
|
129
|
+
const dt = Math.min(64, now - (D.view.at || now))
|
|
130
|
+
D.view.at = now
|
|
131
|
+
const v = D.view
|
|
132
|
+
// Started playing: follow again. Paused: stay exactly where it is.
|
|
133
|
+
if (s.playing && !v.wasPlaying) v.detached = false
|
|
134
|
+
if (!s.playing && v.wasPlaying) {
|
|
135
|
+
v.fit = false
|
|
136
|
+
v.target = null
|
|
137
|
+
}
|
|
138
|
+
v.wasPlaying = !!s.playing
|
|
139
|
+
const following = s.playing && !v.detached && D.dragT == null
|
|
140
|
+
if (following) v.target = followRange(s, s.now)
|
|
141
|
+
else if (v.fit) v.target = fullRange(s)
|
|
142
|
+
const t = v.target
|
|
143
|
+
if (!t) {
|
|
144
|
+
const p = pinStart(v, s)
|
|
145
|
+
v.from = p.from
|
|
146
|
+
v.to = p.to
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const span = v.to - v.from
|
|
150
|
+
const k = following && Math.abs(t.to - t.from - span) < 1 ? 1 : 1 - Math.exp(-dt / 45)
|
|
151
|
+
v.from += (t.from - v.from) * k
|
|
152
|
+
v.to += (t.to - v.to) * k
|
|
153
|
+
if (Math.abs(t.from - v.from) < span / 4000 && Math.abs(t.to - v.to) < span / 4000) {
|
|
154
|
+
v.from = t.from
|
|
155
|
+
v.to = t.to
|
|
156
|
+
if (!v.fit && !following) v.target = null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const geom = () => {
|
|
161
|
+
const r = cv.getBoundingClientRect()
|
|
162
|
+
return { w: r.width, h: r.height, left: r.left, top: r.top }
|
|
163
|
+
}
|
|
164
|
+
const innerW = (g) => Math.max(1, g.w - PAD_L - PAD_R)
|
|
165
|
+
const pxPerMs = (g) => innerW(g) / (D.view.to - D.view.from)
|
|
166
|
+
const xOf = (t, g) => clamp(PAD_L + (t - D.view.from) * pxPerMs(g), -4000, g.w + 4000)
|
|
167
|
+
const timeAt = (clientX, g = geom()) => D.view.from + (clientX - g.left - PAD_L) / pxPerMs(g)
|
|
168
|
+
const r1 = (n) => Math.round(n * 10) / 10
|
|
169
|
+
|
|
170
|
+
// ---- lanes ------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
// Where each lane sits: rows from right under the ruler, any spare height
|
|
173
|
+
// below them. When they don't fit at full height, they close up evenly
|
|
174
|
+
// ("compact": names become dots).
|
|
175
|
+
// Rows in tree order, like a git graph: each timeline directly under its
|
|
176
|
+
// parent's row group. Siblings that split off later sit nearer the parent, so
|
|
177
|
+
// at the moment an earlier sibling splits, every row it drops past is still
|
|
178
|
+
// empty (those timelines begin later): no fork ever crosses a lane.
|
|
179
|
+
function treeOrder() {
|
|
180
|
+
const kids = new Map()
|
|
181
|
+
for (const b of D.branches) {
|
|
182
|
+
const k = kids.get(b.parentId) || []
|
|
183
|
+
k.push(b)
|
|
184
|
+
kids.set(b.parentId, k)
|
|
185
|
+
}
|
|
186
|
+
const out = []
|
|
187
|
+
const visit = (b) => {
|
|
188
|
+
out.push(b)
|
|
189
|
+
const k = (kids.get(b.id) || []).slice().sort((x, y) => y.forkAt - x.forkAt || x.id - y.id)
|
|
190
|
+
k.forEach(visit)
|
|
191
|
+
}
|
|
192
|
+
const roots = D.branches.filter((b) => !b.parentId || !branchById(b.parentId))
|
|
193
|
+
roots.forEach(visit)
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function layoutLanes(g) {
|
|
198
|
+
const avail = g.h - RULER - 4
|
|
199
|
+
const order = treeOrder()
|
|
200
|
+
const n = order.length
|
|
201
|
+
const compact = n * LANE > avail
|
|
202
|
+
const pitch = compact ? Math.max(MIN_PITCH, Math.floor(avail / n)) : LANE
|
|
203
|
+
D.compact = compact
|
|
204
|
+
D.rowPitch = pitch
|
|
205
|
+
const lanes = new Map()
|
|
206
|
+
let y = RULER + 4
|
|
207
|
+
order.forEach((b, row) => {
|
|
208
|
+
lanes.set(b.id, { y: Math.round(y + pitch / 2) + 0.5, thin: compact, row })
|
|
209
|
+
y += pitch
|
|
210
|
+
})
|
|
211
|
+
D.rowOrder = order.map((b) => b.id)
|
|
212
|
+
return lanes
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const growth = (b) => {
|
|
216
|
+
const p = clamp((performance.now() - b.born) / GROW_MS, 0, 1)
|
|
217
|
+
return 1 - Math.pow(1 - p, 3)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A lane: straight from its start, or out of its parent's lane at the exact
|
|
221
|
+
// moment it split, along a short curve, then along its own row.
|
|
222
|
+
// A lane: straight from its start; or, for a timeline that split off, down
|
|
223
|
+
// from its parent's row at the moment it split (a straight drop past rows
|
|
224
|
+
// that are empty there) and one smooth S-curve into its own row. A timeline
|
|
225
|
+
// with nothing recorded yet ends in a short stub.
|
|
226
|
+
function laneShape(b, g, lanes, s, until) {
|
|
227
|
+
const L = lanes.get(b.id)
|
|
228
|
+
const parent = branchById(b.parentId)
|
|
229
|
+
const empty = until - (b.parentId ? b.forkAt : s.start) < 1
|
|
230
|
+
if (!parent || !lanes.get(parent.id)) {
|
|
231
|
+
const x0 = xOf(b.parentId ? b.forkAt : s.start, g)
|
|
232
|
+
const x1 = empty ? x0 + STUB : Math.max(xOf(until, g), x0 + 0.5)
|
|
233
|
+
return { x0, py: L.y, dy: L.y, xb: x0, y: L.y, x1, empty }
|
|
234
|
+
}
|
|
235
|
+
const x0 = xOf(b.forkAt, g)
|
|
236
|
+
const py = lanes.get(parent.id).y
|
|
237
|
+
const dy = L.y - D.rowPitch // the row just above: the curve drops one step
|
|
238
|
+
const xb = x0 + BEND
|
|
239
|
+
const x1 = empty ? xb + STUB : Math.max(xOf(until, g), xb)
|
|
240
|
+
return { x0, py, dy: Math.max(py, dy), xb, y: L.y, x1, empty }
|
|
241
|
+
}
|
|
242
|
+
function tracePath(p) {
|
|
243
|
+
ctx.beginPath()
|
|
244
|
+
ctx.moveTo(p.x0, p.py)
|
|
245
|
+
if (p.xb !== p.x0) {
|
|
246
|
+
if (p.dy > p.py) ctx.lineTo(p.x0, p.dy)
|
|
247
|
+
ctx.bezierCurveTo(p.x0 + BEND * 0.5, p.dy, p.xb - BEND * 0.5, p.y, p.xb, p.y)
|
|
248
|
+
}
|
|
249
|
+
ctx.lineTo(p.x1, p.y)
|
|
250
|
+
}
|
|
251
|
+
// The end of a lane: a small dot where its recording ends; a hollow circle
|
|
252
|
+
// on a new, empty one ("ready").
|
|
253
|
+
function laneEnd(p, color, alpha) {
|
|
254
|
+
ctx.save()
|
|
255
|
+
ctx.globalAlpha = alpha
|
|
256
|
+
ctx.beginPath()
|
|
257
|
+
ctx.arc(p.x1, p.y, p.empty ? 3.5 : 2.5, 0, Math.PI * 2)
|
|
258
|
+
if (p.empty) {
|
|
259
|
+
ctx.fillStyle = INK.page
|
|
260
|
+
ctx.fill()
|
|
261
|
+
ctx.strokeStyle = color
|
|
262
|
+
ctx.lineWidth = 1.5
|
|
263
|
+
ctx.stroke()
|
|
264
|
+
} else {
|
|
265
|
+
ctx.fillStyle = color
|
|
266
|
+
ctx.fill()
|
|
267
|
+
}
|
|
268
|
+
ctx.restore()
|
|
269
|
+
}
|
|
270
|
+
// Drawn partway while it grows (the first 250ms of a new timeline).
|
|
271
|
+
function strokeLane(p, color, alpha, width, grow = 1) {
|
|
272
|
+
ctx.save()
|
|
273
|
+
ctx.globalAlpha = alpha
|
|
274
|
+
ctx.strokeStyle = color
|
|
275
|
+
ctx.lineWidth = width
|
|
276
|
+
ctx.lineCap = "round"
|
|
277
|
+
if (grow < 0.999) {
|
|
278
|
+
const len = Math.abs(p.dy - p.py) + BEND + Math.abs(p.y - p.dy) + (p.x1 - p.xb)
|
|
279
|
+
ctx.setLineDash([len * grow, len * 2])
|
|
280
|
+
}
|
|
281
|
+
tracePath(p)
|
|
282
|
+
ctx.stroke()
|
|
283
|
+
ctx.restore()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- the ruler --------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
const STEPS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000]
|
|
289
|
+
|
|
290
|
+
function drawRuler(g, s, xNow) {
|
|
291
|
+
const ppm = pxPerMs(g)
|
|
292
|
+
const major = STEPS.find((st) => st * ppm >= 72) || STEPS[STEPS.length - 1]
|
|
293
|
+
const minor = STEPS.slice().reverse().find((st) => st < major && major % st === 0 && st * ppm >= 7) || major
|
|
294
|
+
const dec = major >= 1000 ? 0 : major >= 100 ? 1 : major >= 10 ? 2 : 3
|
|
295
|
+
const label = (t) => {
|
|
296
|
+
const ms = t - s.start
|
|
297
|
+
if (major >= 60000) return `${Math.floor(ms / 60000)}:${String(Math.round((ms % 60000) / 1000)).padStart(2, "0")}`
|
|
298
|
+
return (ms / 1000).toFixed(dec) + "s"
|
|
299
|
+
}
|
|
300
|
+
ctx.fillStyle = INK.hair
|
|
301
|
+
ctx.fillRect(0, RULER - 1, g.w, 1)
|
|
302
|
+
ctx.font = MONO
|
|
303
|
+
ctx.textBaseline = "alphabetic"
|
|
304
|
+
const labels = []
|
|
305
|
+
const first = Math.ceil((D.view.from - s.start) / minor) * minor + s.start
|
|
306
|
+
for (let t = first, n = 0; t <= D.view.to && n < 800; t += minor, n++) {
|
|
307
|
+
const rel = Math.round(t - s.start)
|
|
308
|
+
if (rel < 0) continue
|
|
309
|
+
const x = Math.round(xOf(t, g)) + 0.5
|
|
310
|
+
const isMajor = rel % major === 0
|
|
311
|
+
ctx.fillStyle = isMajor ? INK.dim : INK.faint
|
|
312
|
+
ctx.fillRect(x - 0.5, RULER - (isMajor ? 6 : 3), 1, isMajor ? 5 : 2)
|
|
313
|
+
// A label the playhead's cap would sit on is left out.
|
|
314
|
+
if (isMajor && !(x + 3 < xNow + 8 && x + 36 > xNow - 8)) {
|
|
315
|
+
ctx.fillStyle = INK.dim
|
|
316
|
+
ctx.fillText(label(t), x + 3, RULER - 8)
|
|
317
|
+
labels.push(label(t))
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return labels
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- layers on the active lane ---------------------------------------------------
|
|
324
|
+
|
|
325
|
+
// Things you did: blue. The app's own doings (fetches, reloads) aren't shown.
|
|
326
|
+
const ACTION_KINDS = new Set(["click", "key", "input", "submit", "route"])
|
|
327
|
+
|
|
328
|
+
// First marker at or after t (markers are in time order).
|
|
329
|
+
function firstFrom(list, t) {
|
|
330
|
+
let lo = 0
|
|
331
|
+
let hi = list.length
|
|
332
|
+
while (lo < hi) {
|
|
333
|
+
const mid = (lo + hi) >> 1
|
|
334
|
+
if (list[mid].t < t) lo = mid + 1
|
|
335
|
+
else hi = mid
|
|
336
|
+
}
|
|
337
|
+
return lo
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function drawActions(g, markers, y, lo, hi, dim) {
|
|
341
|
+
const out = []
|
|
342
|
+
const from = D.view.from - 2000 // a typing bar can start before the view
|
|
343
|
+
ctx.save()
|
|
344
|
+
ctx.globalAlpha = dim ? 0.35 : 1
|
|
345
|
+
ctx.fillStyle = INK.blue
|
|
346
|
+
for (let i = firstFrom(markers, from); i < markers.length; i++) {
|
|
347
|
+
const m = markers[i]
|
|
348
|
+
if (m.t > D.view.to) break
|
|
349
|
+
if (!ACTION_KINDS.has(m.kind) || m.t < lo - 1 || m.t > hi + 1) continue
|
|
350
|
+
const x = xOf(m.t, g)
|
|
351
|
+
if (m.kind === "input" && m.end != null && m.end > m.t) {
|
|
352
|
+
// A burst of typing: one short bar.
|
|
353
|
+
const x1 = Math.max(x + 3, xOf(m.end, g))
|
|
354
|
+
if (x1 < 0 || x > g.w) continue
|
|
355
|
+
roundRect(x, y - 1.5, x1 - x, 3, 1.5)
|
|
356
|
+
ctx.fill()
|
|
357
|
+
out.push({ i, kind: "typing", x, x1 })
|
|
358
|
+
} else {
|
|
359
|
+
if (x < -4 || x > g.w + 4) continue
|
|
360
|
+
ctx.beginPath()
|
|
361
|
+
ctx.arc(x, y, 2.5, 0, Math.PI * 2)
|
|
362
|
+
ctx.fill()
|
|
363
|
+
out.push({ i, kind: m.kind, x })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
ctx.restore()
|
|
367
|
+
return out
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// timeline().activity: { step, values[, t0] } (0..1 per step of `step` ms
|
|
371
|
+
// from t0, or from the recording's start). Anything else: nothing drawn.
|
|
372
|
+
function activityOf(tl, s) {
|
|
373
|
+
const a = tl.activity
|
|
374
|
+
if (!a || !Array.isArray(a.values) || !(a.step > 0)) return null
|
|
375
|
+
return { step: a.step, values: a.values, t0: a.t0 != null ? a.t0 : s.start }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// A filled waveform under the lane, the max of each pixel's slice of time.
|
|
379
|
+
function drawWave(g, act, y, color, lo, hi, dim) {
|
|
380
|
+
const xa = Math.max(0, Math.floor(xOf(Math.max(lo, act.t0), g)))
|
|
381
|
+
const xb = Math.min(g.w, Math.ceil(xOf(Math.min(hi, act.t0 + act.values.length * act.step), g)))
|
|
382
|
+
if (xb <= xa) return 0
|
|
383
|
+
const top = y + 3
|
|
384
|
+
ctx.save()
|
|
385
|
+
ctx.globalAlpha = dim ? 0.12 : 0.3
|
|
386
|
+
ctx.fillStyle = color
|
|
387
|
+
ctx.beginPath()
|
|
388
|
+
ctx.moveTo(xa, top)
|
|
389
|
+
let cols = 0
|
|
390
|
+
for (let x = xa; x <= xb; x++) {
|
|
391
|
+
const i0 = Math.max(0, Math.floor((timeAt(x + geom().left) - act.t0) / act.step))
|
|
392
|
+
const i1 = Math.min(act.values.length - 1, Math.floor((timeAt(x + 1 + geom().left) - act.t0) / act.step))
|
|
393
|
+
let v = 0
|
|
394
|
+
for (let i = i0; i <= i1; i++) v = Math.max(v, act.values[i] || 0)
|
|
395
|
+
ctx.lineTo(x, top + clamp(v, 0, 1) * WAVE_H)
|
|
396
|
+
cols++
|
|
397
|
+
}
|
|
398
|
+
ctx.lineTo(xb, top)
|
|
399
|
+
ctx.closePath()
|
|
400
|
+
ctx.fill()
|
|
401
|
+
ctx.restore()
|
|
402
|
+
return cols
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function roundRect(x, y, w, h, r) {
|
|
406
|
+
ctx.beginPath()
|
|
407
|
+
ctx.roundRect ? ctx.roundRect(x, y, w, h, r) : ctx.rect(x, y, w, h)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The lens: the hovered element's own animations, as bright capsules.
|
|
411
|
+
function drawLens(g, clips, y) {
|
|
412
|
+
const out = []
|
|
413
|
+
ctx.save()
|
|
414
|
+
ctx.font = '500 10px Geist, ui-sans-serif, system-ui, sans-serif'
|
|
415
|
+
ctx.textBaseline = "middle"
|
|
416
|
+
for (const c of clips) {
|
|
417
|
+
const end = c.end == null ? (D.last ? D.last.end : c.start) : c.end
|
|
418
|
+
if (end < D.view.from || c.start > D.view.to) continue
|
|
419
|
+
const x0 = xOf(c.start, g)
|
|
420
|
+
const x1 = Math.max(x0 + 6, xOf(end, g))
|
|
421
|
+
ctx.fillStyle = INK.lens
|
|
422
|
+
roundRect(x0, y - 7, x1 - x0, 14, 7)
|
|
423
|
+
ctx.fill()
|
|
424
|
+
const label = `${clipName(c)} · ${msWord(end - c.start)}`
|
|
425
|
+
if (ctx.measureText(label).width + 12 <= x1 - x0) {
|
|
426
|
+
ctx.fillStyle = INK.page
|
|
427
|
+
ctx.fillText(label, x0 + 6, y + 0.5)
|
|
428
|
+
}
|
|
429
|
+
out.push({ clip: c, x0, x1, y })
|
|
430
|
+
}
|
|
431
|
+
ctx.restore()
|
|
432
|
+
return out
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---- drawing ------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
function sizeCanvas(g) {
|
|
438
|
+
const dpr = window.devicePixelRatio || 1
|
|
439
|
+
const W = Math.round(g.w * dpr)
|
|
440
|
+
const H = Math.round(g.h * dpr)
|
|
441
|
+
if (cv.width !== W || cv.height !== H) {
|
|
442
|
+
cv.width = W
|
|
443
|
+
cv.height = H
|
|
444
|
+
sceneKey = ""
|
|
445
|
+
}
|
|
446
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function renderTimeline(s, shownT) {
|
|
450
|
+
const g = geom()
|
|
451
|
+
if (!g.w || !g.h) return
|
|
452
|
+
stepView(s)
|
|
453
|
+
const active = activeBranch()
|
|
454
|
+
const activeEnd = Math.max(active.end, s.end)
|
|
455
|
+
const tl = timeline()
|
|
456
|
+
const lanes = layoutLanes(g)
|
|
457
|
+
D.lanes = lanes
|
|
458
|
+
renderGutter(lanes)
|
|
459
|
+
const following = s.playing && !D.view.detached
|
|
460
|
+
liveBtn.hidden = !(s.playing && !s.future && D.view.detached)
|
|
461
|
+
checkPendingFork(s)
|
|
462
|
+
|
|
463
|
+
if (band.id !== D.activeId) {
|
|
464
|
+
band.prev = band.id
|
|
465
|
+
band.id = D.activeId
|
|
466
|
+
band.since = performance.now()
|
|
467
|
+
}
|
|
468
|
+
const bandFading = performance.now() - band.since < BAND_MS + 20
|
|
469
|
+
const lensClips = D.lens ? D.lens.clips : null
|
|
470
|
+
const growing = D.branches.some((b) => performance.now() - b.born < GROW_MS + 20)
|
|
471
|
+
const act = activityOf(tl, s)
|
|
472
|
+
// Only draw when something that shows has changed.
|
|
473
|
+
const key = [
|
|
474
|
+
g.w, g.h, D.view.from.toFixed(2), D.view.to.toFixed(2), shownT.toFixed(2), D.activeId, activeEnd.toFixed(1),
|
|
475
|
+
D.branches.map((b) => `${b.id}.${b.end.toFixed(0)}.${b.forkAt}`).join(","),
|
|
476
|
+
tl.markers.length, act ? act.values.length : -1, D.notes.map((n) => `${n.id}.${n.status}.${n.branchId}`).join(","),
|
|
477
|
+
D.markers.length, lensClips ? D.lens.key : "", D.branchT, D.snapT, D.dragT, growing || bandFading ? performance.now() : 0, D.hoverRow,
|
|
478
|
+
].join("|")
|
|
479
|
+
if (key === sceneKey) return
|
|
480
|
+
sceneKey = key
|
|
481
|
+
const t0 = performance.now()
|
|
482
|
+
sizeCanvas(g)
|
|
483
|
+
ctx.clearRect(0, 0, g.w, g.h)
|
|
484
|
+
const xNow = Math.round(xOf(shownT, g))
|
|
485
|
+
const labels = drawRuler(g, s, xNow)
|
|
486
|
+
const dim = !!lensClips
|
|
487
|
+
const scene = { lanes: [], actions: [], waveCols: 0, capsules: [], notes: [], bookmarks: [], labels, following }
|
|
488
|
+
|
|
489
|
+
// Row bands: a very faint one behind the active row (crossfading in 150ms
|
|
490
|
+
// when it changes) and the hovered row.
|
|
491
|
+
const pitch = D.rowPitch
|
|
492
|
+
const fade = clamp((performance.now() - band.since) / BAND_MS, 0, 1)
|
|
493
|
+
const rowBand = (id, alpha) => {
|
|
494
|
+
const L = lanes.get(id)
|
|
495
|
+
if (!L || alpha <= 0) return
|
|
496
|
+
ctx.fillStyle = `rgba(255,255,255,${alpha.toFixed(4)})`
|
|
497
|
+
ctx.fillRect(0, L.y - pitch / 2, g.w, pitch)
|
|
498
|
+
}
|
|
499
|
+
if (D.hoverRow != null && D.hoverRow !== D.activeId) rowBand(D.hoverRow, 0.02)
|
|
500
|
+
if (band.prev != null && band.prev !== D.activeId) rowBand(band.prev, 0.03 * (1 - fade))
|
|
501
|
+
rowBand(D.activeId, 0.03 * fade)
|
|
502
|
+
|
|
503
|
+
// Lanes: the others at 35% of their colour; then the active one, full up to
|
|
504
|
+
// the playhead and 40% over its recorded future.
|
|
505
|
+
for (const b of D.branches) {
|
|
506
|
+
if (b.id === D.activeId) continue
|
|
507
|
+
const p = laneShape(b, g, lanes, s, b.end)
|
|
508
|
+
const a = dim ? 0.15 : 0.35
|
|
509
|
+
const gr = growth(b)
|
|
510
|
+
strokeLane(p, colorOf(b), a, D.compact ? 1.5 : 2, gr)
|
|
511
|
+
if (gr >= 0.999) laneEnd(p, colorOf(b), a)
|
|
512
|
+
scene.lanes.push({ id: b.id, y: p.y, x0: p.x0, x1: p.x1, color: colorOf(b), active: false, empty: p.empty })
|
|
513
|
+
}
|
|
514
|
+
const color = colorOf(active)
|
|
515
|
+
const A = lanes.get(active.id)
|
|
516
|
+
const p = laneShape(active, g, lanes, s, activeEnd)
|
|
517
|
+
const lo = active.forkAt
|
|
518
|
+
if (act) scene.waveCols = drawWave(g, act, A.y, color, lo, activeEnd, dim)
|
|
519
|
+
const gr = growth(active)
|
|
520
|
+
strokeLane(p, color, dim ? 0.2 : 0.4, 2, gr)
|
|
521
|
+
ctx.save()
|
|
522
|
+
ctx.beginPath()
|
|
523
|
+
ctx.rect(-10, 0, xNow + 10, g.h)
|
|
524
|
+
ctx.clip()
|
|
525
|
+
strokeLane(p, color, dim ? 0.45 : 1, 2, gr)
|
|
526
|
+
ctx.restore()
|
|
527
|
+
if (gr >= 0.999) laneEnd(p, color, p.empty || p.x1 <= xNow + 1 ? 1 : 0.4)
|
|
528
|
+
scene.lanes.push({ id: active.id, y: p.y, x0: p.x0, x1: p.x1, color, active: true, empty: p.empty })
|
|
529
|
+
scene.actions = drawActions(g, tl.markers, A.y, lo, activeEnd, dim)
|
|
530
|
+
if (lensClips) scene.capsules = drawLens(g, lensClips, A.y)
|
|
531
|
+
|
|
532
|
+
// Bookmarks, on the ruler.
|
|
533
|
+
ctx.fillStyle = "#f2f2f5"
|
|
534
|
+
for (const m of D.markers) {
|
|
535
|
+
if (m.branchId !== D.activeId || m.t < D.view.from || m.t > D.view.to) continue
|
|
536
|
+
const x = xOf(m.t, g)
|
|
537
|
+
ctx.beginPath()
|
|
538
|
+
ctx.moveTo(x - 3, RULER - 1)
|
|
539
|
+
ctx.lineTo(x, RULER - 5)
|
|
540
|
+
ctx.lineTo(x + 3, RULER - 1)
|
|
541
|
+
ctx.fill()
|
|
542
|
+
scene.bookmarks.push({ id: m.id, x })
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Control held over the track: where a click would branch the active
|
|
546
|
+
// timeline, in its colour, with the time.
|
|
547
|
+
if (D.branchT != null && D.dragT == null && pendingFork == null) {
|
|
548
|
+
const xg = Math.round(xOf(D.branchT, g)) + 0.5
|
|
549
|
+
ctx.fillStyle = color
|
|
550
|
+
ctx.fillRect(xg - 0.5, RULER, 1, g.h - RULER)
|
|
551
|
+
const lx = clamp(xg + 6, 2, g.w - 58)
|
|
552
|
+
roundRect(lx, RULER + 3, 54, 14, 7)
|
|
553
|
+
ctx.fill()
|
|
554
|
+
ctx.fillStyle = INK.page
|
|
555
|
+
ctx.font = '500 9px "Geist Mono", ui-monospace, Menlo, monospace'
|
|
556
|
+
ctx.textAlign = "center"
|
|
557
|
+
ctx.fillText(fmt(D.branchT - s.start), lx + 27, RULER + 13)
|
|
558
|
+
ctx.textAlign = "start"
|
|
559
|
+
scene.guide = { x: xg, t: D.branchT }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Playhead.
|
|
563
|
+
ctx.fillStyle = INK.white
|
|
564
|
+
ctx.fillRect(xNow, 0, 1, g.h)
|
|
565
|
+
ctx.beginPath()
|
|
566
|
+
ctx.moveTo(xNow - 4, 0)
|
|
567
|
+
ctx.lineTo(xNow + 5, 0)
|
|
568
|
+
ctx.lineTo(xNow + 5, 4)
|
|
569
|
+
ctx.lineTo(xNow + 0.5, 8)
|
|
570
|
+
ctx.lineTo(xNow - 4, 4)
|
|
571
|
+
ctx.fill()
|
|
572
|
+
scene.playhead = xNow
|
|
573
|
+
if (D.snapT != null && D.dragT != null) {
|
|
574
|
+
const xs = Math.round(xOf(D.snapT, g)) + 0.5
|
|
575
|
+
ctx.save()
|
|
576
|
+
ctx.strokeStyle = INK.white
|
|
577
|
+
ctx.setLineDash([1, 2])
|
|
578
|
+
ctx.beginPath()
|
|
579
|
+
ctx.moveTo(xs, RULER)
|
|
580
|
+
ctx.lineTo(xs, g.h)
|
|
581
|
+
ctx.stroke()
|
|
582
|
+
ctx.restore()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Notes, last, so they stay on top. A note made before its timeline split
|
|
586
|
+
// off sits on the parent's lane, where that moment is drawn.
|
|
587
|
+
D.notes.forEach((n, i) => {
|
|
588
|
+
let b = branchById(n.branchId)
|
|
589
|
+
while (b && b.parentId && n.t < b.forkAt - 30) b = branchById(b.parentId)
|
|
590
|
+
const L = b && lanes.get(b.id)
|
|
591
|
+
if (!L || n.t < D.view.from || n.t > D.view.to) return
|
|
592
|
+
const x = xOf(n.t, g)
|
|
593
|
+
ctx.fillStyle = NOTE_FILL[n.status] || NOTE_FILL.pending
|
|
594
|
+
if (L.thin) {
|
|
595
|
+
ctx.beginPath()
|
|
596
|
+
ctx.arc(x, L.y, 3, 0, Math.PI * 2)
|
|
597
|
+
ctx.fill()
|
|
598
|
+
scene.notes.push({ id: n.id, x, y: L.y, r: 5 })
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
const label = String(i + 1)
|
|
602
|
+
const w = 8 + label.length * 5
|
|
603
|
+
roundRect(x - w / 2, L.y - 17, w, 12, 6)
|
|
604
|
+
ctx.fill()
|
|
605
|
+
ctx.fillStyle = INK.page
|
|
606
|
+
ctx.font = '600 8px "Geist Mono", ui-monospace, Menlo, monospace'
|
|
607
|
+
ctx.textAlign = "center"
|
|
608
|
+
ctx.fillText(label, x, L.y - 8)
|
|
609
|
+
ctx.textAlign = "start"
|
|
610
|
+
scene.notes.push({ id: n.id, x, y: L.y - 11, r: 7 })
|
|
611
|
+
})
|
|
612
|
+
scene.drawMs = performance.now() - t0
|
|
613
|
+
D.scene = scene
|
|
614
|
+
D.draws = (D.draws || 0) + 1
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// What's under a point on the track: a note, a lens capsule, a bookmark, or
|
|
618
|
+
// a lane (by id).
|
|
619
|
+
function hitAt(clientX, clientY) {
|
|
620
|
+
const g = geom()
|
|
621
|
+
const x = clientX - g.left
|
|
622
|
+
const y = clientY - g.top
|
|
623
|
+
const sc = D.scene
|
|
624
|
+
if (!sc) return null
|
|
625
|
+
for (const n of sc.notes) if (Math.abs(x - n.x) <= n.r && Math.abs(y - n.y) <= n.r) return { kind: "note", id: n.id }
|
|
626
|
+
for (const c of sc.capsules) if (x >= c.x0 - 2 && x <= c.x1 + 2 && Math.abs(y - c.y) <= 8) return { kind: "capsule", clip: c.clip }
|
|
627
|
+
if (y < RULER) for (const b of sc.bookmarks) if (Math.abs(x - b.x) <= 5) return { kind: "bookmark", id: b.id }
|
|
628
|
+
let best = null
|
|
629
|
+
for (const l of sc.lanes) {
|
|
630
|
+
const d = Math.abs(y - l.y)
|
|
631
|
+
if (d <= 8 && x >= l.x0 - 4 && x <= l.x1 + 4 && (!best || d < best.d)) best = { kind: "lane", id: l.id, active: l.active, d }
|
|
632
|
+
}
|
|
633
|
+
return best
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Lane names, in the gutter beside their lanes, each with its colour.
|
|
637
|
+
let gutterKey = ""
|
|
638
|
+
const invalidateGutter = () => (gutterKey = "")
|
|
639
|
+
function renderGutter(lanes) {
|
|
640
|
+
if (gutter.querySelector("input")) return // renaming
|
|
641
|
+
const key = D.compact + ":" + D.rowPitch + D.branches.map((b) => `${b.id}:${b.name}:${lanes.get(b.id).y}:${b.id === D.activeId}`).join("|")
|
|
642
|
+
if (key === gutterKey) return
|
|
643
|
+
gutterKey = key
|
|
644
|
+
// Short dock: just the colour dots (the name shows on hover).
|
|
645
|
+
gutter.classList.toggle("dots", !!D.compact)
|
|
646
|
+
gutter.innerHTML = D.branches
|
|
647
|
+
.map((b) => {
|
|
648
|
+
const L = lanes.get(b.id)
|
|
649
|
+
const on = b.id === D.activeId
|
|
650
|
+
const cls = `lane-name${on ? " active" : ""}`
|
|
651
|
+
const title = D.compact ? ` title="${esc(b.name)}"` : ""
|
|
652
|
+
return `<div class="${cls}" data-lane="${b.id}" style="top:${L.y}px;height:${D.rowPitch}px;margin-top:${-D.rowPitch / 2}px;--c:${colorOf(b)}"${title}><i></i><span>${esc(b.name)}</span></div>`
|
|
653
|
+
})
|
|
654
|
+
.join("")
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---- the header ---------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
// A moment being built behind only shows here when the user is waiting for
|
|
660
|
+
// it (Play or + before it's in, a moment the preview can't show, a timeline
|
|
661
|
+
// switch, an API seek); otherwise the preview is the moment, and it's Paused.
|
|
662
|
+
function phaseOf(s) {
|
|
663
|
+
if (D.dragT != null || D.keyT != null || (s.previewing && D.scopeEl)) return "Scrubbing"
|
|
664
|
+
if (waits(D.building)) return `Building${buildProgress()}`
|
|
665
|
+
if (s.seeking) return "Loading"
|
|
666
|
+
if (s.playing) return s.future ? "Playing" : "Live"
|
|
667
|
+
return "Paused"
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// How far the frame being built behind the visible one has got: " 43%",
|
|
671
|
+
// counted from where this build's replay started.
|
|
672
|
+
function buildProgress() {
|
|
673
|
+
try {
|
|
674
|
+
const b = D.building.pt && D.building.pt.state()
|
|
675
|
+
if (!b || b.target == null || !b.seeking) return ""
|
|
676
|
+
const from = b.from != null ? b.from : b.start
|
|
677
|
+
const pct = clamp((b.now - from) / Math.max(1, b.target - from), 0, 0.99)
|
|
678
|
+
return ` ${Math.floor(pct * 100)}%`
|
|
679
|
+
} catch {
|
|
680
|
+
return ""
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderHead(s, shownT) {
|
|
685
|
+
const text = fmt(shownT - s.start)
|
|
686
|
+
if (text !== readoutText) {
|
|
687
|
+
readoutText = text
|
|
688
|
+
const dot = text.lastIndexOf(".")
|
|
689
|
+
readoutMs.textContent = dot < 0 ? text : text.slice(0, dot)
|
|
690
|
+
readoutCs.textContent = dot < 0 ? "" : text.slice(dot)
|
|
691
|
+
}
|
|
692
|
+
const phase = phaseOf(s)
|
|
693
|
+
if (readoutPhase.textContent !== phase) readoutPhase.textContent = phase
|
|
694
|
+
readoutPhase.dataset.phase = phase.split(" ")[0].toLowerCase()
|
|
695
|
+
// Waiting to play a moment being built reads as playing (space again: don't).
|
|
696
|
+
const playing = (!!s.playing || !!(D.building && D.building.play)) && D.dragT == null
|
|
697
|
+
playBtn.classList.toggle("playing", playing)
|
|
698
|
+
playBtn.setAttribute("aria-label", playing ? "Pause" : "Play")
|
|
699
|
+
// The hint area is only for a short warning (see flash()).
|
|
700
|
+
if (performance.now() < flashUntil) return
|
|
701
|
+
if (hintEl.textContent) hintEl.textContent = ""
|
|
702
|
+
hintEl.classList.remove("show", "warn")
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// A short message in the hint's place (a refused checkout, say).
|
|
706
|
+
function flash(msg) {
|
|
707
|
+
flashUntil = performance.now() + 3500
|
|
708
|
+
hintEl.textContent = msg
|
|
709
|
+
hintEl.classList.add("show", "warn")
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---- a new timeline from a chosen moment -------------------------------------------
|
|
713
|
+
|
|
714
|
+
// Right here if the frame on show is at that moment; else it goes there first
|
|
715
|
+
// and the fork happens once the moment is in (goTo).
|
|
716
|
+
function newTimelineAt(t) {
|
|
717
|
+
const s = state()
|
|
718
|
+
if (!D.PT || !s || !s.started) return
|
|
719
|
+
goTo(t, { fork: true })
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// The new timeline is made and selected, paused at the moment it split off.
|
|
723
|
+
// Nothing plays until you press play.
|
|
724
|
+
function forkNow() {
|
|
725
|
+
D.PT.pause()
|
|
726
|
+
D.PT.forkHere()
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function checkPendingFork(s) {
|
|
730
|
+
if (pendingFork == null || D.building || !D.PT || s.seeking || s.previewing) return
|
|
731
|
+
if (Math.abs(s.now - pendingFork) > 40) return
|
|
732
|
+
pendingFork = null
|
|
733
|
+
forkNow()
|
|
734
|
+
}
|