tui-cap 0.1.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.
@@ -0,0 +1,3312 @@
1
+ // GHCP Capture GUI — vanilla front-end over the local capture/render API.
2
+
3
+ import {
4
+ PLAYBACK_MIN_MS,
5
+ UNIFORM_FRAME_MS,
6
+ effectiveDurations as modelEffectiveDurations,
7
+ rawDurationMs as modelRawDurationMs,
8
+ cfrCount,
9
+ playbackHold,
10
+ } from './timing-model.js';
11
+
12
+ const $ = (id) => document.getElementById(id);
13
+
14
+ const els = {
15
+ library: $('library'),
16
+ libStatus: $('libStatus'),
17
+ libEmpty: $('libEmpty'),
18
+ libLoading: $('libLoading'),
19
+ openFolderBtn: $('openFolderBtn'),
20
+ preview: $('preview'),
21
+ previewWrap: $('previewWrap'),
22
+ previewEmpty: $('previewEmpty'),
23
+ goStartBtn: $('goStartBtn'),
24
+ prevBtn: $('prevBtn'),
25
+ nextBtn: $('nextBtn'),
26
+ goEndBtn: $('goEndBtn'),
27
+ frameLabel: $('frameLabel'),
28
+ selGroup: $('selGroup'),
29
+ selLen: $('selLen'),
30
+ selLenLabel: $('selLenLabel'),
31
+ selReset: $('selReset'),
32
+ selDelete: $('selDelete'),
33
+ zoom: $('zoom'),
34
+ fitBtn: $('fitBtn'),
35
+ hideSkippedBtn: $('hideSkippedBtn'),
36
+ timelineScroll: $('timelineScroll'),
37
+ timelineRuler: $('timelineRuler'),
38
+ timelineTrack: $('timelineTrack'),
39
+ timelineEmpty: $('timelineEmpty'),
40
+ title: $('title'),
41
+ chrome: $('chrome'),
42
+ cursor: $('cursor'),
43
+ chromeStyle: $('chromeStyle'),
44
+ fontSize: $('fontSize'),
45
+ fontSizeVal: $('fontSizeVal'),
46
+ lineHeight: $('lineHeight'),
47
+ lineHeightVal: $('lineHeightVal'),
48
+ resetType: $('resetType'),
49
+ recordedSize: $('recordedSize'),
50
+ downloadBtn: $('downloadBtn'),
51
+ downloadPngBtn: $('downloadPngBtn'),
52
+ pngScale: $('pngScale'),
53
+ playBtn: $('playBtn'),
54
+ animStatus: $('animStatus'),
55
+ animNote: $('animNote'),
56
+ animFps: $('animFps'),
57
+ animScale: $('animScale'),
58
+ idleCap: $('idleCap'),
59
+ idleCapVal: $('idleCapVal'),
60
+ speed: $('speed'),
61
+ speedVal: $('speedVal'),
62
+ smoothTyping: $('smoothTyping'),
63
+ typingCps: $('typingCps'),
64
+ typingCpsVal: $('typingCpsVal'),
65
+ animLen: $('animLen'),
66
+ clearOverrides: $('clearOverrides'),
67
+ hud: $('hud'),
68
+ hudClose: $('hudClose'),
69
+ timingDebugLink: $('timingDebugLink'),
70
+ selfTestBtn: $('selfTestBtn'),
71
+ selfTestResult: $('selfTestResult'),
72
+ exportMp4Btn: $('exportMp4Btn'),
73
+ exportProgress: $('exportProgress'),
74
+ exportBar: $('exportBar'),
75
+ exportPct: $('exportPct'),
76
+ newRecBtn: $('newRecBtn'),
77
+ zoomFitBtn: $('zoomFitBtn'),
78
+ zoom100Btn: $('zoom100Btn'),
79
+ zoomRange: $('zoomRange'),
80
+ zoomPct: $('zoomPct'),
81
+ recDialog: $('recDialog'),
82
+ recCmd: $('recCmd'),
83
+ recName: $('recName'),
84
+ recOut: $('recOut'),
85
+ recCopy: $('recCopy'),
86
+ updateBanner: $('updateBanner'),
87
+ updateMsg: $('updateMsg'),
88
+ updateBtn: $('updateBtn'),
89
+ updateDismiss: $('updateDismiss'),
90
+ toast: $('toast'),
91
+ };
92
+
93
+ const state = {
94
+ name: null,
95
+ frames: [],
96
+ index: 0,
97
+ recordings: [],
98
+ hasTiming: false,
99
+ animationEnd: 0,
100
+ animCanvas: null,
101
+ // Timeline view state (ephemeral — not persisted with the recording).
102
+ zoom: 0, // pixels per second
103
+ hideSkipped: true, // collapse deleted/skipped frames out of the timeline (hidden by default)
104
+ viewMode: 'fit', // canvas scale: 'fit' (zoom to fit) | 'zoom' (explicit zoomScale)
105
+ zoomScale: 1, // canvas zoom factor when viewMode === 'zoom' (1 = 100%)
106
+ selection: new Set(),
107
+ anchor: 0,
108
+ blocks: [], // per-frame { el, head, handle, img, left, width }
109
+ boundaries: [], // cumulative px boundary positions, length frames+1
110
+ skipped: new Set(),
111
+ settings: { fps: 30, scale: 2, idleCapMs: 1500, speed: 1, overrides: {}, skipped: [], smoothTyping: false, typingCps: 30 },
112
+ // Monotonic content-edits revision (from the server). Used as an SVG cache-buster
113
+ // so the browser re-fetches edited frames instead of serving a stale image.
114
+ editsRevision: 0,
115
+ };
116
+
117
+ // Pixels-per-second zoom bounds (the slider maps onto this range on a log scale).
118
+ const MIN_PPS = 2;
119
+ const MAX_PPS = 1200;
120
+ // A block must be at least this wide before it mounts the (relatively heavy) SVG
121
+ // preview; narrower blocks render as a plain colored band. Matches the old card
122
+ // width so previews stay legible.
123
+ const PREVIEW_MIN_PX = 96;
124
+
125
+ // Fallback per-frame hold when a capture has no timing sidecar.
126
+ // UNIFORM_FRAME_MS is imported from ./timing-model.js (single source of truth).
127
+
128
+ // Inline Octicon-style glyphs for the per-recording library actions.
129
+ const ICON = {
130
+ reveal:
131
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
132
+ '<path d="M0 1.75C0 .784.784 0 1.75 0h3.5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1h5.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 13.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v11.5c0 .138.112.25.25.25h11.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H7.5c-.55 0-1.07-.26-1.4-.7l-.9-1.2a.25.25 0 0 0-.2-.1Z"></path></svg>',
133
+ trash:
134
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
135
+ '<path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"></path></svg>',
136
+ // Folder for the library-wide "open captures folder" action.
137
+ folder:
138
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
139
+ '<path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Zm0 1.5h3.25a.25.25 0 0 1 .2.1l.9 1.2c.33.44.85.7 1.4.7h6.75a.25.25 0 0 1 .25.25v8.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25V2.75a.25.25 0 0 1 .25-.25Z"></path></svg>',
140
+ // Pencil for the per-recording rename action.
141
+ pencil:
142
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
143
+ '<path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm1.414 1.06a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354l-1.086-1.086ZM11.189 6.25 9.75 4.81l-6.286 6.287a.25.25 0 0 0-.064.108l-.558 1.953 1.953-.558a.249.249 0 0 0 .108-.064L11.189 6.25Z"></path></svg>',
144
+ // Transport controls. Each glyph is geometrically distinct so Play (lone
145
+ // triangle), step (single triangle + bar) and skip (double triangle + bar)
146
+ // never read the same at a glance.
147
+ goStart:
148
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
149
+ '<path d="M2.8 3.4h1.7v9.2H2.8zM9 3.4v9.2L4.8 8zM13.4 3.4v9.2L9.2 8z"></path></svg>',
150
+ prev:
151
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
152
+ '<path d="M3.6 3.4h2v9.2h-2zM12.8 3.4v9.2L6.4 8z"></path></svg>',
153
+ play:
154
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
155
+ '<path d="M5 3.4v9.2l7.2-4.6z"></path></svg>',
156
+ pause:
157
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
158
+ '<path d="M4.6 3.4h2.4v9.2H4.6zM9 3.4h2.4v9.2H9z"></path></svg>',
159
+ next:
160
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
161
+ '<path d="M3.2 3.4v9.2l6.4-4.6zM10.4 3.4h2v9.2h-2z"></path></svg>',
162
+ goEnd:
163
+ '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
164
+ '<path d="M2.6 3.4v9.2L6.8 8zM7 3.4v9.2L11.2 8zM11.5 3.4h1.7v9.2h-1.7z"></path></svg>',
165
+ };
166
+
167
+ /* ----------------------------------------------------------------- helpers */
168
+
169
+ function debounce(fn, ms) {
170
+ let t;
171
+ return (...args) => {
172
+ clearTimeout(t);
173
+ t = setTimeout(() => fn(...args), ms);
174
+ };
175
+ }
176
+
177
+ async function apiJson(path) {
178
+ const res = await fetch(path);
179
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
180
+ return res.json();
181
+ }
182
+
183
+ /** Combined query: render options (replay dims come from the recording itself). */
184
+ function query() {
185
+ const p = new URLSearchParams();
186
+ p.set('title', els.title.value);
187
+ p.set('chrome', els.chrome.checked ? '1' : '0');
188
+ p.set('cursor', els.cursor.checked ? '1' : '0');
189
+ p.set('chromeStyle', els.chromeStyle.value);
190
+ p.set('fontSize', els.fontSize.value);
191
+ p.set('lineHeight', els.lineHeight.value);
192
+ // Bumped whenever content edits change so edited frames get a fresh URL and the
193
+ // browser re-fetches them instead of serving a stale (pre-edit) cached image.
194
+ p.set('rev', String(state.editsRevision || 0));
195
+ return p;
196
+ }
197
+
198
+ /**
199
+ * Tight, per-frame render: the renderer auto-fits to this frame's own inked
200
+ * content (trailing background bleed trimmed). Used ONLY for the timeline
201
+ * filmstrip thumbnails, where a snug crop per frame reads better in the strip.
202
+ */
203
+ function svgUrl(index, download = false) {
204
+ const p = query();
205
+ if (download) p.set('download', '1');
206
+ return `/api/recordings/${encodeURIComponent(state.name)}/frames/${index}/svg?${p}`;
207
+ }
208
+
209
+ /**
210
+ * Canonical render params: the full (untrimmed) grid pinned to the recording's
211
+ * uniform animation canvas via fixedCols/fixedRows, so every frame renders at
212
+ * identical pixel dimensions no matter how sparse it is (the renderer otherwise
213
+ * auto-fits each frame to its own content). Shared by the main preview, the
214
+ * still SVG/PNG exports, the MP4 export, AND the editor's /cells geometry, so the
215
+ * window never resizes between frames and the overlay always lines up with what
216
+ * is shown.
217
+ */
218
+ function fullParams() {
219
+ const p = query();
220
+ p.set('trim', '0');
221
+ if (state.animCanvas) {
222
+ p.set('fixedCols', String(state.animCanvas.cols));
223
+ p.set('fixedRows', String(state.animCanvas.rows));
224
+ }
225
+ return p;
226
+ }
227
+
228
+ function svgUrlFull(index) {
229
+ return `/api/recordings/${encodeURIComponent(state.name)}/frames/${index}/svg?${fullParams()}`;
230
+ }
231
+
232
+ /* --------------------------------------------------------- timeline math */
233
+
234
+ /** Raw on-screen hold for a frame, before caps/speed/overrides. */
235
+ function rawDurationMs(i) {
236
+ return modelRawDurationMs(state.frames[i]);
237
+ }
238
+
239
+ /**
240
+ * Effective per-frame durations (ms) across the whole frame list, applying the
241
+ * current settings. A per-frame override wins outright (bypasses cap + speed);
242
+ * otherwise the raw hold is capped at idleCapMs then divided by the speed.
243
+ * Delegates to the shared, parity-tested timing model (src/web/timing-model.js).
244
+ */
245
+ function effectiveDurations() {
246
+ return modelEffectiveDurations(state.frames, state.settings);
247
+ }
248
+
249
+ /** True when frame `i` is marked skipped (excluded from playback + export). */
250
+ function isSkipped(i) {
251
+ return state.skipped.has(i);
252
+ }
253
+
254
+ /**
255
+ * Indices that make up the animation: frames [0..animationEnd], minus any the
256
+ * user has skipped. Drives the play loop, the length readout, and MP4 export.
257
+ */
258
+ function animRange() {
259
+ const last = Math.min(state.animationEnd, state.frames.length - 1);
260
+ const out = [];
261
+ for (let i = 0; i <= last; i++) if (!isSkipped(i)) out.push(i);
262
+ return out;
263
+ }
264
+
265
+ // Manual navigation (transport buttons, Home/End/arrows, scrub) should land on
266
+ // the frames the user can actually see. While skipped frames are collapsed out
267
+ // of the timeline they have zero width and must be hopped over, otherwise "go to
268
+ // start" would surface a deleted opening frame instead of the first kept one.
269
+
270
+ /** First frame the user can land on: the first non-skipped frame while skipped
271
+ * frames are hidden, otherwise frame 0. */
272
+ function firstVisibleIndex() {
273
+ if (state.hideSkipped) {
274
+ for (let i = 0; i < state.frames.length; i++) if (!isSkipped(i)) return i;
275
+ }
276
+ return 0;
277
+ }
278
+ /** Last frame the user can land on (symmetric with firstVisibleIndex). */
279
+ function lastVisibleIndex() {
280
+ const n = state.frames.length;
281
+ if (state.hideSkipped) {
282
+ for (let i = n - 1; i >= 0; i--) if (!isSkipped(i)) return i;
283
+ }
284
+ return n - 1;
285
+ }
286
+ /** Step from `from` by `dir` (±1) to the next landable frame, hopping over
287
+ * frames collapsed out of the timeline. Stays put at the visible edge. */
288
+ function stepVisibleIndex(from, dir) {
289
+ for (let i = from + dir; i >= 0 && i < state.frames.length; i += dir) {
290
+ if (!state.hideSkipped || !isSkipped(i)) return i;
291
+ }
292
+ return from;
293
+ }
294
+
295
+ /** CFR repeat count for a frame held `ms` at `fps` — imported from the shared
296
+ * timing model (src/web/timing-model.js); see `cfrCount` in the import block. */
297
+
298
+ /* ----------------------------------------------------- idle-cap slider map */
299
+
300
+ // The idle-cap slider runs in 60fps-frame units: position 1 = one frame
301
+ // (1000/60 ≈ 16.67ms, the smallest cap) up through IDLE_CAP_MAX_FRAMES, plus one
302
+ // extra position past that = ∞ (no cap), stored as idleCapMs = 0 so an idle frame
303
+ // keeps its real recorded hold. Working in frame units lets the far-left land
304
+ // exactly on a single 60fps frame and the far-right be a clean ∞ stop, while
305
+ // state.settings.idleCapMs stays the source of truth (0 ⇒ ∞ / use real time).
306
+ const IDLE_FRAME_MS = 1000 / 60;
307
+ const IDLE_CAP_MAX_FRAMES = 300; // top finite cap ≈ 5.0s
308
+ const IDLE_CAP_INF = IDLE_CAP_MAX_FRAMES + 1; // slider max → ∞ (no cap)
309
+
310
+ /** Map an idle-cap (ms; 0 ⇒ ∞) to its slider position (frame units). */
311
+ function idleCapToSlider(ms) {
312
+ if (!(ms > 0)) return IDLE_CAP_INF;
313
+ return Math.max(1, Math.min(IDLE_CAP_MAX_FRAMES, Math.round(ms / IDLE_FRAME_MS)));
314
+ }
315
+
316
+ /** Map a slider position back to an idle-cap (ms); the top stop ⇒ 0 (∞). */
317
+ function sliderToIdleCap(pos) {
318
+ return pos >= IDLE_CAP_INF ? 0 : pos * IDLE_FRAME_MS;
319
+ }
320
+
321
+ /** Compact idle-cap label: ∞ for no cap, else 1.5s / 100ms / 16.7ms. */
322
+ function fmtIdleCap(ms) {
323
+ if (!(ms > 0)) return '∞';
324
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
325
+ if (ms >= 100) return `${Math.round(ms)}ms`;
326
+ return `${Math.round(ms * 10) / 10}ms`;
327
+ }
328
+
329
+ /* ----------------------------------------------------------- zoom mapping */
330
+
331
+ // The zoom slider is 0..1000; map it onto [MIN_PPS, MAX_PPS] on a log scale so
332
+ // low and high zoom both get usable resolution.
333
+ function sliderToPps(v) {
334
+ const t = Math.max(0, Math.min(1, Number(v) / 1000));
335
+ return MIN_PPS * Math.pow(MAX_PPS / MIN_PPS, t);
336
+ }
337
+ function ppsToSlider(pps) {
338
+ const clamped = Math.max(MIN_PPS, Math.min(MAX_PPS, pps));
339
+ const t = Math.log(clamped / MIN_PPS) / Math.log(MAX_PPS / MIN_PPS);
340
+ return Math.round(t * 1000);
341
+ }
342
+ /** Clamp a pixels-per-second zoom value to the supported range. */
343
+ function clampPps(pps) {
344
+ return Math.max(MIN_PPS, Math.min(MAX_PPS, pps));
345
+ }
346
+
347
+ function relTime(ms) {
348
+ const diff = Date.now() - ms;
349
+ const s = Math.round(diff / 1000);
350
+ if (s < 45) return 'just now';
351
+ const m = Math.round(s / 60);
352
+ if (m < 60) return `${m}m ago`;
353
+ const h = Math.round(m / 60);
354
+ if (h < 24) return `${h}h ago`;
355
+ const d = Math.round(h / 24);
356
+ if (d < 7) return `${d}d ago`;
357
+ return new Date(ms).toLocaleDateString();
358
+ }
359
+
360
+ let toastTimer;
361
+ function toast(msg) {
362
+ els.toast.textContent = msg;
363
+ els.toast.classList.remove('hidden');
364
+ clearTimeout(toastTimer);
365
+ toastTimer = setTimeout(() => els.toast.classList.add('hidden'), 2400);
366
+ }
367
+
368
+ /* --------------------------------------------------------------- library */
369
+
370
+ // The first library scan parses every capture to count frames, so a large
371
+ // library can take a moment. Show a spinner on that initial load only — later
372
+ // SSE/poll refreshes happen in the background and must not flash it.
373
+ let libraryLoaded = false;
374
+
375
+ async function loadLibrary({ keepSelection = false } = {}) {
376
+ const firstLoad = !libraryLoaded;
377
+ if (firstLoad) {
378
+ els.libLoading.classList.remove('hidden');
379
+ els.libEmpty.classList.add('hidden');
380
+ els.libStatus.textContent = 'Loading…';
381
+ }
382
+ let data;
383
+ try {
384
+ data = await apiJson('/api/recordings');
385
+ } catch (e) {
386
+ els.libStatus.textContent = 'error';
387
+ els.libLoading.classList.add('hidden');
388
+ return;
389
+ } finally {
390
+ libraryLoaded = true;
391
+ }
392
+ els.libLoading.classList.add('hidden');
393
+ state.recordings = data.recordings;
394
+ els.libStatus.textContent = `${data.recordings.length} item${data.recordings.length === 1 ? '' : 's'}`;
395
+ els.library.innerHTML = '';
396
+ els.libEmpty.classList.toggle('hidden', data.recordings.length > 0);
397
+
398
+ for (const rec of data.recordings) {
399
+ const li = document.createElement('li');
400
+ li.className = 'rec-item';
401
+ li.dataset.name = rec.name;
402
+ if (rec.name === state.name) li.classList.add('active');
403
+
404
+ const main = document.createElement('div');
405
+ main.className = 'rec-main';
406
+ main.innerHTML =
407
+ `<span class="name">${rec.name}</span>` +
408
+ `<span class="meta"><span>${relTime(rec.mtime)}</span>` +
409
+ `<span>${rec.frames} frame${rec.frames === 1 ? '' : 's'}</span></span>`;
410
+ main.addEventListener('click', () => selectRecording(rec.name));
411
+
412
+ const actions = document.createElement('div');
413
+ actions.className = 'rec-actions';
414
+
415
+ const revealBtn = document.createElement('button');
416
+ revealBtn.className = 'rec-act';
417
+ revealBtn.type = 'button';
418
+ revealBtn.title = 'Reveal in Finder';
419
+ revealBtn.setAttribute('aria-label', `Reveal ${rec.name} in Finder`);
420
+ revealBtn.innerHTML = ICON.reveal;
421
+ revealBtn.addEventListener('click', (e) => {
422
+ e.stopPropagation();
423
+ revealRecording(rec.name);
424
+ });
425
+
426
+ const renameBtn = document.createElement('button');
427
+ renameBtn.className = 'rec-act';
428
+ renameBtn.type = 'button';
429
+ renameBtn.title = 'Rename recording';
430
+ renameBtn.setAttribute('aria-label', `Rename ${rec.name}`);
431
+ renameBtn.innerHTML = ICON.pencil;
432
+ renameBtn.addEventListener('click', (e) => {
433
+ e.stopPropagation();
434
+ renameRecording(rec.name);
435
+ });
436
+
437
+ const delBtn = document.createElement('button');
438
+ delBtn.className = 'rec-act danger';
439
+ delBtn.type = 'button';
440
+ delBtn.title = 'Delete recording';
441
+ delBtn.setAttribute('aria-label', `Delete ${rec.name}`);
442
+ delBtn.innerHTML = ICON.trash;
443
+ delBtn.addEventListener('click', (e) => {
444
+ e.stopPropagation();
445
+ deleteRecording(rec.name);
446
+ });
447
+
448
+ actions.append(revealBtn, renameBtn, delBtn);
449
+ li.append(main, actions);
450
+ els.library.appendChild(li);
451
+ }
452
+
453
+ const stillThere = data.recordings.some((r) => r.name === state.name);
454
+ if (!keepSelection || !stillThere) {
455
+ if (data.recordings.length > 0) selectRecording(data.recordings[0].name);
456
+ else clearPreview();
457
+ }
458
+ }
459
+
460
+ function markActiveRecording() {
461
+ for (const li of els.library.children) {
462
+ li.classList.toggle('active', li.dataset.name === state.name);
463
+ }
464
+ }
465
+
466
+ async function revealRecording(name) {
467
+ try {
468
+ const res = await fetch(`/api/recordings/${encodeURIComponent(name)}/reveal`, {
469
+ method: 'POST',
470
+ });
471
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
472
+ toast('Revealed in Finder');
473
+ } catch (e) {
474
+ toast(`Reveal failed: ${e.message}`);
475
+ }
476
+ }
477
+
478
+ async function openCapturesFolder() {
479
+ try {
480
+ const res = await fetch('/api/open-folder', { method: 'POST' });
481
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
482
+ toast('Opened captures folder');
483
+ } catch (e) {
484
+ toast(`Open folder failed: ${e.message}`);
485
+ }
486
+ }
487
+
488
+ /* --------------------------------------------------------------- updates */
489
+
490
+ // Filled in by checkForUpdate() so the update button knows the install kind and
491
+ // the exact command to fall back to if an in-place update can't run.
492
+ let updateInfo = null;
493
+
494
+ // Check npm (via the local server) for a newer release on launch, and surface a
495
+ // banner when one exists. Silent on any failure — an offline machine just won't
496
+ // see the prompt.
497
+ async function checkForUpdate() {
498
+ try {
499
+ updateInfo = await apiJson('/api/version');
500
+ } catch {
501
+ return;
502
+ }
503
+ if (!updateInfo || !updateInfo.updateAvailable || !updateInfo.latest) return;
504
+ showUpdateBanner();
505
+ }
506
+
507
+ function showUpdateBanner() {
508
+ const { current, latest, installKind } = updateInfo;
509
+ if (installKind === 'npx') {
510
+ // Nothing to update in place — npx always fetches the latest on demand.
511
+ els.updateMsg.textContent =
512
+ `tui-cap v${latest} is available (you're on v${current}). ` +
513
+ `You're running via npx — re-run \`npx tui-cap@latest\` to upgrade.`;
514
+ els.updateBtn.classList.add('hidden');
515
+ } else {
516
+ els.updateMsg.textContent = `A new version of tui-cap is available — v${current} → v${latest}.`;
517
+ els.updateBtn.classList.remove('hidden');
518
+ }
519
+ els.updateBanner.classList.remove('hidden');
520
+ }
521
+
522
+ function fallbackUpdateCommand() {
523
+ return (updateInfo && updateInfo.updateCommand) || 'npm install -g tui-cap@latest';
524
+ }
525
+
526
+ async function doUpdate() {
527
+ els.updateBtn.disabled = true;
528
+ els.updateBtn.textContent = 'Updating…';
529
+ els.updateMsg.textContent = 'Updating tui-cap… this can take up to a minute.';
530
+ let data = {};
531
+ try {
532
+ const res = await fetch('/api/update', { method: 'POST' });
533
+ data = await res.json().catch(() => ({}));
534
+ if (res.ok && data.ok) {
535
+ els.updateBtn.classList.add('hidden');
536
+ els.updateDismiss.textContent = 'Dismiss';
537
+ els.updateMsg.textContent =
538
+ `Updated to v${data.latest || ''} ✓ Quit tui-cap (Ctrl+C in its terminal) ` +
539
+ `and run it again to use the new version.`;
540
+ return;
541
+ }
542
+ if (data.skipped) {
543
+ els.updateBtn.classList.add('hidden');
544
+ els.updateDismiss.textContent = 'Dismiss';
545
+ els.updateMsg.textContent = data.message || `Re-run \`${data.command}\`.`;
546
+ return;
547
+ }
548
+ } catch {
549
+ // network error talking to the local server — fall through to manual path
550
+ }
551
+ failUpdate();
552
+ }
553
+
554
+ function failUpdate() {
555
+ els.updateBtn.disabled = false;
556
+ els.updateBtn.textContent = 'Retry';
557
+ const cmd = fallbackUpdateCommand();
558
+ els.updateMsg.textContent = `Couldn't update automatically. Run this in your terminal: ${cmd}`;
559
+ toast('Update failed — copy the command shown');
560
+ }
561
+
562
+
563
+ async function renameRecording(name) {
564
+ const current = name.replace(/\.ans$/i, '');
565
+ const input = window.prompt(`Rename ${name} to:`, current);
566
+ if (input === null) return;
567
+ const trimmed = input.trim();
568
+ if (!trimmed) return;
569
+ const target = /\.ans$/i.test(trimmed) ? trimmed : `${trimmed}.ans`;
570
+ if (target === name) return;
571
+ const wasActive = state.name === name;
572
+ try {
573
+ const res = await fetch(`/api/recordings/${encodeURIComponent(name)}/rename`, {
574
+ method: 'POST',
575
+ headers: { 'content-type': 'application/json' },
576
+ body: JSON.stringify({ to: trimmed }),
577
+ });
578
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
579
+ const data = await res.json().catch(() => ({}));
580
+ const finalName = data.to || target;
581
+ if (wasActive) state.name = finalName;
582
+ await loadLibrary({ keepSelection: true });
583
+ if (wasActive) selectRecording(finalName);
584
+ toast(`Renamed to ${finalName}`);
585
+ } catch (e) {
586
+ toast(`Rename failed: ${e.message}`);
587
+ }
588
+ }
589
+
590
+ async function deleteRecording(name) {
591
+ if (!window.confirm(`Delete ${name}? This removes the capture file from your library.`)) {
592
+ return;
593
+ }
594
+ try {
595
+ const res = await fetch(`/api/recordings/${encodeURIComponent(name)}`, { method: 'DELETE' });
596
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
597
+ if (state.name === name) {
598
+ state.name = null;
599
+ clearPreview();
600
+ }
601
+ toast(`Deleted ${name}`);
602
+ loadLibrary({ keepSelection: true });
603
+ } catch (e) {
604
+ toast(`Delete failed: ${e.message}`);
605
+ }
606
+ }
607
+
608
+ /* ----------------------------------------------------------------- frames */
609
+
610
+ async function selectRecording(name) {
611
+ state.name = name;
612
+ markActiveRecording();
613
+ await loadFrames();
614
+ }
615
+
616
+ async function loadFrames() {
617
+ if (!state.name) return;
618
+ let data;
619
+ try {
620
+ data = await apiJson(`/api/recordings/${encodeURIComponent(state.name)}/frames`);
621
+ } catch (e) {
622
+ toast(`Failed to read frames: ${e.message}`);
623
+ return;
624
+ }
625
+ state.frames = data.frames;
626
+ state.editsRevision = typeof data.editsRevision === 'number' ? data.editsRevision : 0;
627
+ state.hasTiming = Boolean(data.hasTiming);
628
+ state.animationEnd =
629
+ typeof data.animationEnd === 'number' ? data.animationEnd : data.frames.length - 1;
630
+ state.animCanvas =
631
+ data.animCanvas && data.animCanvas.cols && data.animCanvas.rows ? data.animCanvas : null;
632
+ if (data.cols && data.rows) {
633
+ els.recordedSize.textContent = `${data.cols} × ${data.rows}`;
634
+ }
635
+ await loadAnimationSettings();
636
+ state.selection = new Set();
637
+ buildTimeline();
638
+ fitTimeline();
639
+ setIndex(data.defaultIndex ?? state.frames.length - 1);
640
+ selectSingle(state.index);
641
+ await onRecordingEdits();
642
+ }
643
+
644
+ /* -------------------------------------------------------------- timeline */
645
+
646
+ /**
647
+ * Build the timeline DOM once per recording: one absolutely-positioned block
648
+ * per frame (with a header strip and a right-edge resize handle) plus a single
649
+ * playhead. Positions and the SVG previews are filled in by `layoutTimeline`.
650
+ */
651
+ function buildTimeline() {
652
+ els.timelineTrack.innerHTML = '';
653
+ els.timelineRuler.innerHTML = '';
654
+ state.blocks = [];
655
+
656
+ state.frames.forEach((f, i) => {
657
+ const el = document.createElement('div');
658
+ el.className = 'tl-frame';
659
+ if (f.isTyping) el.classList.add('is-typing');
660
+ el.dataset.index = String(i);
661
+
662
+ const head = document.createElement('div');
663
+ head.className = 'tl-head';
664
+ head.textContent = String(i + 1);
665
+ el.appendChild(head);
666
+
667
+ const handle = document.createElement('div');
668
+ handle.className = 'tl-handle';
669
+ handle.title = 'Drag to change this frame’s length';
670
+ handle.addEventListener('pointerdown', (e) => startResize(e, i));
671
+ el.appendChild(handle);
672
+
673
+ el.addEventListener('click', (e) => onBlockClick(e, i));
674
+
675
+ els.timelineTrack.appendChild(el);
676
+ state.blocks.push({ el, head, handle, img: null });
677
+ });
678
+
679
+ const playhead = document.createElement('div');
680
+ playhead.className = 'tl-playhead';
681
+ playhead.style.display = 'none';
682
+ els.timelineTrack.appendChild(playhead);
683
+ state.playhead = playhead;
684
+
685
+ els.timelineEmpty.classList.toggle('hidden', state.frames.length > 0);
686
+ }
687
+
688
+ /** Cumulative px boundaries for the current zoom: boundaries[i]..boundaries[i+1]. */
689
+ function computeBoundaries() {
690
+ const eff = effectiveDurations();
691
+ const pps = state.zoom > 0 ? state.zoom : sliderToPps(0);
692
+ const b = new Array(state.frames.length + 1);
693
+ b[0] = 0;
694
+ let cum = 0;
695
+ for (let i = 0; i < state.frames.length; i++) {
696
+ const hidden = state.hideSkipped && isSkipped(i);
697
+ cum += hidden ? 0 : Math.max(0, eff[i]) / 1000;
698
+ b[i + 1] = Math.round(cum * pps);
699
+ }
700
+ return b;
701
+ }
702
+
703
+ /** Position every block + handle, redraw the ruler, refresh previews + playhead. */
704
+ function layoutTimeline() {
705
+ if (state.frames.length === 0) return;
706
+ const b = computeBoundaries();
707
+ state.boundaries = b;
708
+ const total = b[b.length - 1];
709
+ els.timelineTrack.style.width = `${total}px`;
710
+ els.timelineRuler.style.width = `${total}px`;
711
+
712
+ state.blocks.forEach((blk, i) => {
713
+ const hidden = state.hideSkipped && isSkipped(i);
714
+ const left = b[i];
715
+ const width = Math.max(1, b[i + 1] - b[i]);
716
+ blk.el.style.display = hidden ? 'none' : '';
717
+ blk.el.style.left = `${left}px`;
718
+ blk.el.style.width = `${width}px`;
719
+ blk.width = hidden ? 0 : width;
720
+ });
721
+
722
+ renderRuler(total);
723
+ updatePreviews();
724
+ positionPlayhead();
725
+ }
726
+
727
+ /** Draw second/sub-second tick marks at a "nice" interval for the current zoom. */
728
+ function renderRuler(total) {
729
+ const pps = state.zoom > 0 ? state.zoom : sliderToPps(0);
730
+ els.timelineRuler.innerHTML = '';
731
+ if (!isFinite(pps) || pps <= 0) return;
732
+ const steps = [0.05, 0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
733
+ const targetPx = 80;
734
+ let step = steps[steps.length - 1];
735
+ for (const s of steps) {
736
+ if (s * pps >= targetPx) {
737
+ step = s;
738
+ break;
739
+ }
740
+ }
741
+ const totalSec = total / pps;
742
+ const frag = document.createDocumentFragment();
743
+ for (let t = 0; t <= totalSec + 1e-6; t += step) {
744
+ const x = Math.round(t * pps);
745
+ if (x > total) break;
746
+ const tick = document.createElement('div');
747
+ tick.className = 'tl-tick';
748
+ tick.style.left = `${x}px`;
749
+ const span = document.createElement('span');
750
+ span.textContent = step < 1 ? `${t.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}s` : `${Math.round(t)}s`;
751
+ tick.appendChild(span);
752
+ frag.appendChild(tick);
753
+ }
754
+ els.timelineRuler.appendChild(frag);
755
+ }
756
+
757
+ /**
758
+ * Mount an SVG preview inside a block only when it's wide enough to be legible
759
+ * and near the visible scroll window; remove previews that fall out of those
760
+ * bounds so we never hold hundreds of SVGs at once.
761
+ */
762
+ function updatePreviews() {
763
+ if (state.frames.length === 0) return;
764
+ const scroll = els.timelineScroll;
765
+ const viewLeft = scroll.scrollLeft - scroll.clientWidth;
766
+ const viewRight = scroll.scrollLeft + scroll.clientWidth * 2;
767
+ state.blocks.forEach((blk, i) => {
768
+ const left = state.boundaries[i];
769
+ const right = state.boundaries[i + 1];
770
+ const visible = right >= viewLeft && left <= viewRight;
771
+ const wide = (blk.width || 0) >= PREVIEW_MIN_PX;
772
+ if (wide && visible) {
773
+ if (!blk.img) {
774
+ const img = document.createElement('img');
775
+ img.className = 'tl-prev';
776
+ img.loading = 'lazy';
777
+ img.alt = `Frame ${i + 1}`;
778
+ img.src = svgUrl(i);
779
+ blk.el.appendChild(img);
780
+ blk.img = img;
781
+ }
782
+ } else if (blk.img) {
783
+ blk.img.remove();
784
+ blk.img = null;
785
+ }
786
+ });
787
+ }
788
+
789
+ const onTimelineScroll = () => updatePreviews();
790
+
791
+ /** X (track px) -> frame index, via binary search over the boundaries. */
792
+ function frameAtX(x) {
793
+ const b = state.boundaries;
794
+ if (!b || b.length < 2) return 0;
795
+ // Clamp into the track, then find the rightmost boundary at or left of x.
796
+ // Collapsed (skipped) frames share a boundary with their visible neighbour, so
797
+ // the search naturally prefers the visible frame at a shared edge — except a
798
+ // run of skipped frames at the very end, which we walk back off below.
799
+ const xc = Math.max(0, Math.min(x, b[b.length - 1]));
800
+ let lo = 0;
801
+ let hi = b.length - 2;
802
+ while (lo < hi) {
803
+ const mid = (lo + hi + 1) >> 1;
804
+ if (b[mid] <= xc) lo = mid;
805
+ else hi = mid - 1;
806
+ }
807
+ if (state.hideSkipped && isSkipped(lo)) {
808
+ while (lo > 0 && isSkipped(lo)) lo--;
809
+ }
810
+ return lo;
811
+ }
812
+
813
+ function setIndex(i) {
814
+ const n = state.frames.length;
815
+ if (n === 0) return clearPreview();
816
+ state.index = Math.max(0, Math.min(n - 1, i));
817
+
818
+ const frame = state.frames[state.index];
819
+ // Always the uniform canvas (not only during playback) so the window never
820
+ // changes size as you scrub onto a sparse frame — e.g. the tabs-only opening
821
+ // frames or the teardown summary. Matches the video + exports exactly.
822
+ els.preview.src = svgUrlFull(state.index);
823
+ els.preview.classList.remove('hidden');
824
+ els.previewEmpty.classList.add('hidden');
825
+
826
+ els.downloadBtn.disabled = false;
827
+ els.downloadPngBtn.disabled = false;
828
+ els.pngScale.disabled = false;
829
+ els.playBtn.disabled = false;
830
+ els.exportMp4Btn.disabled = false;
831
+ if (els.selfTestBtn) els.selfTestBtn.disabled = false;
832
+ els.goStartBtn.disabled = false;
833
+ els.prevBtn.disabled = false;
834
+ els.nextBtn.disabled = false;
835
+ els.goEndBtn.disabled = false;
836
+ els.zoom.disabled = false;
837
+ els.fitBtn.disabled = false;
838
+ els.hideSkippedBtn.disabled = false;
839
+ syncHideSkippedBtn();
840
+ updateViewScaleButtons();
841
+
842
+ state.blocks.forEach((blk, idx) => blk.el.classList.toggle('current', idx === state.index));
843
+ scrollFrameIntoView(state.index);
844
+ positionPlayhead();
845
+ updateFrameLabel();
846
+
847
+ // Keep the content-edit overlay in sync with the newly shown frame: close any
848
+ // open inline editor and re-measure cells for the new frame's grid/text.
849
+ if (ed.on && !play.on) {
850
+ closePopover();
851
+ fetchCells(state.index).then(positionOverlay);
852
+ }
853
+ }
854
+
855
+ /** Keep the given frame within the horizontal scroll viewport. */
856
+ function scrollFrameIntoView(i) {
857
+ const b = state.boundaries;
858
+ if (!b || b.length < 2) return;
859
+ const scroll = els.timelineScroll;
860
+ const left = b[i];
861
+ const right = b[i + 1];
862
+ if (left < scroll.scrollLeft) scroll.scrollLeft = Math.max(0, left - 16);
863
+ else if (right > scroll.scrollLeft + scroll.clientWidth) {
864
+ scroll.scrollLeft = right - scroll.clientWidth + 16;
865
+ }
866
+ }
867
+
868
+ /** Place the playhead at a px position (defaults to the current frame's start). */
869
+ function positionPlayhead(px) {
870
+ if (!state.playhead || state.frames.length === 0) return;
871
+ const b = state.boundaries;
872
+ const x = typeof px === 'number' ? px : b && b.length ? b[state.index] : 0;
873
+ state.playhead.style.display = '';
874
+ state.playhead.style.left = `${x}px`;
875
+ }
876
+
877
+ function updateFrameLabel() {
878
+ const n = state.frames.length;
879
+ if (n === 0) {
880
+ els.frameLabel.textContent = '—';
881
+ return;
882
+ }
883
+ els.frameLabel.textContent =
884
+ state.selection.size > 1
885
+ ? `${state.selection.size} selected`
886
+ : `Frame ${state.index + 1} of ${n}`;
887
+ }
888
+
889
+ /* ------------------------------------------------------------- scrub */
890
+
891
+ /**
892
+ * Click or drag on the ruler to scrub: moves the playhead to the cursor and
893
+ * updates the current frame + big preview live. Pure navigation — it interrupts
894
+ * playback but deliberately leaves the multi-frame selection untouched.
895
+ */
896
+ function startScrub(e) {
897
+ if (state.frames.length === 0) return;
898
+ e.preventDefault();
899
+ stopPlay();
900
+ els.timelineScroll.classList.add('dragging');
901
+
902
+ let lastIdx = -1;
903
+ const seekToClientX = (clientX) => {
904
+ const x = clientX - els.timelineRuler.getBoundingClientRect().left;
905
+ const i = frameAtX(x);
906
+ if (i !== lastIdx) {
907
+ setIndex(i);
908
+ lastIdx = i;
909
+ }
910
+ positionPlayhead(Math.max(0, x));
911
+ };
912
+ seekToClientX(e.clientX);
913
+
914
+ const onMove = (ev) => seekToClientX(ev.clientX);
915
+ const onUp = () => {
916
+ window.removeEventListener('pointermove', onMove);
917
+ window.removeEventListener('pointerup', onUp);
918
+ els.timelineScroll.classList.remove('dragging');
919
+ positionPlayhead(); // settle the playhead onto the frame's start boundary
920
+ };
921
+ window.addEventListener('pointermove', onMove);
922
+ window.addEventListener('pointerup', onUp);
923
+ }
924
+
925
+ /* ----------------------------------------------------------- selection */
926
+
927
+ function onBlockClick(e, i) {
928
+ // Ignore the click that can follow a drag-resize. Using a short time window
929
+ // (rather than a sticky flag) means a drag whose pointerup lands off the block
930
+ // — so no click fires to clear the flag — can never eat a later real click.
931
+ if (Date.now() - (state.dragEndedAt || 0) < 250) return;
932
+ e.preventDefault();
933
+ if (e.shiftKey) selectRange(state.anchor, i);
934
+ else if (e.metaKey || e.ctrlKey) selectToggle(i);
935
+ else selectSingle(i);
936
+ }
937
+
938
+ function selectSingle(i) {
939
+ stopPlay();
940
+ state.selection = new Set([i]);
941
+ state.anchor = i;
942
+ setIndex(i);
943
+ updateFrameStates();
944
+ updateSelectionUI();
945
+ }
946
+
947
+ function selectToggle(i) {
948
+ stopPlay();
949
+ if (state.selection.has(i)) {
950
+ state.selection.delete(i);
951
+ if (state.selection.size === 0) state.selection.add(i);
952
+ } else {
953
+ state.selection.add(i);
954
+ }
955
+ state.anchor = i;
956
+ setIndex(i);
957
+ updateFrameStates();
958
+ updateSelectionUI();
959
+ }
960
+
961
+ function selectRange(a, b) {
962
+ stopPlay();
963
+ const lo = Math.min(a, b);
964
+ const hi = Math.max(a, b);
965
+ state.selection = new Set();
966
+ for (let i = lo; i <= hi; i++) state.selection.add(i);
967
+ setIndex(b);
968
+ updateFrameStates();
969
+ updateSelectionUI();
970
+ }
971
+
972
+ /** Sorted array of the currently selected frame indices. */
973
+ function selectedIndices() {
974
+ return Array.from(state.selection).sort((a, b) => a - b);
975
+ }
976
+
977
+ /* ------------------------------------------------------ marquee select */
978
+
979
+ /**
980
+ * Shift + drag across the track brush-selects every frame the pointer sweeps
981
+ * over, deliberately bypassing the per-frame resize handles so frames stay
982
+ * selectable when zoomed out (where each block is mostly handle). A shift+click
983
+ * with no drag instead extends the selection from the previously-anchored frame
984
+ * to the one clicked — the classic "click one frame, scroll elsewhere, shift-
985
+ * click another to grab the whole span" gesture — so both gestures coexist.
986
+ */
987
+ function startMarquee(e) {
988
+ stopPlay();
989
+ const trackLeft = () => els.timelineTrack.getBoundingClientRect().left;
990
+ const startIdx = frameAtX(e.clientX - trackLeft());
991
+ // Preserve the prior anchor up front: we don't yet know if this is a click or
992
+ // a drag, and a shift+CLICK must extend from the last-anchored frame. Touching
993
+ // the selection now (as the old code did via selectSingle) would clobber that
994
+ // anchor and collapse the range to a single frame.
995
+ const rangeAnchor = state.selection.size > 0 ? state.anchor : startIdx;
996
+ els.timelineScroll.classList.add('selecting');
997
+ let moved = false;
998
+ let lastEnd = startIdx;
999
+
1000
+ const onMove = (ev) => {
1001
+ const idx = frameAtX(ev.clientX - trackLeft());
1002
+ // Frame-quantised drag threshold: ignore jitter until the pointer actually
1003
+ // leaves the start frame, so a shaky shift+click isn't misread as a one-
1004
+ // frame marquee (which would drop the extend-from-anchor behaviour).
1005
+ if (!moved && idx === startIdx) return;
1006
+ if (!moved) {
1007
+ moved = true;
1008
+ state.anchor = startIdx; // a real drag re-anchors at where it began
1009
+ }
1010
+ if (idx === lastEnd) return;
1011
+ lastEnd = idx;
1012
+ selectRange(startIdx, idx); // brush from the drag origin to the pointer
1013
+ };
1014
+ const onUp = () => {
1015
+ window.removeEventListener('pointermove', onMove);
1016
+ window.removeEventListener('pointerup', onUp);
1017
+ els.timelineScroll.classList.remove('selecting');
1018
+ if (moved) {
1019
+ // A drag happened: suppress the click it spawns so it can't collapse the
1020
+ // brush selection (onBlockClick ignores clicks within 250ms of this).
1021
+ state.dragEndedAt = Date.now();
1022
+ } else {
1023
+ // No drag → shift+click range-select from the preserved anchor to here.
1024
+ // Done here (not left to onBlockClick) so it also works when the pointer
1025
+ // up lands in a gap between blocks; the duplicate block click is swallowed
1026
+ // via dragEndedAt. The anchor stays put so further shift+clicks re-extend.
1027
+ selectRange(rangeAnchor, startIdx);
1028
+ state.anchor = rangeAnchor;
1029
+ state.dragEndedAt = Date.now();
1030
+ }
1031
+ };
1032
+ window.addEventListener('pointermove', onMove);
1033
+ window.addEventListener('pointerup', onUp);
1034
+ }
1035
+
1036
+ /** Toolbar selection group: visibility, the length input, and its label. */
1037
+ function updateSelectionUI() {
1038
+ const sel = selectedIndices();
1039
+ els.selGroup.classList.toggle('hidden', sel.length === 0);
1040
+ updateFrameLabel();
1041
+ if (sel.length === 0) return;
1042
+
1043
+ const eff = effectiveDurations();
1044
+ const vals = sel.map((i) => Math.round(eff[i]));
1045
+ const allSame = vals.every((v) => v === vals[0]);
1046
+ if (document.activeElement !== els.selLen) {
1047
+ els.selLen.value = allSame ? String(vals[0]) : '';
1048
+ els.selLen.placeholder = allSame ? '' : 'multiple';
1049
+ }
1050
+ els.selLenLabel.textContent = sel.length > 1 ? `Length ×${sel.length}` : 'Length';
1051
+
1052
+ const anySkipped = sel.some((i) => isSkipped(i));
1053
+ els.selDelete.textContent = anySkipped ? 'Restore' : 'Delete';
1054
+ }
1055
+
1056
+ /* ------------------------------------------------------- frame editing */
1057
+
1058
+ function setOverrideFor(i, ms) {
1059
+ state.settings.overrides[String(i)] = Math.max(0, Math.min(600000, Math.round(ms)));
1060
+ }
1061
+
1062
+ /** Apply a single length (ms) to every selected frame as an override. */
1063
+ function applyLengthToSelection(ms) {
1064
+ const sel = selectedIndices();
1065
+ if (sel.length === 0 || !isFinite(ms)) return;
1066
+ for (const i of sel) setOverrideFor(i, ms);
1067
+ updateAnimUI();
1068
+ saveAnimationSettings();
1069
+ }
1070
+
1071
+ /** Clear duration overrides on the selection (back to captured timing). */
1072
+ function resetSelection() {
1073
+ for (const i of selectedIndices()) delete state.settings.overrides[String(i)];
1074
+ updateAnimUI();
1075
+ saveAnimationSettings();
1076
+ }
1077
+
1078
+ /** Toggle skip on the selection: skip all, or restore if any are already skipped. */
1079
+ function toggleSkipSelection() {
1080
+ const sel = selectedIndices();
1081
+ if (sel.length === 0) return;
1082
+ const anySkipped = sel.some((i) => isSkipped(i));
1083
+ for (const i of sel) {
1084
+ if (anySkipped) state.skipped.delete(i);
1085
+ else state.skipped.add(i);
1086
+ }
1087
+ state.settings.skipped = Array.from(state.skipped).sort((a, b) => a - b);
1088
+ stopPlay();
1089
+ updateAnimUI();
1090
+ saveAnimationSettings();
1091
+ }
1092
+
1093
+ /* ------------------------------------------------------------- zoom */
1094
+
1095
+ /**
1096
+ * Re-zoom the timeline, keeping one focal point fixed on screen so the content
1097
+ * appears to scale around it. Pass either:
1098
+ * - { anchorPx, anchorScreenX }: pin the track px `anchorPx` (measured under
1099
+ * the CURRENT zoom) at `anchorScreenX` px from the viewport's left edge.
1100
+ * Used to anchor on the playhead (slider) or the cursor (wheel/pinch).
1101
+ * - { anchorRatio }: keep the time under a viewport-relative point fixed.
1102
+ */
1103
+ function setZoom(pps, { anchorRatio = null, anchorPx = null, anchorScreenX = null } = {}) {
1104
+ const scroll = els.timelineScroll;
1105
+ const oldZoom = state.zoom;
1106
+ // Resolve the focal point to a (trackPx, screenX) pair under the old zoom.
1107
+ let trackPx = null;
1108
+ let screenX = null;
1109
+ if (anchorPx != null) {
1110
+ trackPx = anchorPx;
1111
+ screenX = anchorScreenX != null ? anchorScreenX : anchorPx - scroll.scrollLeft;
1112
+ } else if (anchorRatio != null) {
1113
+ screenX = scroll.clientWidth * anchorRatio;
1114
+ trackPx = scroll.scrollLeft + screenX;
1115
+ }
1116
+ const focusTime = trackPx != null && oldZoom > 0 ? trackPx / oldZoom : null;
1117
+
1118
+ state.zoom = clampPps(pps);
1119
+ els.zoom.value = String(ppsToSlider(state.zoom));
1120
+ layoutTimeline();
1121
+
1122
+ if (focusTime != null) {
1123
+ scroll.scrollLeft = Math.max(0, focusTime * state.zoom - screenX);
1124
+ updatePreviews();
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Zoom focal point that keeps the playhead pinned where it currently sits on
1130
+ * screen, so slider zoom stays centred on the scrub position. Falls back to the
1131
+ * viewport centre when the playhead has been scrolled out of view.
1132
+ */
1133
+ function playheadZoomAnchor() {
1134
+ const scroll = els.timelineScroll;
1135
+ const b = state.boundaries;
1136
+ if (!b || b.length < 2 || !(state.zoom > 0)) return { anchorRatio: 0.5 };
1137
+ const px = b[state.index];
1138
+ const screenX = px - scroll.scrollLeft;
1139
+ if (screenX < 0 || screenX > scroll.clientWidth) {
1140
+ return { anchorPx: px, anchorScreenX: scroll.clientWidth / 2 };
1141
+ }
1142
+ return { anchorPx: px, anchorScreenX: screenX };
1143
+ }
1144
+
1145
+ // Pinch (trackpad) and ⌘/Ctrl + wheel over the timeline zoom toward the cursor.
1146
+ const ZOOM_WHEEL_SENS = 0.0025;
1147
+ function onTimelineWheel(e) {
1148
+ if (state.frames.length === 0) return;
1149
+ if (!(e.ctrlKey || e.metaKey)) return; // plain scroll stays horizontal scroll
1150
+ e.preventDefault();
1151
+ const scroll = els.timelineScroll;
1152
+ let dy = e.deltaY;
1153
+ if (e.deltaMode === 1) dy *= 16; // lines → ~px
1154
+ else if (e.deltaMode === 2) dy *= scroll.clientHeight; // pages → ~px
1155
+ const cur = state.zoom > 0 ? state.zoom : sliderToPps(Number(els.zoom.value));
1156
+ const next = clampPps(cur * Math.exp(-dy * ZOOM_WHEEL_SENS));
1157
+ if (next === cur) return;
1158
+ const screenX = e.clientX - scroll.getBoundingClientRect().left;
1159
+ setZoom(next, { anchorPx: scroll.scrollLeft + screenX, anchorScreenX: screenX });
1160
+ }
1161
+
1162
+ /** Set zoom so the whole timeline fits the visible width. */
1163
+ function fitTimeline() {
1164
+ const eff = effectiveDurations();
1165
+ const totalSec = eff.reduce((a, d) => a + Math.max(0, d), 0) / 1000;
1166
+ const w = Math.max(40, els.timelineScroll.clientWidth - 4);
1167
+ const pps = totalSec > 0 ? w / totalSec : MAX_PPS;
1168
+ els.timelineScroll.scrollLeft = 0;
1169
+ setZoom(pps);
1170
+ }
1171
+
1172
+ /* ------------------------------------------------------- drag to resize */
1173
+
1174
+ /**
1175
+ * Drag a block's right edge to change that single frame's length. Converts the
1176
+ * horizontal drag distance to ms via the current zoom and writes it as a
1177
+ * duration override (overrides bypass the cap/speed — an explicit user length).
1178
+ */
1179
+ function startResize(e, i) {
1180
+ if (e.shiftKey) return; // shift = marquee select, not resize (see startMarquee)
1181
+ e.preventDefault();
1182
+ e.stopPropagation();
1183
+ stopPlay();
1184
+ // Grabbing a frame's edge makes it the active single selection so the header
1185
+ // length readout tracks the frame being resized.
1186
+ if (!(state.selection.size === 1 && state.selection.has(i))) selectSingle(i);
1187
+ const eff = effectiveDurations();
1188
+ const startMs = eff[i];
1189
+ const startX = e.clientX;
1190
+ const pps = state.zoom > 0 ? state.zoom : sliderToPps(0);
1191
+ els.timelineScroll.classList.add('dragging');
1192
+ let moved = false;
1193
+
1194
+ const onMove = (ev) => {
1195
+ const deltaMs = ((ev.clientX - startX) / pps) * 1000;
1196
+ if (Math.abs(ev.clientX - startX) > 2) moved = true;
1197
+ setOverrideFor(i, Math.max(0, startMs + deltaMs));
1198
+ layoutTimeline();
1199
+ updateFrameStates();
1200
+ updateSelectionUI();
1201
+ };
1202
+ const onUp = () => {
1203
+ window.removeEventListener('pointermove', onMove);
1204
+ window.removeEventListener('pointerup', onUp);
1205
+ els.timelineScroll.classList.remove('dragging');
1206
+ // Mark the drag end so the click it may spawn is ignored (see onBlockClick).
1207
+ if (moved) state.dragEndedAt = Date.now();
1208
+ saveAnimationSettings();
1209
+ };
1210
+ window.addEventListener('pointermove', onMove);
1211
+ window.addEventListener('pointerup', onUp);
1212
+ }
1213
+
1214
+ function clearPreview() {
1215
+ stopPlay();
1216
+ state.frames = [];
1217
+ state.index = 0;
1218
+ state.hasTiming = false;
1219
+ state.animationEnd = 0;
1220
+ state.animCanvas = null;
1221
+ state.blocks = [];
1222
+ state.boundaries = [];
1223
+ state.selection = new Set();
1224
+ state.skipped = new Set();
1225
+ els.preview.classList.add('hidden');
1226
+ els.preview.removeAttribute('src');
1227
+ els.previewEmpty.classList.remove('hidden');
1228
+ els.frameLabel.textContent = '—';
1229
+ els.timelineTrack.innerHTML = '';
1230
+ els.timelineRuler.innerHTML = '';
1231
+ els.timelineTrack.style.width = '';
1232
+ els.timelineRuler.style.width = '';
1233
+ els.timelineEmpty.classList.remove('hidden');
1234
+ els.selGroup.classList.add('hidden');
1235
+ els.downloadBtn.disabled = true;
1236
+ els.downloadPngBtn.disabled = true;
1237
+ els.pngScale.disabled = true;
1238
+ els.playBtn.disabled = true;
1239
+ els.exportMp4Btn.disabled = true;
1240
+ if (els.selfTestBtn) els.selfTestBtn.disabled = true;
1241
+ els.goStartBtn.disabled = true;
1242
+ els.prevBtn.disabled = true;
1243
+ els.nextBtn.disabled = true;
1244
+ els.goEndBtn.disabled = true;
1245
+ els.zoom.disabled = true;
1246
+ els.fitBtn.disabled = true;
1247
+ // Reset the canvas back to Zoom-to-Fit and disable the scale controls.
1248
+ state.viewMode = 'fit';
1249
+ state.zoomScale = 1;
1250
+ els.previewWrap.classList.remove('scaled');
1251
+ els.preview.style.width = '';
1252
+ els.preview.style.height = '';
1253
+ els.zoomFitBtn.setAttribute('aria-pressed', 'true');
1254
+ els.zoom100Btn.setAttribute('aria-pressed', 'false');
1255
+ els.zoomFitBtn.disabled = true;
1256
+ els.zoom100Btn.disabled = true;
1257
+ els.zoomRange.disabled = true;
1258
+ els.zoomRange.value = '100';
1259
+ els.zoomPct.textContent = '100%';
1260
+ els.recordedSize.textContent = '—';
1261
+ els.animLen.textContent = '—';
1262
+ els.animStatus.textContent = '';
1263
+ els.animNote.classList.add('hidden');
1264
+ els.clearOverrides.classList.add('hidden');
1265
+
1266
+ // Tear down any content-edit overlay/popover for the (now absent) recording.
1267
+ ed.cells = null;
1268
+ closePopover();
1269
+ edEls.overlay.classList.add('hidden');
1270
+ edEls.hover.classList.add('hidden');
1271
+ edEls.section.classList.add('hidden');
1272
+ }
1273
+
1274
+ /* ------------------------------------------------------- canvas view scale */
1275
+
1276
+ // Canvas zoom bounds (factors). The slider is expressed in whole percent.
1277
+ const ZOOM_MIN = 0.1;
1278
+ const ZOOM_MAX = 4;
1279
+ const clampZoom = (s) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, s));
1280
+
1281
+ /** Natural (1:1) pixel size of the rendered canvas, or null until it loads. */
1282
+ function previewBaseSize() {
1283
+ const img = els.preview;
1284
+ const w = img.naturalWidth;
1285
+ const h = img.naturalHeight;
1286
+ return w && h ? { w, h } : null;
1287
+ }
1288
+
1289
+ /** The scale Zoom-to-Fit resolves to (how `contain` sizes the canvas; ≤ 1). */
1290
+ function computeFitScale() {
1291
+ const base = previewBaseSize();
1292
+ if (!base) return 1;
1293
+ const wrap = els.previewWrap;
1294
+ const cs = getComputedStyle(wrap);
1295
+ const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight);
1296
+ const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
1297
+ const availW = wrap.clientWidth - padX;
1298
+ const availH = wrap.clientHeight - padY;
1299
+ if (availW <= 0 || availH <= 0) return 1;
1300
+ return Math.min(availW / base.w, availH / base.h, 1);
1301
+ }
1302
+
1303
+ /** The scale currently shown on the canvas (explicit zoom, or the fit scale). */
1304
+ function currentScale() {
1305
+ return state.viewMode === 'zoom' ? state.zoomScale : computeFitScale();
1306
+ }
1307
+
1308
+ /** Sync the zoom slider + percentage label to the active scale. */
1309
+ function updateZoomReadout() {
1310
+ const pct = Math.round(currentScale() * 100);
1311
+ els.zoomPct.textContent = `${pct}%`;
1312
+ // Don't fight the user mid-drag; the slider is the source of truth then.
1313
+ if (document.activeElement !== els.zoomRange) {
1314
+ const clamped = Math.round(clampZoom(pct / 100) * 100);
1315
+ els.zoomRange.value = String(clamped);
1316
+ }
1317
+ }
1318
+
1319
+ /** Reflect the active scale mode on the canvas, header buttons, and slider. */
1320
+ function applyViewMode() {
1321
+ const zoom = state.viewMode === 'zoom';
1322
+ els.previewWrap.classList.toggle('scaled', zoom);
1323
+ const img = els.preview;
1324
+ if (zoom) {
1325
+ const base = previewBaseSize();
1326
+ if (base) {
1327
+ img.style.width = `${Math.round(base.w * state.zoomScale)}px`;
1328
+ img.style.height = `${Math.round(base.h * state.zoomScale)}px`;
1329
+ }
1330
+ } else {
1331
+ img.style.width = '';
1332
+ img.style.height = '';
1333
+ }
1334
+ els.zoomFitBtn.setAttribute('aria-pressed', zoom ? 'false' : 'true');
1335
+ els.zoom100Btn.setAttribute('aria-pressed', zoom && Math.abs(state.zoomScale - 1) < 1e-3 ? 'true' : 'false');
1336
+ updateZoomReadout();
1337
+ // Keep the content-edit overlay aligned with the resized image.
1338
+ if (ed.on) positionOverlay();
1339
+ }
1340
+
1341
+ /** Zoom-to-Fit: let the canvas shrink to fit the viewport (no scroll). */
1342
+ function fitCanvas() {
1343
+ state.viewMode = 'fit';
1344
+ applyViewMode();
1345
+ }
1346
+
1347
+ /** Zoom to an explicit factor, keeping the viewport centre stable. */
1348
+ function setCanvasZoom(scale, { center = false } = {}) {
1349
+ state.zoomScale = clampZoom(scale);
1350
+ state.viewMode = 'zoom';
1351
+ const wrap = els.previewWrap;
1352
+ // Remember the content point under the viewport centre so the zoom feels
1353
+ // anchored there rather than jumping to the top-left.
1354
+ const cx = (wrap.scrollLeft + wrap.clientWidth / 2) / Math.max(1, wrap.scrollWidth);
1355
+ const cy = (wrap.scrollTop + wrap.clientHeight / 2) / Math.max(1, wrap.scrollHeight);
1356
+ applyViewMode();
1357
+ if (center) {
1358
+ wrap.scrollLeft = Math.max(0, (wrap.scrollWidth - wrap.clientWidth) / 2);
1359
+ wrap.scrollTop = Math.max(0, (wrap.scrollHeight - wrap.clientHeight) / 2);
1360
+ } else {
1361
+ wrap.scrollLeft = cx * wrap.scrollWidth - wrap.clientWidth / 2;
1362
+ wrap.scrollTop = cy * wrap.scrollHeight - wrap.clientHeight / 2;
1363
+ }
1364
+ }
1365
+
1366
+ /** Scale controls are usable only while a frame is on the canvas. */
1367
+ function updateViewScaleButtons() {
1368
+ const has = state.frames.length > 0;
1369
+ els.zoomFitBtn.disabled = !has;
1370
+ els.zoom100Btn.disabled = !has;
1371
+ els.zoomRange.disabled = !has;
1372
+ }
1373
+
1374
+ /** Re-fetch images after a render-only option change (preview + mounted blocks). */
1375
+ function refreshImages() {
1376
+ if (state.frames.length === 0) return;
1377
+ els.preview.src = svgUrlFull(state.index);
1378
+ state.blocks.forEach((blk, i) => {
1379
+ if (blk.img) blk.img.src = svgUrl(i);
1380
+ });
1381
+ if (ed.on) {
1382
+ // Geometry (font size, chrome, spacing) may have changed — re-measure cells.
1383
+ fetchCells(state.index).then(positionOverlay);
1384
+ }
1385
+ }
1386
+
1387
+ /* ----------------------------------------------------------------- export */
1388
+
1389
+ /**
1390
+ * Save a blob to disk. Prefers the File System Access "Save As" dialog so the
1391
+ * user chooses the folder and filename; falls back to a normal anchor download
1392
+ * (the browser's default location) where that API isn't available, e.g. Firefox
1393
+ * or Safari. Returns false only when the user explicitly cancels the dialog.
1394
+ */
1395
+ async function saveBlob(blob, suggestedName, types) {
1396
+ if (window.showSaveFilePicker) {
1397
+ try {
1398
+ const handle = await window.showSaveFilePicker({ suggestedName, types });
1399
+ const writable = await handle.createWritable();
1400
+ await writable.write(blob);
1401
+ await writable.close();
1402
+ return true;
1403
+ } catch (e) {
1404
+ if (e && e.name === 'AbortError') return false; // user cancelled
1405
+ // Any other failure (permissions, etc.) falls through to a plain download.
1406
+ }
1407
+ }
1408
+ triggerDownload(blob, suggestedName);
1409
+ return true;
1410
+ }
1411
+
1412
+ /** Fetch the current frame's SVG and save it via the Save As dialog. */
1413
+ async function downloadSvg() {
1414
+ if (!state.name) return;
1415
+ els.downloadBtn.disabled = true;
1416
+ try {
1417
+ const res = await fetch(svgUrlFull(state.index));
1418
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
1419
+ const svgText = await res.text();
1420
+ const blob = new Blob([svgText], { type: 'image/svg+xml' });
1421
+ const filename = `${exportBaseName()}.svg`;
1422
+ const saved = await saveBlob(blob, filename, [
1423
+ { description: 'SVG image', accept: { 'image/svg+xml': ['.svg'] } },
1424
+ ]);
1425
+ if (saved) toast(`Saved ${filename}`);
1426
+ } catch (e) {
1427
+ toast(`SVG export failed: ${e.message}`);
1428
+ } finally {
1429
+ els.downloadBtn.disabled = state.frames.length === 0;
1430
+ }
1431
+ }
1432
+
1433
+ /** Filesystem-safe base name for an export, e.g. "copilot-capture_12-frame64". */
1434
+ function exportBaseName() {
1435
+ const stem = (state.name || 'capture').replace(/\.ans$/i, '');
1436
+ return `${stem}-frame${state.index + 1}`;
1437
+ }
1438
+
1439
+ function triggerDownload(blob, filename) {
1440
+ const url = URL.createObjectURL(blob);
1441
+ const a = document.createElement('a');
1442
+ a.href = url;
1443
+ a.download = filename;
1444
+ document.body.appendChild(a);
1445
+ a.click();
1446
+ a.remove();
1447
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1448
+ }
1449
+
1450
+ /**
1451
+ * Rasterize the current frame's SVG to a PNG entirely in the browser, at the
1452
+ * chosen pixel scale, and download it. Going through the same SVG the preview
1453
+ * shows keeps the PNG byte-for-byte consistent with what's on screen; the SVG
1454
+ * references only system monospace fonts, so an offscreen <canvas> renders it
1455
+ * faithfully without tainting (same-origin, no external resources).
1456
+ */
1457
+ async function downloadPng() {
1458
+ if (!state.name) return;
1459
+ const scale = Number(els.pngScale.value) || 2;
1460
+ els.downloadPngBtn.disabled = true;
1461
+ let blobUrl;
1462
+ try {
1463
+ const res = await fetch(svgUrlFull(state.index));
1464
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
1465
+ const svgText = await res.text();
1466
+ blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
1467
+
1468
+ const img = new Image();
1469
+ await new Promise((resolve, reject) => {
1470
+ img.onload = resolve;
1471
+ img.onerror = () => reject(new Error('could not rasterize the SVG'));
1472
+ img.src = blobUrl;
1473
+ });
1474
+
1475
+ // Prefer the SVG's declared px dimensions; fall back to the loaded image.
1476
+ const dims = svgText.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"[^>]*\bheight="(\d+(?:\.\d+)?)"/);
1477
+ const w = dims ? Number(dims[1]) : img.naturalWidth;
1478
+ const h = dims ? Number(dims[2]) : img.naturalHeight;
1479
+ if (!w || !h) throw new Error('unknown SVG size');
1480
+
1481
+ const canvas = document.createElement('canvas');
1482
+ canvas.width = Math.round(w * scale);
1483
+ canvas.height = Math.round(h * scale);
1484
+ const ctx = canvas.getContext('2d');
1485
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1486
+
1487
+ const pngBlob = await new Promise((resolve, reject) => {
1488
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('PNG encode failed'))), 'image/png');
1489
+ });
1490
+ const filename = `${exportBaseName()}@${scale}x.png`;
1491
+ const saved = await saveBlob(pngBlob, filename, [
1492
+ { description: 'PNG image', accept: { 'image/png': ['.png'] } },
1493
+ ]);
1494
+ if (saved) toast(`Saved ${filename} (${scale}×, ${canvas.width}×${canvas.height})`);
1495
+ } catch (e) {
1496
+ toast(`PNG export failed: ${e.message}`);
1497
+ } finally {
1498
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
1499
+ els.downloadPngBtn.disabled = state.frames.length === 0;
1500
+ }
1501
+ }
1502
+
1503
+ /* --------------------------------------------------------- animation */
1504
+
1505
+ /** Manual navigation that should interrupt playback and update the selection. */
1506
+ function userSeek(i) {
1507
+ if (state.frames.length === 0) return;
1508
+ selectSingle(Math.max(0, Math.min(state.frames.length - 1, i)));
1509
+ }
1510
+
1511
+ async function loadAnimationSettings() {
1512
+ const fallback = { fps: 30, scale: 2, idleCapMs: 1500, speed: 1, overrides: {}, skipped: [], smoothTyping: false, typingCps: 30 };
1513
+ let s = fallback;
1514
+ try {
1515
+ const data = await apiJson(`/api/recordings/${encodeURIComponent(state.name)}/animation`);
1516
+ if (data && data.settings) s = data.settings;
1517
+ } catch {
1518
+ /* keep fallback */
1519
+ }
1520
+ state.settings = {
1521
+ fps: s.fps ?? 30,
1522
+ scale: s.scale ?? 2,
1523
+ idleCapMs: s.idleCapMs ?? 1500,
1524
+ speed: s.speed ?? 1,
1525
+ overrides: s.overrides && typeof s.overrides === 'object' ? { ...s.overrides } : {},
1526
+ skipped: Array.isArray(s.skipped) ? s.skipped.slice() : [],
1527
+ smoothTyping: typeof s.smoothTyping === 'boolean' ? s.smoothTyping : false,
1528
+ typingCps: s.typingCps ?? 30,
1529
+ };
1530
+ state.skipped = new Set(state.settings.skipped);
1531
+ applySettingsToControls();
1532
+ }
1533
+
1534
+ const saveAnimationSettings = debounce(async () => {
1535
+ if (!state.name) return;
1536
+ try {
1537
+ await fetch(`/api/recordings/${encodeURIComponent(state.name)}/animation`, {
1538
+ method: 'PUT',
1539
+ headers: { 'content-type': 'application/json' },
1540
+ body: JSON.stringify(state.settings),
1541
+ });
1542
+ } catch {
1543
+ /* settings autosave is best-effort */
1544
+ }
1545
+ }, 400);
1546
+
1547
+ function applySettingsToControls() {
1548
+ const s = state.settings;
1549
+ els.animFps.value = String(s.fps);
1550
+ els.animScale.value = String(s.scale);
1551
+ els.idleCap.value = String(idleCapToSlider(s.idleCapMs));
1552
+ els.idleCapVal.textContent = fmtIdleCap(s.idleCapMs);
1553
+ els.speed.value = String(s.speed);
1554
+ els.speedVal.textContent = `${Number(s.speed).toFixed(2).replace(/0$/, '')}×`;
1555
+ els.smoothTyping.checked = !!s.smoothTyping;
1556
+ els.typingCps.value = String(s.typingCps);
1557
+ els.typingCpsVal.textContent = `${s.typingCps} cps`;
1558
+ els.typingCps.disabled = !s.smoothTyping;
1559
+ }
1560
+
1561
+ /** Reset all per-frame edits: duration overrides AND skips. */
1562
+ function clearAllOverrides() {
1563
+ state.settings.overrides = {};
1564
+ state.skipped = new Set();
1565
+ state.settings.skipped = [];
1566
+ updateAnimUI();
1567
+ saveAnimationSettings();
1568
+ }
1569
+
1570
+ /** Per-block visual states + the animation readouts (length, note, status). */
1571
+ function updateFrameStates() {
1572
+ if (state.frames.length === 0) return;
1573
+ const overrides = state.settings.overrides;
1574
+ let overrideCount = 0;
1575
+ state.blocks.forEach((blk, i) => {
1576
+ const has = Object.prototype.hasOwnProperty.call(overrides, String(i));
1577
+ if (has) overrideCount++;
1578
+ blk.el.classList.toggle('overridden', has);
1579
+ blk.el.classList.toggle('excluded', i > state.animationEnd);
1580
+ blk.el.classList.toggle('skipped', isSkipped(i));
1581
+ blk.el.classList.toggle('selected', state.selection.has(i));
1582
+ blk.el.classList.toggle('current', i === state.index);
1583
+ });
1584
+
1585
+ const eff = effectiveDurations();
1586
+ const range = animRange();
1587
+ const totalMs = range.reduce((sum, i) => sum + eff[i], 0);
1588
+ els.animLen.textContent = `${(totalMs / 1000).toFixed(1)}s · ${range.length} frame${range.length === 1 ? '' : 's'}`;
1589
+ els.clearOverrides.classList.toggle('hidden', overrideCount === 0 && state.skipped.size === 0);
1590
+
1591
+ if (!state.hasTiming) {
1592
+ els.animNote.textContent = `No timing captured — using a uniform ${UNIFORM_FRAME_MS / 1000}s per frame. Re-record to capture real timing.`;
1593
+ els.animNote.classList.remove('hidden');
1594
+ } else {
1595
+ els.animNote.classList.add('hidden');
1596
+ }
1597
+ els.animStatus.textContent = state.settings.scale ? `${state.settings.scale}× · ${state.settings.fps}fps` : '';
1598
+ }
1599
+
1600
+ /** Full refresh after a timing/zoom/skip change: re-layout, re-state, re-select. */
1601
+ function updateAnimUI() {
1602
+ if (state.frames.length === 0) return;
1603
+ layoutTimeline();
1604
+ updateFrameStates();
1605
+ updateSelectionUI();
1606
+ }
1607
+
1608
+ /* ------------------------------------------------------ play controller */
1609
+
1610
+ /** Format ms compactly: 38ms / 1.24s. */
1611
+ function fmtMs(ms) {
1612
+ if (!isFinite(ms)) return '—';
1613
+ const a = Math.abs(ms);
1614
+ if (a >= 1000) return `${(ms / 1000).toFixed(2)}s`;
1615
+ return `${Math.round(ms)}ms`;
1616
+ }
1617
+
1618
+ /** Signed ms for the drift line: +123ms / -40ms / 0ms. */
1619
+ function fmtSigned(ms) {
1620
+ if (!isFinite(ms)) return '—';
1621
+ const r = Math.round(ms);
1622
+ return `${r > 0 ? '+' : ''}${r}ms`;
1623
+ }
1624
+
1625
+ /**
1626
+ * Paint the live timing HUD from the current telemetry. Null-guarded so the play
1627
+ * loop can call it whether or not the HUD markup is present / enabled. `rec` is
1628
+ * the frame that just swapped in (null when playback stops).
1629
+ */
1630
+ function renderHud(rec) {
1631
+ const hud = document.getElementById('hud');
1632
+ if (!hud || hud.classList.contains('hidden')) return;
1633
+ const tel = play.telemetry;
1634
+ const set = (id, txt) => {
1635
+ const el = document.getElementById(id);
1636
+ if (el) el.textContent = txt;
1637
+ };
1638
+
1639
+ set('hudState', play.on ? '● playing' : '⏸ paused');
1640
+ const hs = document.getElementById('hudState');
1641
+ if (hs) hs.classList.toggle('live', play.on);
1642
+
1643
+ if (!tel || tel.records.length === 0) {
1644
+ ['hudFrame', 'hudThis', 'hudActual', 'hudFps', 'hudElapsed', 'hudDrift'].forEach((id) =>
1645
+ set(id, '—'),
1646
+ );
1647
+ } else {
1648
+ const recs = tel.records;
1649
+ const cur = rec || recs[recs.length - 1];
1650
+ const total = tel.rangeLen || recs.length;
1651
+ set('hudFrame', `#${cur.idx} (${cur.pos % total}/${total})`);
1652
+ set('hudThis', `raw ${fmtMs(cur.raw)} · eff ${fmtMs(cur.eff)} · hold ${fmtMs(cur.hold)}`);
1653
+
1654
+ const done = recs.filter((r) => r.shownMs != null);
1655
+ const last = done[done.length - 1];
1656
+ set('hudActual', last ? `${fmtMs(last.shownMs)} (target ${fmtMs(last.hold)})` : '—');
1657
+
1658
+ // Rolling FPS over the last 12 completed frames.
1659
+ const win = done.slice(-12);
1660
+ const winMs = win.reduce((a, r) => a + r.shownMs, 0);
1661
+ set('hudFps', win.length && winMs > 0 ? `${((win.length / winMs) * 1000).toFixed(1)} fps` : '—');
1662
+
1663
+ const actual = done.reduce((a, r) => a + r.shownMs, 0);
1664
+ const expected = done.reduce((a, r) => a + r.hold, 0);
1665
+ set('hudElapsed', `${fmtMs(actual)} actual / ${fmtMs(expected)} target`);
1666
+ const drift = actual - expected;
1667
+ set('hudDrift', `${fmtSigned(drift)}${expected > 0 ? ` (${((drift / expected) * 100).toFixed(1)}%)` : ''}`);
1668
+ const hd = document.getElementById('hudDrift');
1669
+ if (hd) hd.classList.toggle('warn', Math.abs(drift) > 250 && expected > 0);
1670
+ }
1671
+
1672
+ const s = state.settings;
1673
+ set(
1674
+ 'hudSettings',
1675
+ `speed ${s.speed}× · idle-cap ${fmtIdleCap(s.idleCapMs)} · ${s.fps}fps · floor ${PLAYBACK_MIN_MS}ms`,
1676
+ );
1677
+ }
1678
+
1679
+ const play = {
1680
+ on: false,
1681
+ timer: null,
1682
+ raf: null,
1683
+ telemetry: null, // per-run frame timing log, read by the HUD + window.__timing
1684
+ stopAtEnd: false, // self-test: stop after one full pass instead of looping
1685
+ onComplete: null,
1686
+ };
1687
+
1688
+ function updatePlayBtn() {
1689
+ els.playBtn.innerHTML = play.on ? ICON.pause : ICON.play;
1690
+ els.playBtn.classList.toggle('playing', play.on);
1691
+ els.playBtn.title = play.on ? 'Pause (space)' : 'Play animation (space)';
1692
+ els.playBtn.setAttribute('aria-label', play.on ? 'Pause' : 'Play animation');
1693
+ }
1694
+
1695
+ /** Finalize the on-screen duration of the frame currently showing (called when
1696
+ * the next frame swaps in, and when playback stops). */
1697
+ function closeTelemetryFrame(now) {
1698
+ const tel = play.telemetry;
1699
+ if (!tel || tel.records.length === 0) return;
1700
+ const last = tel.records[tel.records.length - 1];
1701
+ if (last.shownMs == null) last.shownMs = now - last.shownAt;
1702
+ }
1703
+
1704
+ /** Aggregate the recorded run into the numbers the HUD + tests assert on. */
1705
+ function timingSummary(tel) {
1706
+ if (!tel) return null;
1707
+ const recs = tel.records.filter((r) => r.shownMs != null);
1708
+ const n = recs.length;
1709
+ const actualMs = recs.reduce((a, r) => a + r.shownMs, 0);
1710
+ const expectedMs = recs.reduce((a, r) => a + r.hold, 0); // floored playback target
1711
+ const readoutMs = recs.reduce((a, r) => a + r.eff, 0); // raw effective (the length readout)
1712
+ let worst = 0;
1713
+ for (const r of recs) {
1714
+ const d = r.shownMs - r.hold;
1715
+ if (Math.abs(d) > Math.abs(worst)) worst = d;
1716
+ }
1717
+ return {
1718
+ frames: n,
1719
+ actualMs,
1720
+ expectedMs,
1721
+ readoutMs,
1722
+ driftMs: actualMs - expectedMs,
1723
+ worstFrameMs: worst,
1724
+ fps: actualMs > 0 ? (n / actualMs) * 1000 : 0,
1725
+ loops: tel.loops,
1726
+ settings: {
1727
+ idleCapMs: state.settings.idleCapMs,
1728
+ speed: state.settings.speed,
1729
+ fps: state.settings.fps,
1730
+ },
1731
+ };
1732
+ }
1733
+
1734
+ async function startPlay() {
1735
+ if (play.on || state.frames.length === 0) return;
1736
+ const range = animRange();
1737
+ if (range.length === 0) return;
1738
+ play.on = true;
1739
+ updatePlayBtn();
1740
+
1741
+ // Preload the untrimmed frames so swaps during playback are instant.
1742
+ await Promise.all(
1743
+ range.map(
1744
+ (i) =>
1745
+ new Promise((res) => {
1746
+ const im = new Image();
1747
+ im.onload = im.onerror = res;
1748
+ im.src = svgUrlFull(i);
1749
+ }),
1750
+ ),
1751
+ );
1752
+ if (!play.on) return; // stopped while preloading
1753
+
1754
+ const eff = effectiveDurations();
1755
+ play.telemetry = {
1756
+ records: [],
1757
+ loops: 0,
1758
+ startedAt: 0,
1759
+ fps: state.settings.fps,
1760
+ rangeLen: range.length,
1761
+ };
1762
+ let k = range.indexOf(state.index);
1763
+ if (k < 0) k = 0;
1764
+
1765
+ // Deadline-based scheduling: each frame has an absolute deadline measured from
1766
+ // the pass start (Σ holds so far). The next timer sleeps only until that
1767
+ // deadline, so a slow render/swap eats into the FOLLOWING delay instead of
1768
+ // pushing the whole timeline out — cumulative drift can't accumulate. (The old
1769
+ // loop re-armed setTimeout(hold) after each swap, so every render's latency
1770
+ // compounded and playback ran progressively slow.)
1771
+ let playStart = 0;
1772
+ let targetElapsed = 0;
1773
+
1774
+ const step = () => {
1775
+ if (!play.on) return;
1776
+ const now = performance.now();
1777
+ closeTelemetryFrame(now);
1778
+ if (playStart === 0) {
1779
+ playStart = now;
1780
+ play.telemetry.startedAt = now;
1781
+ }
1782
+ const idx = range[k % range.length];
1783
+ setIndex(idx);
1784
+
1785
+ const eMs = Math.max(0, eff[idx]);
1786
+ const hold = playbackHold(eMs);
1787
+ const rec = {
1788
+ pos: play.telemetry.records.length,
1789
+ idx,
1790
+ raw: rawDurationMs(idx),
1791
+ eff: eMs,
1792
+ hold,
1793
+ shownAt: now,
1794
+ shownMs: null,
1795
+ };
1796
+ play.telemetry.records.push(rec);
1797
+ renderHud(rec);
1798
+
1799
+ // Animate the playhead across this block for the duration of its hold.
1800
+ const b = state.boundaries;
1801
+ const from = b && b.length > idx ? b[idx] : 0;
1802
+ const to = b && b.length > idx + 1 ? b[idx + 1] : from;
1803
+ const startT = now;
1804
+ const tick = (t) => {
1805
+ if (!play.on) return;
1806
+ const frac = Math.min(1, (t - startT) / hold);
1807
+ positionPlayhead(from + (to - from) * frac);
1808
+ if (frac < 1) play.raf = requestAnimationFrame(tick);
1809
+ };
1810
+ cancelAnimationFrame(play.raf);
1811
+ play.raf = requestAnimationFrame(tick);
1812
+
1813
+ targetElapsed += hold;
1814
+ const delay = Math.max(0, playStart + targetElapsed - performance.now());
1815
+ play.timer = setTimeout(() => {
1816
+ k++;
1817
+ if (k >= range.length) {
1818
+ if (play.stopAtEnd) {
1819
+ const cb = play.onComplete;
1820
+ closeTelemetryFrame(performance.now());
1821
+ stopPlay();
1822
+ if (cb) cb();
1823
+ return;
1824
+ }
1825
+ // Loop: rebase the deadline clock so a long run doesn't accumulate.
1826
+ k = 0;
1827
+ playStart = 0;
1828
+ targetElapsed = 0;
1829
+ play.telemetry.loops++;
1830
+ }
1831
+ step();
1832
+ }, delay);
1833
+ };
1834
+ step();
1835
+ }
1836
+
1837
+ function stopPlay() {
1838
+ if (!play.on) return;
1839
+ play.on = false;
1840
+ closeTelemetryFrame(performance.now());
1841
+ clearTimeout(play.timer);
1842
+ play.timer = null;
1843
+ cancelAnimationFrame(play.raf);
1844
+ play.raf = null;
1845
+ positionPlayhead();
1846
+ updatePlayBtn();
1847
+ renderHud(null);
1848
+ }
1849
+
1850
+ function togglePlay() {
1851
+ if (play.on) stopPlay();
1852
+ else startPlay();
1853
+ }
1854
+
1855
+ /**
1856
+ * Play the full animation range once from the start, then resolve with the
1857
+ * measured timing summary. Backs the HUD "self-test" button and is exposed on
1858
+ * window.__timing for e2e harnesses.
1859
+ */
1860
+ function runTimingSelfTest() {
1861
+ return new Promise((resolve) => {
1862
+ if (state.frames.length === 0) {
1863
+ resolve(null);
1864
+ return;
1865
+ }
1866
+ stopPlay();
1867
+ const range = animRange();
1868
+ state.index = range.length ? range[0] : 0;
1869
+ setIndex(state.index);
1870
+ play.stopAtEnd = true;
1871
+ play.onComplete = () => {
1872
+ play.stopAtEnd = false;
1873
+ play.onComplete = null;
1874
+ resolve(timingSummary(play.telemetry));
1875
+ };
1876
+ startPlay();
1877
+ });
1878
+ }
1879
+
1880
+ /** Show or hide the timing HUD overlay; paint it immediately when enabled. */
1881
+ function setHudVisible(on) {
1882
+ if (!els.hud) return;
1883
+ els.hud.classList.toggle('hidden', !on);
1884
+ els.hud.setAttribute('aria-hidden', on ? 'false' : 'true');
1885
+ if (on) renderHud(null);
1886
+ }
1887
+
1888
+ /** Drive the HUD's "Run timing test" button: play the range once and report
1889
+ * expected-vs-actual-vs-readout so the user can see playback honors its target. */
1890
+ async function runSelfTestUI() {
1891
+ if (!els.selfTestBtn || els.selfTestBtn.disabled) return;
1892
+ const out = els.selfTestResult;
1893
+ els.selfTestBtn.disabled = true;
1894
+ const prevLabel = els.selfTestBtn.textContent;
1895
+ els.selfTestBtn.textContent = 'Running…';
1896
+ if (out) {
1897
+ out.classList.remove('hidden', 'ok', 'warn');
1898
+ out.textContent = 'Playing range once…';
1899
+ }
1900
+ // Make sure the HUD is visible so the run is watchable.
1901
+ setHudVisible(true);
1902
+ try {
1903
+ const s = await runTimingSelfTest();
1904
+ if (out) {
1905
+ if (!s || s.frames === 0) {
1906
+ out.classList.add('warn');
1907
+ out.textContent = 'No frames to test.';
1908
+ } else {
1909
+ const pct = s.expectedMs > 0 ? (s.driftMs / s.expectedMs) * 100 : 0;
1910
+ const ok = Math.abs(pct) <= 2 && Math.abs(s.driftMs) <= 250;
1911
+ out.classList.add(ok ? 'ok' : 'warn');
1912
+ out.textContent =
1913
+ `${s.frames} frames · actual ${fmtMs(s.actualMs)} vs target ${fmtMs(s.expectedMs)} ` +
1914
+ `(drift ${fmtSigned(s.driftMs)}, ${pct.toFixed(1)}%)\n` +
1915
+ `readout ${fmtMs(s.readoutMs)} · worst frame ${fmtSigned(s.worstFrameMs)} · ` +
1916
+ `${s.fps.toFixed(1)} fps`;
1917
+ }
1918
+ }
1919
+ } catch (err) {
1920
+ if (out) {
1921
+ out.classList.add('warn');
1922
+ out.textContent = `Self-test failed: ${err && err.message ? err.message : err}`;
1923
+ }
1924
+ } finally {
1925
+ els.selfTestBtn.textContent = prevLabel;
1926
+ els.selfTestBtn.disabled = state.frames.length === 0;
1927
+ }
1928
+ }
1929
+ window.__timing = {
1930
+ get: () => timingSummary(play.telemetry),
1931
+ records: () => (play.telemetry ? play.telemetry.records.slice() : []),
1932
+ reset: () => {
1933
+ if (play.telemetry) {
1934
+ play.telemetry.records = [];
1935
+ play.telemetry.loops = 0;
1936
+ }
1937
+ },
1938
+ isPlaying: () => play.on,
1939
+ runSelfTest: runTimingSelfTest,
1940
+ };
1941
+
1942
+
1943
+ /* ----------------------------------------------------------- mp4 export */
1944
+
1945
+ function showProgress(frac) {
1946
+ els.exportProgress.classList.remove('hidden');
1947
+ const pct = Math.max(0, Math.min(100, Math.round(frac * 100)));
1948
+ els.exportBar.style.width = `${pct}%`;
1949
+ els.exportPct.textContent = `${pct}%`;
1950
+ }
1951
+
1952
+ function hideProgress() {
1953
+ els.exportProgress.classList.add('hidden');
1954
+ els.exportBar.style.width = '0%';
1955
+ }
1956
+
1957
+ function bitrateFor(w, h, fps) {
1958
+ return Math.min(40_000_000, Math.max(1_000_000, Math.round(w * h * fps * 0.12)));
1959
+ }
1960
+
1961
+ async function pickH264Codec(w, h, fps) {
1962
+ const candidates = [
1963
+ 'avc1.640034',
1964
+ 'avc1.640033',
1965
+ 'avc1.640032',
1966
+ 'avc1.64002A',
1967
+ 'avc1.640028',
1968
+ 'avc1.4D0028',
1969
+ 'avc1.42E01E',
1970
+ 'avc1.42001E',
1971
+ ];
1972
+ for (const codec of candidates) {
1973
+ try {
1974
+ const support = await VideoEncoder.isConfigSupported({
1975
+ codec,
1976
+ width: w,
1977
+ height: h,
1978
+ framerate: fps,
1979
+ bitrate: bitrateFor(w, h, fps),
1980
+ });
1981
+ if (support && support.supported) return codec;
1982
+ } catch {
1983
+ /* try next */
1984
+ }
1985
+ }
1986
+ return null;
1987
+ }
1988
+
1989
+ /** Rasterize one full-size frame SVG onto an opaque canvas at the output size. */
1990
+ async function rasterizeFrame(index, w, h, bg) {
1991
+ const res = await fetch(svgUrlFull(index));
1992
+ if (!res.ok) throw new Error(`frame ${index + 1} fetch failed`);
1993
+ const svgText = await res.text();
1994
+ const blobUrl = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
1995
+ try {
1996
+ const img = new Image();
1997
+ await new Promise((resolve, reject) => {
1998
+ img.onload = resolve;
1999
+ img.onerror = () => reject(new Error('could not rasterize frame'));
2000
+ img.src = blobUrl;
2001
+ });
2002
+ const canvas = document.createElement('canvas');
2003
+ canvas.width = w;
2004
+ canvas.height = h;
2005
+ const ctx = canvas.getContext('2d');
2006
+ ctx.fillStyle = bg;
2007
+ ctx.fillRect(0, 0, w, h);
2008
+ ctx.drawImage(img, 0, 0, w, h);
2009
+ return canvas;
2010
+ } finally {
2011
+ URL.revokeObjectURL(blobUrl);
2012
+ }
2013
+ }
2014
+
2015
+ function waitForQueue(encoder, limit) {
2016
+ return new Promise((resolve) => {
2017
+ const check = () => {
2018
+ if (encoder.encodeQueueSize <= limit) resolve();
2019
+ else setTimeout(check, 8);
2020
+ };
2021
+ check();
2022
+ });
2023
+ }
2024
+
2025
+ async function exportMp4() {
2026
+ if (!state.name) return;
2027
+ if (typeof window.VideoEncoder === 'undefined') {
2028
+ toast('MP4 export needs a Chromium or Safari 16.4+ browser (WebCodecs).');
2029
+ return;
2030
+ }
2031
+ stopPlay();
2032
+ const range = animRange();
2033
+ if (range.length === 0) {
2034
+ toast('Nothing to export');
2035
+ return;
2036
+ }
2037
+
2038
+ const fps = state.settings.fps;
2039
+ const scale = state.settings.scale;
2040
+ els.exportMp4Btn.disabled = true;
2041
+ showProgress(0);
2042
+
2043
+ try {
2044
+ const { Muxer, ArrayBufferTarget } = await import('./vendor/mp4-muxer.js');
2045
+
2046
+ // Base dimensions from a full (untrimmed) frame so all frames match.
2047
+ const firstSvg = await (await fetch(svgUrlFull(range[0]))).text();
2048
+ const dims = firstSvg.match(/<svg[^>]*\bwidth="(\d+(?:\.\d+)?)"[^>]*\bheight="(\d+(?:\.\d+)?)"/);
2049
+ if (!dims) throw new Error('could not read frame size');
2050
+ const baseW = Number(dims[1]);
2051
+ const baseH = Number(dims[2]);
2052
+ // H.264 requires even dimensions.
2053
+ const outW = Math.max(2, Math.round(baseW * scale) & ~1);
2054
+ const outH = Math.max(2, Math.round(baseH * scale) & ~1);
2055
+
2056
+ const codec = await pickH264Codec(outW, outH, fps);
2057
+ if (!codec) throw new Error('no supported H.264 encoder configuration for these dimensions');
2058
+
2059
+ const target = new ArrayBufferTarget();
2060
+ const muxer = new Muxer({
2061
+ target,
2062
+ video: { codec: 'avc', width: outW, height: outH },
2063
+ fastStart: 'in-memory',
2064
+ });
2065
+
2066
+ let encoderError = null;
2067
+ const encoder = new VideoEncoder({
2068
+ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
2069
+ error: (e) => {
2070
+ encoderError = e;
2071
+ },
2072
+ });
2073
+ encoder.configure({
2074
+ codec,
2075
+ width: outW,
2076
+ height: outH,
2077
+ framerate: fps,
2078
+ bitrate: bitrateFor(outW, outH, fps),
2079
+ });
2080
+
2081
+ const bg =
2082
+ getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() || '#0d1117';
2083
+ const eff = effectiveDurations();
2084
+ const counts = range.map((idx) => cfrCount(eff[idx], fps));
2085
+ const totalOut = counts.reduce((a, b) => a + b, 0);
2086
+ const usPerFrame = 1_000_000 / fps;
2087
+ const keyInterval = Math.max(1, Math.round(fps * 2));
2088
+
2089
+ let outIndex = 0;
2090
+ for (let r = 0; r < range.length; r++) {
2091
+ if (encoderError) throw encoderError;
2092
+ const canvas = await rasterizeFrame(range[r], outW, outH, bg);
2093
+ for (let n = 0; n < counts[r]; n++) {
2094
+ const frame = new VideoFrame(canvas, {
2095
+ timestamp: Math.round(outIndex * usPerFrame),
2096
+ duration: Math.round(usPerFrame),
2097
+ });
2098
+ encoder.encode(frame, { keyFrame: outIndex % keyInterval === 0 });
2099
+ frame.close();
2100
+ outIndex++;
2101
+ if (outIndex % 4 === 0) showProgress((outIndex / totalOut) * 0.92);
2102
+ if (encoder.encodeQueueSize > 24) await waitForQueue(encoder, 8);
2103
+ }
2104
+ }
2105
+
2106
+ await encoder.flush();
2107
+ if (encoderError) throw encoderError;
2108
+ muxer.finalize();
2109
+ showProgress(0.98);
2110
+
2111
+ const blob = new Blob([target.buffer], { type: 'video/mp4' });
2112
+ const stem = (state.name || 'capture').replace(/\.ans$/i, '');
2113
+ const filename = `${stem}@${scale}x.mp4`;
2114
+ const saved = await saveBlob(blob, filename, [
2115
+ { description: 'MP4 video', accept: { 'video/mp4': ['.mp4'] } },
2116
+ ]);
2117
+ showProgress(1);
2118
+ if (saved) toast(`Saved ${filename} (${outW}×${outH}, ${fps}fps)`);
2119
+ } catch (e) {
2120
+ toast(`MP4 export failed: ${e.message}`);
2121
+ } finally {
2122
+ els.exportMp4Btn.disabled = state.frames.length === 0;
2123
+ setTimeout(hideProgress, 700);
2124
+ }
2125
+ }
2126
+
2127
+ /* -------------------------------------------------------- new recording */
2128
+
2129
+ function updateRecCommand() {
2130
+ const cmd = els.recCmd.value.trim() || '<command>';
2131
+ let name = els.recName.value.trim();
2132
+ if (name && !/\.ans$/i.test(name)) name += '.ans';
2133
+ const outFlag = name ? `-o ${name} ` : '';
2134
+ els.recOut.textContent = `tui-cap record ${outFlag}-- ${cmd}`;
2135
+ }
2136
+
2137
+ /* --------------------------------------------------------------- wiring */
2138
+
2139
+ const debouncedRefresh = debounce(refreshImages, 180);
2140
+
2141
+ els.chrome.addEventListener('change', () => {
2142
+ els.chromeStyle.disabled = !els.chrome.checked;
2143
+ refreshImages();
2144
+ });
2145
+ els.chromeStyle.addEventListener('change', refreshImages);
2146
+ els.cursor.addEventListener('change', refreshImages);
2147
+ els.title.addEventListener('input', debouncedRefresh);
2148
+ els.fontSize.addEventListener('input', () => {
2149
+ els.fontSizeVal.textContent = `${els.fontSize.value}px`;
2150
+ debouncedRefresh();
2151
+ });
2152
+ els.lineHeight.addEventListener('input', () => {
2153
+ els.lineHeightVal.textContent = Number(els.lineHeight.value).toFixed(2);
2154
+ debouncedRefresh();
2155
+ });
2156
+
2157
+ // Restore font size + line spacing to their HTML default values.
2158
+ function resetTypography() {
2159
+ els.fontSize.value = els.fontSize.defaultValue;
2160
+ els.lineHeight.value = els.lineHeight.defaultValue;
2161
+ els.fontSizeVal.textContent = `${els.fontSize.value}px`;
2162
+ els.lineHeightVal.textContent = Number(els.lineHeight.value).toFixed(2);
2163
+ refreshImages();
2164
+ }
2165
+ els.resetType.addEventListener('click', resetTypography);
2166
+
2167
+ els.goStartBtn.addEventListener('click', () => userSeek(firstVisibleIndex()));
2168
+ els.prevBtn.addEventListener('click', () => userSeek(stepVisibleIndex(state.index, -1)));
2169
+ els.nextBtn.addEventListener('click', () => userSeek(stepVisibleIndex(state.index, 1)));
2170
+ els.goEndBtn.addEventListener('click', () => userSeek(lastVisibleIndex()));
2171
+ els.downloadBtn.addEventListener('click', downloadSvg);
2172
+ els.downloadPngBtn.addEventListener('click', downloadPng);
2173
+
2174
+ // Timeline view controls.
2175
+ els.zoom.addEventListener('input', () => setZoom(sliderToPps(Number(els.zoom.value)), playheadZoomAnchor()));
2176
+ els.fitBtn.addEventListener('click', fitTimeline);
2177
+
2178
+ // Canvas scale controls (header): Zoom-to-Fit, an arbitrary-zoom slider, and 100%.
2179
+ els.zoomFitBtn.addEventListener('click', () => fitCanvas());
2180
+ els.zoom100Btn.addEventListener('click', () => setCanvasZoom(1, { center: true }));
2181
+ els.zoomRange.addEventListener('input', () => setCanvasZoom(Number(els.zoomRange.value) / 100));
2182
+ els.timelineScroll.addEventListener('scroll', onTimelineScroll, { passive: true });
2183
+ // Pinch / ⌘|Ctrl + wheel over the timeline zooms (toward the cursor).
2184
+ els.timelineScroll.addEventListener('wheel', onTimelineWheel, { passive: false });
2185
+ // Click or drag the ruler to scrub the playhead through the timeline.
2186
+ els.timelineRuler.addEventListener('pointerdown', startScrub);
2187
+ // Shift + pointer-drag on the track brush-selects frames. Capture phase so it
2188
+ // pre-empts the per-frame resize handles' own pointerdown handlers.
2189
+ els.timelineTrack.addEventListener(
2190
+ 'pointerdown',
2191
+ (e) => {
2192
+ if (state.frames.length === 0 || !e.shiftKey) return;
2193
+ e.preventDefault();
2194
+ e.stopPropagation();
2195
+ startMarquee(e);
2196
+ },
2197
+ true,
2198
+ );
2199
+ // While Shift is held, the timeline is in brush-select mode: reflect that with a
2200
+ // marquee cursor (so the frame edges no longer read as resize handles).
2201
+ const syncShiftCursor = (down) => els.timelineScroll.classList.toggle('shift-select', down);
2202
+ window.addEventListener('keydown', (e) => {
2203
+ if (e.key === 'Shift') syncShiftCursor(true);
2204
+ });
2205
+ window.addEventListener('keyup', (e) => {
2206
+ if (e.key === 'Shift') syncShiftCursor(false);
2207
+ });
2208
+ window.addEventListener('blur', () => syncShiftCursor(false));
2209
+
2210
+ // Toggle showing/hiding deleted (skipped) frames in the timeline view. Deleted
2211
+ // frames are hidden by default, so the label is action-oriented: it names what a
2212
+ // click will do ("Show deleted" while hidden, "Hide deleted" while shown). The
2213
+ // pressed/active styling marks the non-default "deleted visible" override.
2214
+ function syncHideSkippedBtn() {
2215
+ const showing = !state.hideSkipped; // are deleted frames currently visible?
2216
+ els.hideSkippedBtn.textContent = showing ? 'Hide deleted' : 'Show deleted';
2217
+ els.hideSkippedBtn.title = showing
2218
+ ? 'Hide deleted frames from the timeline'
2219
+ : 'Show deleted frames in the timeline';
2220
+ els.hideSkippedBtn.classList.toggle('active', showing);
2221
+ els.hideSkippedBtn.setAttribute('aria-pressed', showing ? 'true' : 'false');
2222
+ }
2223
+
2224
+ els.hideSkippedBtn.addEventListener('click', () => {
2225
+ state.hideSkipped = !state.hideSkipped;
2226
+ syncHideSkippedBtn();
2227
+ updateAnimUI();
2228
+ });
2229
+
2230
+ // Selection length + skip/delete (operate on the whole selection).
2231
+ els.selLen.addEventListener('change', () => applyLengthToSelection(els.selLen.value));
2232
+ els.selLen.addEventListener('keydown', (e) => {
2233
+ if (e.key === 'Enter') {
2234
+ applyLengthToSelection(els.selLen.value);
2235
+ els.selLen.blur();
2236
+ }
2237
+ });
2238
+ els.selReset.addEventListener('click', resetSelection);
2239
+ els.selDelete.addEventListener('click', toggleSkipSelection);
2240
+
2241
+ let resizeRaf = null;
2242
+ window.addEventListener('resize', () => {
2243
+ cancelAnimationFrame(resizeRaf);
2244
+ resizeRaf = requestAnimationFrame(() => {
2245
+ if (state.frames.length > 0) layoutTimeline();
2246
+ });
2247
+ });
2248
+
2249
+ // Animation controls — each change updates settings, refreshes the timeline
2250
+ // readouts, and autosaves to the .anim.json sidecar.
2251
+ els.animFps.addEventListener('change', () => {
2252
+ state.settings.fps = Number(els.animFps.value) || 30;
2253
+ updateAnimUI();
2254
+ saveAnimationSettings();
2255
+ });
2256
+ els.animScale.addEventListener('change', () => {
2257
+ state.settings.scale = Number(els.animScale.value) || 2;
2258
+ updateAnimUI();
2259
+ saveAnimationSettings();
2260
+ });
2261
+ els.idleCap.addEventListener('input', () => {
2262
+ state.settings.idleCapMs = sliderToIdleCap(Number(els.idleCap.value));
2263
+ els.idleCapVal.textContent = fmtIdleCap(state.settings.idleCapMs);
2264
+ updateAnimUI();
2265
+ saveAnimationSettings();
2266
+ });
2267
+ els.speed.addEventListener('input', () => {
2268
+ state.settings.speed = Number(els.speed.value) || 1;
2269
+ els.speedVal.textContent = `${Number(state.settings.speed).toFixed(2).replace(/0$/, '')}×`;
2270
+ updateAnimUI();
2271
+ saveAnimationSettings();
2272
+ });
2273
+ els.smoothTyping.addEventListener('change', () => {
2274
+ state.settings.smoothTyping = els.smoothTyping.checked;
2275
+ els.typingCps.disabled = !state.settings.smoothTyping;
2276
+ updateAnimUI();
2277
+ saveAnimationSettings();
2278
+ });
2279
+ els.typingCps.addEventListener('input', () => {
2280
+ const cps = Math.max(10, Math.min(60, Number(els.typingCps.value) || 30));
2281
+ state.settings.typingCps = cps;
2282
+ els.typingCpsVal.textContent = `${cps} cps`;
2283
+ updateAnimUI();
2284
+ saveAnimationSettings();
2285
+ });
2286
+ els.clearOverrides.addEventListener('click', clearAllOverrides);
2287
+ els.playBtn.addEventListener('click', togglePlay);
2288
+ els.exportMp4Btn.addEventListener('click', exportMp4);
2289
+ if (els.timingDebugLink) els.timingDebugLink.addEventListener('click', () => setHudVisible(true));
2290
+ if (els.hudClose) els.hudClose.addEventListener('click', () => setHudVisible(false));
2291
+ if (els.selfTestBtn) els.selfTestBtn.addEventListener('click', runSelfTestUI);
2292
+ els.goStartBtn.innerHTML = ICON.goStart;
2293
+ els.prevBtn.innerHTML = ICON.prev;
2294
+ els.nextBtn.innerHTML = ICON.next;
2295
+ els.goEndBtn.innerHTML = ICON.goEnd;
2296
+ updatePlayBtn();
2297
+
2298
+ els.openFolderBtn.innerHTML = ICON.folder;
2299
+ els.openFolderBtn.addEventListener('click', openCapturesFolder);
2300
+
2301
+ els.newRecBtn.addEventListener('click', () => {
2302
+ updateRecCommand();
2303
+ els.recDialog.showModal();
2304
+ });
2305
+ els.recCmd.addEventListener('input', updateRecCommand);
2306
+ els.recName.addEventListener('input', updateRecCommand);
2307
+ els.recCopy.addEventListener('click', async () => {
2308
+ try {
2309
+ await navigator.clipboard.writeText(els.recOut.textContent);
2310
+ toast('Command copied');
2311
+ } catch {
2312
+ toast('Copy failed — select and copy manually');
2313
+ }
2314
+ });
2315
+
2316
+ els.updateBtn.addEventListener('click', doUpdate);
2317
+ els.updateDismiss.addEventListener('click', () => els.updateBanner.classList.add('hidden'));
2318
+
2319
+ document.addEventListener('keydown', (e) => {
2320
+ const tag = (document.activeElement?.tagName || '').toLowerCase();
2321
+ if (tag === 'input' || tag === 'select' || tag === 'textarea') return;
2322
+ if (els.recDialog.open) return;
2323
+ if (e.key === 'ArrowLeft') {
2324
+ userSeek(stepVisibleIndex(state.index, -1));
2325
+ e.preventDefault();
2326
+ } else if (e.key === 'ArrowRight') {
2327
+ userSeek(stepVisibleIndex(state.index, 1));
2328
+ e.preventDefault();
2329
+ } else if (e.key === 'Home') {
2330
+ userSeek(firstVisibleIndex());
2331
+ e.preventDefault();
2332
+ } else if (e.key === 'End') {
2333
+ userSeek(lastVisibleIndex());
2334
+ e.preventDefault();
2335
+ } else if (e.key === 'Delete' || e.key === 'Backspace') {
2336
+ if (state.selection.size > 0) {
2337
+ toggleSkipSelection();
2338
+ e.preventDefault();
2339
+ }
2340
+ } else if (e.key === ' ') {
2341
+ if (state.frames.length > 0) togglePlay();
2342
+ e.preventDefault();
2343
+ }
2344
+ });
2345
+
2346
+ // Live library refresh when new captures land (SSE, with the browser's own
2347
+ // auto-reconnect as the fallback).
2348
+ try {
2349
+ const es = new EventSource('/api/events');
2350
+ es.addEventListener('recordings', () => loadLibrary({ keepSelection: true }));
2351
+ } catch {
2352
+ setInterval(() => loadLibrary({ keepSelection: true }), 4000);
2353
+ }
2354
+
2355
+ /* =====================================================================
2356
+ * Content edits — non-destructive, cross-frame text + colour + spacing.
2357
+ * Anchors selected runs to their text content (server `edits.ts`) so an
2358
+ * edit follows its line across every frame, persisted to `<name>.edits.json`.
2359
+ * ===================================================================== */
2360
+
2361
+ const ed = {
2362
+ on: false,
2363
+ cells: null, // GET /cells response for the current frame
2364
+ palette: null, // GET /api/palette
2365
+ theme: 'dark', // GUI renders the dark theme by default
2366
+ doc: { schemaVersion: 1, revision: 0, edits: [] },
2367
+ undo: [],
2368
+ redo: [],
2369
+ sel: null, // active selection / popover target
2370
+ editing: false, // inline in-canvas editor active
2371
+ drag: null, // in-progress drag selection
2372
+ eyedrop: false, // eyedropper armed: next canvas click samples a char's colour
2373
+ fetchToken: 0,
2374
+ };
2375
+
2376
+ const edEls = {
2377
+ toggle: $('editToggle'),
2378
+ hint: $('editHint'),
2379
+ tools: $('editTools'),
2380
+ undoBtn: $('undoBtn'),
2381
+ redoBtn: $('redoBtn'),
2382
+ count: $('editCount'),
2383
+ reset: $('resetEdits'),
2384
+ overlay: $('selOverlay'),
2385
+ hover: $('selHover'),
2386
+ rect: $('selRect'),
2387
+ section: $('editsSection'),
2388
+ listCount: $('editsCount'),
2389
+ empty: $('editsEmpty'),
2390
+ list: $('editsList'),
2391
+ pop: $('editPopover'),
2392
+ selected: $('epSelected'),
2393
+ budget: $('epBudget'),
2394
+ fg: $('epFg'),
2395
+ fgPick: $('epFgPick'),
2396
+ bg: $('epBg'),
2397
+ all: $('epAll'),
2398
+ spaceRemove: $('epSpaceRemove'),
2399
+ spaceVal: $('epSpaceVal'),
2400
+ spaceAdd: $('epSpaceAdd'),
2401
+ remove: $('epRemove'),
2402
+ cancel: $('epCancel'),
2403
+ apply: $('epApply'),
2404
+ editor: $('selEditor'),
2405
+ editGhost: $('selEditGhost'),
2406
+ editText: $('selEditText'),
2407
+ caret: $('selCaret'),
2408
+ input: $('selInput'),
2409
+ };
2410
+
2411
+ /* ----------------------------------------------- grapheme + column helpers */
2412
+ // Mirrors the column math in src/edits.ts so anchors captured here line up with
2413
+ // what the server matches/applies (terminal columns, wide glyphs = 2 columns).
2414
+
2415
+ const GSEG =
2416
+ typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
2417
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
2418
+ : null;
2419
+ function graphemes(s) {
2420
+ return GSEG ? Array.from(GSEG.segment(s), (x) => x.segment) : Array.from(s);
2421
+ }
2422
+ const WIDE_RE =
2423
+ /[\u1100-\u115F\u2329\u232A\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/;
2424
+ const PICTO_RE = /\p{Extended_Pictographic}/u;
2425
+ function gWidth(g) {
2426
+ const cp = g.codePointAt(0);
2427
+ if (cp === undefined) return 1;
2428
+ if (cp >= 0x1f300 || PICTO_RE.test(g) || WIDE_RE.test(g)) return 2;
2429
+ return 1;
2430
+ }
2431
+ function strToCells(text) {
2432
+ const cells = [];
2433
+ let col = 0;
2434
+ for (const g of graphemes(text)) {
2435
+ const w = gWidth(g);
2436
+ cells.push({ char: g, col, width: w });
2437
+ col += w;
2438
+ }
2439
+ return cells;
2440
+ }
2441
+ function contentWidth(text) {
2442
+ let col = 0;
2443
+ for (const g of graphemes(text)) col += gWidth(g);
2444
+ return col;
2445
+ }
2446
+ /** [start,end) columns of a row's text, space-padded; matches edits.ts columnSlice. */
2447
+ function columnSlice(text, start, end) {
2448
+ const out = [];
2449
+ for (let c = start; c < end; c++) out.push(' ');
2450
+ for (const cell of strToCells(text)) {
2451
+ const w = Math.max(1, cell.width);
2452
+ if (cell.col + w <= start || cell.col >= end) continue;
2453
+ const idx = cell.col - start;
2454
+ if (idx >= 0 && idx < out.length) out[idx] = cell.char;
2455
+ for (let k = 1; k < w; k++) {
2456
+ const j = idx + k;
2457
+ if (j >= 0 && j < out.length) out[j] = '';
2458
+ }
2459
+ }
2460
+ return out.join('');
2461
+ }
2462
+ const trimEnd = (s) => String(s == null ? '' : s).replace(/\s+$/, '');
2463
+ function escapeHtml(s) {
2464
+ return String(s).replace(/[&<>"']/g, (c) =>
2465
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
2466
+ );
2467
+ }
2468
+ let edIdCounter = 0;
2469
+ function freshEditId() {
2470
+ edIdCounter += 1;
2471
+ return `e${Date.now().toString(36)}${edIdCounter.toString(36)}`;
2472
+ }
2473
+ const encName = () => encodeURIComponent(state.name);
2474
+
2475
+ /* --------------------------------------------------------------- geometry */
2476
+
2477
+ function geom() {
2478
+ return ed.cells && ed.cells.geometry;
2479
+ }
2480
+ /** Map a pointer event to a grid {col,row} via the rendered image's geometry. */
2481
+ function eventToCell(e) {
2482
+ const g = geom();
2483
+ if (!g) return null;
2484
+ const r = edEls.overlay.getBoundingClientRect();
2485
+ const scale = r.width / g.width || 1;
2486
+ const px = (e.clientX - r.left) / scale;
2487
+ const py = (e.clientY - r.top) / scale;
2488
+ let col = Math.floor((px - g.contentLeft) / g.cellW);
2489
+ let row = Math.floor((py - g.contentTop) / g.cellH);
2490
+ col = Math.max(0, Math.min(g.cols - 1, col));
2491
+ row = Math.max(0, Math.min(ed.cells.rows.length - 1, row));
2492
+ return { col, row };
2493
+ }
2494
+ /** Position the selection overlay exactly over the rendered <img>. */
2495
+ function positionOverlay() {
2496
+ const img = els.preview;
2497
+ if (!ed.on || !ed.cells || !img.getAttribute('src') || img.classList.contains('hidden')) {
2498
+ edEls.overlay.classList.add('hidden');
2499
+ return;
2500
+ }
2501
+ edEls.overlay.style.left = `${img.offsetLeft}px`;
2502
+ edEls.overlay.style.top = `${img.offsetTop}px`;
2503
+ edEls.overlay.style.width = `${img.offsetWidth}px`;
2504
+ edEls.overlay.style.height = `${img.offsetHeight}px`;
2505
+ edEls.overlay.classList.remove('hidden');
2506
+ if (ed.sel) {
2507
+ if (ed.editing) renderInlineEditor();
2508
+ else drawSelectionRect();
2509
+ }
2510
+ }
2511
+ /** Default terminal background / foreground for the active theme (for masking). */
2512
+ function themeBg() {
2513
+ return (ed.palette && ed.palette[ed.theme] && ed.palette[ed.theme].background) || '#0d1117';
2514
+ }
2515
+ function themeFg() {
2516
+ return (ed.palette && ed.palette[ed.theme] && ed.palette[ed.theme].foreground) || '#e6edf3';
2517
+ }
2518
+ function fontPx() {
2519
+ return Number(els.fontSize.value) || 14;
2520
+ }
2521
+ /** Draw a highlight box at [startCol,endCol) on a display row, in overlay px. */
2522
+ function cellBox(el, row, startCol, endCol) {
2523
+ const g = geom();
2524
+ const r = edEls.overlay.getBoundingClientRect();
2525
+ const scale = r.width / g.width || 1;
2526
+ el.style.left = `${(g.contentLeft + startCol * g.cellW) * scale}px`;
2527
+ el.style.top = `${(g.contentTop + row * g.cellH) * scale}px`;
2528
+ el.style.width = `${Math.max(1, endCol - startCol) * g.cellW * scale}px`;
2529
+ el.style.height = `${g.cellH * scale}px`;
2530
+ el.classList.remove('hidden');
2531
+ }
2532
+ /** Snap clicked columns out to whole glyphs and clamp to the row's content. */
2533
+ function snapSpan(displayRow, c0, c1) {
2534
+ const row = ed.cells.rows[displayRow];
2535
+ const cells = strToCells(row ? row.text : '');
2536
+ const contentEnd = cells.length ? cells[cells.length - 1].col + cells[cells.length - 1].width : 0;
2537
+ let lo = Math.min(c0, c1);
2538
+ let hi = Math.max(c0, c1);
2539
+ const cover = (col) => cells.find((ce) => col >= ce.col && col < ce.col + ce.width);
2540
+ const loCell = cover(lo);
2541
+ if (loCell) lo = loCell.col;
2542
+ const hiCell = cover(hi);
2543
+ let end = hiCell ? hiCell.col + hiCell.width : hi + 1;
2544
+ lo = Math.max(0, lo);
2545
+ end = Math.min(Math.max(end, lo + 1), Math.max(contentEnd, lo + 1));
2546
+ return { startCol: lo, endCol: end };
2547
+ }
2548
+
2549
+ /* ----------------------------------------------------------- data fetching */
2550
+
2551
+ async function loadPalette() {
2552
+ try {
2553
+ ed.palette = await apiJson('/api/palette');
2554
+ } catch {
2555
+ ed.palette = null;
2556
+ }
2557
+ }
2558
+ async function fetchCells(index) {
2559
+ if (!state.name) return;
2560
+ const token = ++ed.fetchToken;
2561
+ try {
2562
+ const data = await apiJson(`/api/recordings/${encName()}/frames/${index}/cells?${fullParams()}`);
2563
+ if (token !== ed.fetchToken) return; // a newer fetch superseded this one
2564
+ ed.cells = data;
2565
+ } catch {
2566
+ if (token === ed.fetchToken) ed.cells = null;
2567
+ }
2568
+ }
2569
+
2570
+ /* --------------------------------------------------------- edits lifecycle */
2571
+
2572
+ async function onRecordingEdits() {
2573
+ closePopover();
2574
+ ed.doc = { schemaVersion: 1, revision: 0, edits: [] };
2575
+ try {
2576
+ const data = await apiJson(`/api/recordings/${encName()}/edits`);
2577
+ if (data && data.edits) ed.doc = data.edits;
2578
+ } catch {
2579
+ /* default empty doc */
2580
+ }
2581
+ state.editsRevision = ed.doc && typeof ed.doc.revision === 'number' ? ed.doc.revision : 0;
2582
+ restoreHistory();
2583
+ renderEditsList();
2584
+ updateUndoButtons();
2585
+ if (ed.on) {
2586
+ await fetchCells(state.index);
2587
+ positionOverlay();
2588
+ }
2589
+ }
2590
+
2591
+ async function toggleEditMode() {
2592
+ ed.on = !ed.on;
2593
+ edEls.toggle.setAttribute('aria-pressed', ed.on ? 'true' : 'false');
2594
+ edEls.hint.classList.toggle('hidden', !ed.on);
2595
+ edEls.tools.classList.toggle('hidden', !ed.on);
2596
+ if (ed.on) {
2597
+ stopPlay();
2598
+ if (!ed.palette) await loadPalette();
2599
+ await fetchCells(state.index);
2600
+ positionOverlay();
2601
+ } else {
2602
+ closePopover();
2603
+ edEls.overlay.classList.add('hidden');
2604
+ edEls.hover.classList.add('hidden');
2605
+ edEls.rect.classList.add('hidden');
2606
+ }
2607
+ updateEditsSectionVisibility();
2608
+ }
2609
+
2610
+ /* ------------------------------------------------------------- selection UI */
2611
+
2612
+ edEls.overlay.addEventListener('pointerdown', (e) => {
2613
+ if (!ed.on) return;
2614
+ // A click inside the open inline editor must edit its text (focus + native
2615
+ // caret placement), not start a brand-new selection. The real <input> is a
2616
+ // descendant of this overlay, so its pointerdown bubbles up here — let it pass
2617
+ // through untouched so the input keeps focus and positions the caret. (During
2618
+ // eyedrop the editor is pointer-events:none, so the target is never the editor
2619
+ // and colour sampling still falls through to the overlay as before.)
2620
+ if (ed.editing && ed.sel && !ed.eyedrop && edEls.editor.contains(e.target)) return;
2621
+ const c = eventToCell(e);
2622
+ if (!c) return;
2623
+ e.preventDefault();
2624
+ if (ed.eyedrop) {
2625
+ sampleColorAt(c.row, c.col);
2626
+ return;
2627
+ }
2628
+ closePopover();
2629
+ try {
2630
+ edEls.overlay.setPointerCapture(e.pointerId);
2631
+ } catch {
2632
+ /* not all pointer types support capture */
2633
+ }
2634
+ ed.drag = { row: c.row, c0: c.col, c1: c.col };
2635
+ drawDragRect();
2636
+ });
2637
+ edEls.overlay.addEventListener('pointermove', (e) => {
2638
+ if (!ed.on) return;
2639
+ const c = eventToCell(e);
2640
+ if (ed.drag) {
2641
+ if (c) ed.drag.c1 = c.col;
2642
+ drawDragRect();
2643
+ } else if (c) {
2644
+ const span = snapSpan(c.row, c.col, c.col);
2645
+ cellBox(edEls.hover, c.row, span.startCol, span.endCol);
2646
+ }
2647
+ });
2648
+ edEls.overlay.addEventListener('pointerup', (e) => {
2649
+ if (!ed.on || !ed.drag) return;
2650
+ try {
2651
+ edEls.overlay.releasePointerCapture(e.pointerId);
2652
+ } catch {
2653
+ /* ignore */
2654
+ }
2655
+ const d = ed.drag;
2656
+ ed.drag = null;
2657
+ const span = snapSpan(d.row, d.c0, d.c1);
2658
+ openPopoverForSelection(d.row, span.startCol, span.endCol);
2659
+ });
2660
+ edEls.overlay.addEventListener('pointerleave', () => {
2661
+ if (!ed.drag) edEls.hover.classList.add('hidden');
2662
+ });
2663
+ function drawDragRect() {
2664
+ const span = snapSpan(ed.drag.row, ed.drag.c0, ed.drag.c1);
2665
+ edEls.hover.classList.add('hidden');
2666
+ cellBox(edEls.rect, ed.drag.row, span.startCol, span.endCol);
2667
+ }
2668
+ function drawSelectionRect() {
2669
+ if (ed.sel) cellBox(edEls.rect, ed.sel.displayRow, ed.sel.startCol, ed.sel.endCol);
2670
+ }
2671
+
2672
+ /* --------------------------------------------------------------- popover */
2673
+
2674
+ function openPopoverForSelection(displayRow, startCol, endCol) {
2675
+ const rowObj = ed.cells.rows[displayRow];
2676
+ if (!rowObj || rowObj.sourceRow === null) {
2677
+ toast('Pick a line that has text.');
2678
+ edEls.rect.classList.add('hidden');
2679
+ return;
2680
+ }
2681
+ const rawLineText = rowObj.rawLineText;
2682
+ const anchorText = columnSlice(rawLineText, startCol, endCol);
2683
+ const existing = ed.doc.edits.find(
2684
+ (e) =>
2685
+ e.kind === 'text' &&
2686
+ e.srcRow === rowObj.sourceRow &&
2687
+ e.startCol === startCol &&
2688
+ e.endCol === endCol &&
2689
+ e.rawLineText === rawLineText,
2690
+ );
2691
+ const spacing = ed.doc.edits.find(
2692
+ (e) => e.kind === 'spacing' && e.srcRow === rowObj.sourceRow && e.rawLineText === rawLineText,
2693
+ );
2694
+ ed.sel = {
2695
+ displayRow,
2696
+ sourceRow: rowObj.sourceRow,
2697
+ startCol,
2698
+ endCol, // current block end — grows as the user types
2699
+ originalEnd: endCol, // the dragged span end; the block never shrinks below it
2700
+ rawLineText,
2701
+ anchorText,
2702
+ originalText: anchorText, // the underlying glyphs, shown dimmed beneath the live text
2703
+ existingTextId: existing ? existing.id : null,
2704
+ fg: existing ? existing.fg : null,
2705
+ bg: existing ? existing.bg : null,
2706
+ spacing: spacing ? spacing.blankLines : 0,
2707
+ };
2708
+ ed.editing = true;
2709
+ edEls.hover.classList.add('hidden');
2710
+ edEls.rect.classList.add('hidden'); // the inline editor box replaces the rect
2711
+ edEls.selected.textContent = anchorText || '(spaces)';
2712
+ edEls.all.checked = existing ? Boolean(existing.applyToAll) : false;
2713
+ edEls.spaceVal.textContent = String(ed.sel.spacing);
2714
+ renderSwatches();
2715
+ edEls.remove.classList.toggle('hidden', !existing && !spacing);
2716
+ // Seed the in-canvas editor: an existing edit reopens with its replacement
2717
+ // text; a fresh selection starts empty so the first keystroke overwrites the
2718
+ // (dimmed) original rather than inserting in front of it. Caret goes to the end
2719
+ // of whatever we seed (the first cell of the block for a fresh, empty edit).
2720
+ edEls.input.value =
2721
+ existing && existing.text !== null && existing.text !== undefined ? existing.text : '';
2722
+ edEls.editor.classList.remove('hidden');
2723
+ renderInlineEditor();
2724
+ showPopoverNear();
2725
+ edEls.input.focus();
2726
+ try {
2727
+ const end = edEls.input.value.length;
2728
+ edEls.input.setSelectionRange(end, end);
2729
+ } catch {
2730
+ /* some inputs disallow programmatic selection */
2731
+ }
2732
+ pokeCaret();
2733
+ renderInlineEditor();
2734
+ }
2735
+
2736
+ /** Current block width in columns, growing rightward with the typed text. */
2737
+ function blockColsNow() {
2738
+ const g = geom();
2739
+ const sel = ed.sel;
2740
+ if (!g || !sel) return Math.max(1, sel ? sel.originalEnd - sel.startCol : 1);
2741
+ const remaining = g.cols - sel.startCol;
2742
+ const minSpan = Math.min(remaining, sel.originalEnd - sel.startCol);
2743
+ const typedW = contentWidth(edEls.input.value);
2744
+ return Math.max(1, Math.min(remaining, Math.max(minSpan, typedW)));
2745
+ }
2746
+
2747
+ let caretTimer = null;
2748
+ /** Keep the caret solid briefly after activity, then resume blinking. */
2749
+ function pokeCaret() {
2750
+ edEls.caret.classList.add('solid');
2751
+ if (caretTimer) clearTimeout(caretTimer);
2752
+ caretTimer = setTimeout(() => edEls.caret.classList.remove('solid'), 520);
2753
+ }
2754
+
2755
+ /** Draw the live editor (mask + per-cell glyphs + caret) over the selection. */
2756
+ function renderInlineEditor() {
2757
+ const g = geom();
2758
+ const sel = ed.sel;
2759
+ if (!g || !sel || !ed.editing) {
2760
+ edEls.editor.classList.add('hidden');
2761
+ return;
2762
+ }
2763
+ const r = edEls.overlay.getBoundingClientRect();
2764
+ const scale = r.width / g.width || 1;
2765
+ const cw = g.cellW * scale; // display px per terminal column
2766
+ const ch = g.cellH * scale;
2767
+ const fpx = fontPx() * scale;
2768
+ const value = edEls.input.value;
2769
+ const remaining = g.cols - sel.startCol;
2770
+ const blockCols = blockColsNow();
2771
+ const fg = sel.fg || themeFg();
2772
+
2773
+ const box = edEls.editor;
2774
+ box.style.left = `${(g.contentLeft + sel.startCol * g.cellW) * scale}px`;
2775
+ box.style.top = `${(g.contentTop + sel.displayRow * g.cellH) * scale}px`;
2776
+ box.style.width = `${blockCols * cw}px`;
2777
+ box.style.height = `${ch}px`;
2778
+ box.style.background = sel.bg || themeBg();
2779
+ box.classList.remove('hidden');
2780
+
2781
+ edEls.editText.style.color = fg;
2782
+ edEls.editText.style.fontSize = `${fpx}px`;
2783
+
2784
+ // Dimmed ghost of the original glyphs, pinned to the original span, so typing
2785
+ // visibly overwrites the old content in place instead of pushing it aside.
2786
+ let ghostHtml = '';
2787
+ let gcol = 0;
2788
+ for (const gch of graphemes(sel.originalText || '')) {
2789
+ const gw = gWidth(gch);
2790
+ if (gcol + gw > remaining) break;
2791
+ ghostHtml += `<span class="sel-edit-char" style="left:${gcol * cw}px;width:${gw * cw}px;height:${ch}px;line-height:${ch}px">${escapeHtml(gch)}</span>`;
2792
+ gcol += gw;
2793
+ }
2794
+ edEls.editGhost.innerHTML = ghostHtml;
2795
+ edEls.editGhost.style.fontSize = `${fpx}px`;
2796
+ edEls.editGhost.style.color = themeFg();
2797
+
2798
+ let html = '';
2799
+ let col = 0;
2800
+ for (const gch of graphemes(value)) {
2801
+ const gw = gWidth(gch);
2802
+ if (col + gw > remaining) break; // clip at the end of the line
2803
+ html += `<span class="sel-edit-char" style="left:${col * cw}px;width:${gw * cw}px;height:${ch}px;line-height:${ch}px">${escapeHtml(gch)}</span>`;
2804
+ col += gw;
2805
+ }
2806
+ edEls.editText.innerHTML = html;
2807
+
2808
+ const selStart =
2809
+ typeof edEls.input.selectionStart === 'number' ? edEls.input.selectionStart : value.length;
2810
+ const caretCol = Math.max(0, Math.min(remaining, contentWidth(value.slice(0, selStart))));
2811
+ edEls.caret.style.left = `${caretCol * cw}px`;
2812
+ edEls.caret.style.height = `${ch}px`;
2813
+ edEls.caret.style.color = fg;
2814
+
2815
+ edEls.input.style.fontSize = `${fpx}px`;
2816
+ edEls.input.style.lineHeight = `${ch}px`;
2817
+ updateBudget();
2818
+ }
2819
+
2820
+ function updateBudget() {
2821
+ if (!ed.sel) return;
2822
+ const g = geom();
2823
+ const remaining = g ? g.cols - ed.sel.startCol : ed.sel.endCol - ed.sel.startCol;
2824
+ const used = contentWidth(edEls.input.value);
2825
+ edEls.budget.textContent = `${Math.min(used, remaining)}/${remaining} cols${used > remaining ? ' · clipped' : ''}`;
2826
+ }
2827
+
2828
+ function renderSwatches() {
2829
+ const pal = (ed.palette && ed.palette[ed.theme]) || { ansi: [] };
2830
+ buildSwatchRow(edEls.fg, pal, ed.sel.fg, (hex) => {
2831
+ ed.sel.fg = hex;
2832
+ renderSwatches();
2833
+ if (ed.editing) renderInlineEditor(); // colour previews live in the canvas
2834
+ });
2835
+ buildSwatchRow(edEls.bg, pal, ed.sel.bg, (hex) => {
2836
+ ed.sel.bg = hex;
2837
+ renderSwatches();
2838
+ if (ed.editing) renderInlineEditor();
2839
+ });
2840
+ }
2841
+ function buildSwatchRow(container, pal, current, onPick) {
2842
+ container.innerHTML = '';
2843
+ const clear = document.createElement('button');
2844
+ clear.type = 'button';
2845
+ clear.className = `swatch clear${current == null ? ' selected' : ''}`;
2846
+ clear.title = 'Default (no override)';
2847
+ clear.addEventListener('click', () => onPick(null));
2848
+ container.appendChild(clear);
2849
+ for (const hex of pal.ansi || []) {
2850
+ const b = document.createElement('button');
2851
+ b.type = 'button';
2852
+ const on = current && current.toLowerCase() === hex.toLowerCase();
2853
+ b.className = `swatch${on ? ' selected' : ''}`;
2854
+ b.style.background = hex;
2855
+ b.title = hex;
2856
+ b.addEventListener('click', () => onPick(hex));
2857
+ container.appendChild(b);
2858
+ }
2859
+ }
2860
+
2861
+ /* --------------------------------------------------------------- eyedropper */
2862
+
2863
+ /** Arm/disarm the eyedropper: while armed, the next canvas click samples the
2864
+ * glyph colour under the pointer into the selection's text colour. */
2865
+ function toggleEyedrop() {
2866
+ if (ed.eyedrop) exitEyedrop();
2867
+ else enterEyedrop();
2868
+ }
2869
+ function enterEyedrop() {
2870
+ if (!ed.sel) return;
2871
+ ed.eyedrop = true;
2872
+ edEls.overlay.classList.add('eyedrop');
2873
+ edEls.fgPick.setAttribute('aria-pressed', 'true');
2874
+ edEls.hover.classList.add('hidden');
2875
+ toast('Eyedropper: click a character to pick its colour · Esc to cancel');
2876
+ }
2877
+ function exitEyedrop() {
2878
+ ed.eyedrop = false;
2879
+ edEls.overlay.classList.remove('eyedrop');
2880
+ edEls.fgPick.setAttribute('aria-pressed', 'false');
2881
+ }
2882
+ /** Resolved foreground colour of the glyph covering (row, col), or null. */
2883
+ function fgColorAt(displayRow, col) {
2884
+ const row = ed.cells && ed.cells.rows[displayRow];
2885
+ if (!row || !row.fg) return null;
2886
+ const cells = strToCells(row.text);
2887
+ const cover = cells.find((ce) => col >= ce.col && col < ce.col + ce.width);
2888
+ const key = cover ? cover.col : col;
2889
+ const hex = row.fg[key] ?? row.fg[String(key)];
2890
+ return hex || null;
2891
+ }
2892
+ /** Sample the glyph colour at a cell and apply it to the selection's fg. */
2893
+ function sampleColorAt(displayRow, col) {
2894
+ const hex = fgColorAt(displayRow, col);
2895
+ if (!hex) {
2896
+ toast('No character there to sample — click a letter or symbol.');
2897
+ return;
2898
+ }
2899
+ exitEyedrop();
2900
+ if (!ed.sel) return;
2901
+ ed.sel.fg = hex;
2902
+ renderSwatches();
2903
+ if (ed.editing) {
2904
+ renderInlineEditor();
2905
+ edEls.input.focus();
2906
+ }
2907
+ toast(`Picked text colour ${hex}`);
2908
+ }
2909
+
2910
+ function showPopoverNear() {
2911
+ edEls.pop.classList.remove('hidden');
2912
+ const anchorEl = ed.editing ? edEls.editor : edEls.rect;
2913
+ const r = anchorEl.getBoundingClientRect();
2914
+ const pw = edEls.pop.offsetWidth;
2915
+ const ph = edEls.pop.offsetHeight;
2916
+ let top = r.bottom + 8;
2917
+ if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 8);
2918
+ const left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8));
2919
+ edEls.pop.style.left = `${left}px`;
2920
+ edEls.pop.style.top = `${top}px`;
2921
+ }
2922
+ function closePopover() {
2923
+ exitEyedrop();
2924
+ edEls.pop.classList.add('hidden');
2925
+ edEls.rect.classList.add('hidden');
2926
+ edEls.editor.classList.add('hidden');
2927
+ ed.editing = false;
2928
+ ed.sel = null;
2929
+ }
2930
+
2931
+ /* --------------------------------------------------------- mutate + persist */
2932
+
2933
+ function anchorFields(sel) {
2934
+ return {
2935
+ rawLineText: sel.rawLineText,
2936
+ startCol: sel.startCol,
2937
+ endCol: sel.endCol,
2938
+ anchorText: sel.anchorText,
2939
+ leftContext: columnSlice(sel.rawLineText, Math.max(0, sel.startCol - 3), sel.startCol),
2940
+ rightContext: columnSlice(sel.rawLineText, sel.endCol, sel.endCol + 3),
2941
+ lineAbove: trimEnd(neighborRaw(sel.displayRow - 1)),
2942
+ lineBelow: trimEnd(neighborRaw(sel.displayRow + 1)),
2943
+ srcFrame: state.index,
2944
+ srcRow: sel.sourceRow,
2945
+ };
2946
+ }
2947
+ function neighborRaw(displayRow) {
2948
+ const r = ed.cells && ed.cells.rows[displayRow];
2949
+ return r ? r.rawLineText : '';
2950
+ }
2951
+
2952
+ function applyFromPopover() {
2953
+ const sel = ed.sel;
2954
+ if (!sel) return;
2955
+ const g = geom();
2956
+ const value = edEls.input.value;
2957
+ // Resolve the grown block extent: it overwrites rightward up to the end of the
2958
+ // line, but never shrinks below the originally selected span (layout-preserving).
2959
+ const remaining = g ? g.cols - sel.startCol : sel.originalEnd - sel.startCol;
2960
+ const minSpan = Math.min(remaining, sel.originalEnd - sel.startCol);
2961
+ const blockCols = Math.max(1, Math.min(remaining, Math.max(minSpan, contentWidth(value))));
2962
+ const grownEnd = sel.startCol + blockCols;
2963
+ // Anchor/apply span follow the grown block so the matcher and overwrite agree.
2964
+ sel.endCol = grownEnd;
2965
+ sel.anchorText = columnSlice(sel.rawLineText, sel.startCol, grownEnd);
2966
+ // Empty (nothing typed) or unchanged content → colour-only edit, never blank
2967
+ // out the original by committing an empty replacement.
2968
+ const text = value === '' || value === sel.anchorText ? null : value;
2969
+ const fg = sel.fg ?? null;
2970
+ const bg = sel.bg ?? null;
2971
+ pushHistory();
2972
+ let edits = ed.doc.edits.slice();
2973
+ if (sel.existingTextId) edits = edits.filter((e) => e.id !== sel.existingTextId);
2974
+ if (text !== null || fg !== null || bg !== null) {
2975
+ edits.push({
2976
+ ...anchorFields(sel),
2977
+ id: sel.existingTextId || freshEditId(),
2978
+ kind: 'text',
2979
+ text,
2980
+ fg,
2981
+ bg,
2982
+ applyToAll: edEls.all.checked,
2983
+ });
2984
+ }
2985
+ ed.doc.edits = edits;
2986
+ commitEdits();
2987
+ closePopover();
2988
+ }
2989
+
2990
+ function changeSpacing(delta) {
2991
+ const sel = ed.sel;
2992
+ if (!sel) return;
2993
+ let edits = ed.doc.edits.slice();
2994
+ const idx = edits.findIndex(
2995
+ (e) => e.kind === 'spacing' && e.srcRow === sel.sourceRow && e.rawLineText === sel.rawLineText,
2996
+ );
2997
+ const prevId = idx >= 0 ? edits[idx].id : null;
2998
+ let next = (idx >= 0 ? edits[idx].blankLines : 0) + delta;
2999
+ next = Math.max(-10, Math.min(20, next));
3000
+ pushHistory();
3001
+ if (idx >= 0) edits.splice(idx, 1);
3002
+ if (next !== 0) {
3003
+ edits.push({
3004
+ ...anchorFields(sel),
3005
+ id: prevId || freshEditId(),
3006
+ kind: 'spacing',
3007
+ blankLines: next,
3008
+ applyToAll: edEls.all.checked,
3009
+ });
3010
+ }
3011
+ ed.doc.edits = edits;
3012
+ sel.spacing = next;
3013
+ edEls.spaceVal.textContent = String(next);
3014
+ edEls.remove.classList.remove('hidden');
3015
+ commitEdits();
3016
+ }
3017
+
3018
+ function removeCurrent() {
3019
+ const sel = ed.sel;
3020
+ if (!sel) return;
3021
+ pushHistory();
3022
+ ed.doc.edits = ed.doc.edits.filter((e) => {
3023
+ if (e.kind === 'text' && e.id === sel.existingTextId) return false;
3024
+ if (e.kind === 'spacing' && e.srcRow === sel.sourceRow && e.rawLineText === sel.rawLineText)
3025
+ return false;
3026
+ return true;
3027
+ });
3028
+ commitEdits();
3029
+ closePopover();
3030
+ }
3031
+ function removeById(id) {
3032
+ pushHistory();
3033
+ ed.doc.edits = ed.doc.edits.filter((e) => e.id !== id);
3034
+ commitEdits();
3035
+ }
3036
+ async function resetAllEdits() {
3037
+ if (ed.doc.edits.length === 0) return;
3038
+ if (!window.confirm('Remove all content edits for this recording?')) return;
3039
+ pushHistory();
3040
+ ed.doc.edits = [];
3041
+ await commitEdits();
3042
+ }
3043
+
3044
+ /** Persist + re-render everything after an edits mutation. */
3045
+ async function commitEdits() {
3046
+ renderEditsList();
3047
+ updateUndoButtons();
3048
+ await saveEditsNow();
3049
+ refreshImages();
3050
+ await refreshFramesMeta();
3051
+ await fetchCells(state.index);
3052
+ positionOverlay();
3053
+ }
3054
+ async function saveEditsNow() {
3055
+ if (!state.name) return;
3056
+ try {
3057
+ const res = await fetch(`/api/recordings/${encName()}/edits`, {
3058
+ method: 'PUT',
3059
+ headers: { 'content-type': 'application/json' },
3060
+ body: JSON.stringify({ schemaVersion: 1, edits: ed.doc.edits }),
3061
+ });
3062
+ if (res.ok) {
3063
+ const data = await res.json();
3064
+ ed.doc = data.edits;
3065
+ if (ed.doc && typeof ed.doc.revision === 'number') state.editsRevision = ed.doc.revision;
3066
+ }
3067
+ } catch {
3068
+ /* best-effort autosave */
3069
+ }
3070
+ persistHistory();
3071
+ }
3072
+ /** Refresh just the uniform animation canvas (spacing edits can grow it). */
3073
+ async function refreshFramesMeta() {
3074
+ try {
3075
+ const data = await apiJson(`/api/recordings/${encName()}/frames`);
3076
+ if (typeof data.editsRevision === 'number') state.editsRevision = data.editsRevision;
3077
+ if (data.animCanvas && data.animCanvas.cols && data.animCanvas.rows) {
3078
+ state.animCanvas = data.animCanvas;
3079
+ }
3080
+ if (data.cols && data.rows) els.recordedSize.textContent = `${data.cols} × ${data.rows}`;
3081
+ } catch {
3082
+ /* leave the previous canvas in place */
3083
+ }
3084
+ }
3085
+
3086
+ /* ----------------------------------------------------------- undo / redo */
3087
+
3088
+ function snapshot() {
3089
+ return JSON.stringify(ed.doc.edits);
3090
+ }
3091
+ function pushHistory() {
3092
+ ed.undo.push(snapshot());
3093
+ ed.redo.length = 0;
3094
+ }
3095
+ async function undoEdit() {
3096
+ if (ed.undo.length === 0) return;
3097
+ ed.redo.push(snapshot());
3098
+ ed.doc.edits = JSON.parse(ed.undo.pop());
3099
+ closePopover();
3100
+ await commitEdits();
3101
+ }
3102
+ async function redoEdit() {
3103
+ if (ed.redo.length === 0) return;
3104
+ ed.undo.push(snapshot());
3105
+ ed.doc.edits = JSON.parse(ed.redo.pop());
3106
+ closePopover();
3107
+ await commitEdits();
3108
+ }
3109
+ function updateUndoButtons() {
3110
+ edEls.undoBtn.disabled = ed.undo.length === 0;
3111
+ edEls.redoBtn.disabled = ed.redo.length === 0;
3112
+ }
3113
+ function histKey() {
3114
+ return `ghcp-edits-history:${state.name}`;
3115
+ }
3116
+ function persistHistory() {
3117
+ try {
3118
+ sessionStorage.setItem(
3119
+ histKey(),
3120
+ JSON.stringify({ revision: ed.doc.revision, undo: ed.undo, redo: ed.redo }),
3121
+ );
3122
+ } catch {
3123
+ /* sessionStorage may be unavailable */
3124
+ }
3125
+ updateUndoButtons();
3126
+ }
3127
+ function restoreHistory() {
3128
+ ed.undo = [];
3129
+ ed.redo = [];
3130
+ try {
3131
+ const raw = sessionStorage.getItem(histKey());
3132
+ if (!raw) return;
3133
+ const h = JSON.parse(raw);
3134
+ // Discard history built on a different sidecar revision (out-of-band change).
3135
+ if (h && h.revision === ed.doc.revision) {
3136
+ ed.undo = Array.isArray(h.undo) ? h.undo : [];
3137
+ ed.redo = Array.isArray(h.redo) ? h.redo : [];
3138
+ }
3139
+ } catch {
3140
+ /* ignore corrupt history */
3141
+ }
3142
+ }
3143
+
3144
+ /* ------------------------------------------------------------- edits list */
3145
+
3146
+ function updateEditsSectionVisibility() {
3147
+ const show = ed.on || ed.doc.edits.length > 0;
3148
+ edEls.section.classList.toggle('hidden', !show);
3149
+ }
3150
+ async function jumpToEdit(edit) {
3151
+ if (state.frames.length === 0) return;
3152
+ // Re-selecting an edit needs the editor overlay + per-frame cell geometry, so
3153
+ // turn edit mode on (when off) before navigating and reopening the dialog.
3154
+ if (!ed.on) await toggleEditMode();
3155
+ // Land on the exact frame the edit was authored on — even if it's skipped — so
3156
+ // its source row and raw line match what was anchored.
3157
+ const target =
3158
+ typeof edit.srcFrame === 'number' && edit.srcFrame >= 0 && edit.srcFrame < state.frames.length
3159
+ ? edit.srcFrame
3160
+ : state.index;
3161
+ userSeek(target);
3162
+ // setIndex() (via userSeek) closes any open popover and starts its own cells
3163
+ // fetch; await a fresh one so ed.cells is guaranteed to describe the target
3164
+ // frame before we resolve the source row.
3165
+ await fetchCells(target);
3166
+ positionOverlay();
3167
+ reopenEditPopover(edit);
3168
+ }
3169
+
3170
+ /** Reopen the content dialog on the display row that carries an edit's anchor.
3171
+ * openPopoverForSelection() re-discovers the edit by (srcRow, startCol, endCol,
3172
+ * rawLineText) and seeds existingTextId/colours, so applying changes updates
3173
+ * that same edit in place instead of creating a duplicate. */
3174
+ function reopenEditPopover(edit) {
3175
+ const rows = ed.cells && ed.cells.rows;
3176
+ if (!rows) {
3177
+ toast('Could not open this edit.');
3178
+ return;
3179
+ }
3180
+ let displayRow = rows.findIndex(
3181
+ (r) => r.sourceRow === edit.srcRow && r.rawLineText === edit.rawLineText,
3182
+ );
3183
+ if (displayRow < 0) displayRow = rows.findIndex((r) => r.sourceRow === edit.srcRow);
3184
+ if (displayRow < 0) {
3185
+ toast('This edit’s line is no longer on its source frame.');
3186
+ return;
3187
+ }
3188
+ openPopoverForSelection(displayRow, edit.startCol, edit.endCol);
3189
+ }
3190
+ function renderEditsList() {
3191
+ const edits = ed.doc.edits;
3192
+ const label = edits.length ? `${edits.length} edit${edits.length === 1 ? '' : 's'}` : '';
3193
+ edEls.count.textContent = label;
3194
+ edEls.listCount.textContent = label;
3195
+ edEls.reset.classList.toggle('hidden', edits.length === 0);
3196
+ edEls.empty.classList.toggle('hidden', edits.length > 0);
3197
+ edEls.list.innerHTML = '';
3198
+ for (const e of edits) {
3199
+ const li = document.createElement('li');
3200
+ li.className = 'edit-item';
3201
+ const main = document.createElement('div');
3202
+ main.className = 'ei-main';
3203
+ main.title = 'Click to reopen this edit';
3204
+ if (e.kind === 'spacing') {
3205
+ const n = e.blankLines;
3206
+ main.innerHTML =
3207
+ `<span class="ei-kind">space</span>` +
3208
+ `<span class="ei-text">${n > 0 ? '+' : ''}${n} line${Math.abs(n) === 1 ? '' : 's'} · ${escapeHtml(trimEnd(e.rawLineText) || '(blank)')}</span>`;
3209
+ } else {
3210
+ const labelText = e.text !== null && e.text !== undefined ? e.text : trimEnd(e.anchorText) || '(recolor)';
3211
+ let sw = '';
3212
+ if (e.fg) sw += `<span class="ei-swatch" style="background:${escapeHtml(e.fg)}"></span>`;
3213
+ if (e.bg) sw += `<span class="ei-swatch" style="background:${escapeHtml(e.bg)}"></span>`;
3214
+ main.innerHTML =
3215
+ `<span class="ei-kind">text</span>` +
3216
+ `<span class="ei-text">${escapeHtml(labelText)}</span>${sw}`;
3217
+ }
3218
+ main.addEventListener('click', () => jumpToEdit(e));
3219
+ const rm = document.createElement('button');
3220
+ rm.className = 'ei-remove';
3221
+ rm.type = 'button';
3222
+ rm.title = 'Remove edit';
3223
+ rm.textContent = '×';
3224
+ rm.addEventListener('click', (ev) => {
3225
+ ev.stopPropagation();
3226
+ removeById(e.id);
3227
+ });
3228
+ li.append(main, rm);
3229
+ edEls.list.appendChild(li);
3230
+ }
3231
+ updateEditsSectionVisibility();
3232
+ }
3233
+
3234
+ /* --------------------------------------------------------------- wiring */
3235
+
3236
+ edEls.toggle.addEventListener('click', toggleEditMode);
3237
+ edEls.undoBtn.addEventListener('click', undoEdit);
3238
+ edEls.redoBtn.addEventListener('click', redoEdit);
3239
+ edEls.reset.addEventListener('click', resetAllEdits);
3240
+ edEls.apply.addEventListener('click', applyFromPopover);
3241
+ edEls.cancel.addEventListener('click', closePopover);
3242
+ edEls.remove.addEventListener('click', removeCurrent);
3243
+ edEls.spaceAdd.addEventListener('click', () => changeSpacing(1));
3244
+ edEls.spaceRemove.addEventListener('click', () => changeSpacing(-1));
3245
+ edEls.fgPick.addEventListener('click', toggleEyedrop);
3246
+
3247
+ // Live in-canvas editing: the hidden input is the keystroke source of truth; we
3248
+ // mirror its value + caret into the on-canvas glyph layer on every change.
3249
+ edEls.input.addEventListener('input', () => {
3250
+ pokeCaret();
3251
+ renderInlineEditor();
3252
+ });
3253
+ edEls.input.addEventListener('keyup', () => renderInlineEditor());
3254
+ edEls.input.addEventListener('click', () => {
3255
+ pokeCaret();
3256
+ renderInlineEditor();
3257
+ });
3258
+ edEls.input.addEventListener('select', () => renderInlineEditor());
3259
+ edEls.input.addEventListener('keydown', (e) => {
3260
+ if (e.key === 'Enter') {
3261
+ e.preventDefault();
3262
+ applyFromPopover();
3263
+ } else if (e.key === 'Escape') {
3264
+ e.preventDefault();
3265
+ if (ed.eyedrop) exitEyedrop();
3266
+ else closePopover();
3267
+ } else {
3268
+ pokeCaret();
3269
+ }
3270
+ });
3271
+
3272
+ document.addEventListener('keydown', (e) => {
3273
+ if (!ed.on) return;
3274
+ if (e.key === 'Escape') {
3275
+ if (ed.eyedrop) exitEyedrop();
3276
+ else if (!edEls.pop.classList.contains('hidden')) closePopover();
3277
+ return;
3278
+ }
3279
+ if (!(e.metaKey || e.ctrlKey)) return;
3280
+ const k = e.key.toLowerCase();
3281
+ if (k === 'z') {
3282
+ if (document.activeElement === edEls.input) return; // let native text-undo run
3283
+ e.preventDefault();
3284
+ if (e.shiftKey) redoEdit();
3285
+ else undoEdit();
3286
+ } else if (k === 'y') {
3287
+ if (document.activeElement === edEls.input) return;
3288
+ e.preventDefault();
3289
+ redoEdit();
3290
+ }
3291
+ });
3292
+
3293
+ els.preview.addEventListener('load', () => {
3294
+ // The canvas size can change between renders (new recording, font/spacing
3295
+ // tweaks). Re-apply the active scale so explicit zoom keeps the right pixel
3296
+ // size and the Zoom-to-Fit readout reflects the new dimensions.
3297
+ applyViewMode();
3298
+ });
3299
+ window.addEventListener('resize', () => {
3300
+ // Zoom-to-Fit depends on the viewport, so keep its readout in sync.
3301
+ if (state.frames.length > 0) updateZoomReadout();
3302
+ if (ed.on) {
3303
+ positionOverlay();
3304
+ if (!edEls.pop.classList.contains('hidden')) showPopoverNear();
3305
+ }
3306
+ });
3307
+
3308
+ /* --------------------------------------------------------------- bootstrap */
3309
+
3310
+ clearPreview();
3311
+ loadLibrary();
3312
+ checkForUpdate();