keyframes 0.1.0__py3-none-any.whl

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.
Files changed (77) hide show
  1. keyframes/__init__.py +1 -0
  2. keyframes/build.py +102 -0
  3. keyframes/check.py +107 -0
  4. keyframes/cli.py +149 -0
  5. keyframes/deck.py +67 -0
  6. keyframes/export.py +106 -0
  7. keyframes/guide/__init__.py +25 -0
  8. keyframes/guide/components/keyframes-code.md +60 -0
  9. keyframes/guide/components/keyframes-excalidraw.md +148 -0
  10. keyframes/guide/components/keyframes-mujoco.md +76 -0
  11. keyframes/guide/components/keyframes-slider.md +65 -0
  12. keyframes/guide/components/keyframes-three.md +81 -0
  13. keyframes/guide/core.md +113 -0
  14. keyframes/patch.py +77 -0
  15. keyframes/render.py +32 -0
  16. keyframes/scaffold/.gitignore +3 -0
  17. keyframes/scaffold/CLAUDE.md +1 -0
  18. keyframes/scaffold/assets/deck.css +1 -0
  19. keyframes/scaffold/assets/figure.svg +1 -0
  20. keyframes/scaffold/deck.toml +10 -0
  21. keyframes/scaffold/references/.gitkeep +0 -0
  22. keyframes/scaffold/slides/01-title.html +4 -0
  23. keyframes/scaffold/slides/02-intro.html +17 -0
  24. keyframes/server.py +204 -0
  25. keyframes/static/components/keyframes-code.js +423 -0
  26. keyframes/static/components/keyframes-excalidraw.js +189 -0
  27. keyframes/static/components/keyframes-mujoco.js +515 -0
  28. keyframes/static/components/keyframes-slider.js +255 -0
  29. keyframes/static/components/keyframes-three.js +224 -0
  30. keyframes/static/editor/editor.css +228 -0
  31. keyframes/static/editor/editor.js +594 -0
  32. keyframes/static/editor/panel.css +127 -0
  33. keyframes/static/editor/panel.js +357 -0
  34. keyframes/static/index.html +23 -0
  35. keyframes/static/runtime/component.js +126 -0
  36. keyframes/static/runtime/live.js +31 -0
  37. keyframes/static/runtime/math.js +33 -0
  38. keyframes/static/runtime/presenter.js +91 -0
  39. keyframes/static/runtime/runtime.css +91 -0
  40. keyframes/static/runtime/runtime.js +215 -0
  41. keyframes/static/runtime/steps.js +15 -0
  42. keyframes/terminal.py +144 -0
  43. keyframes/themes/default/STYLE_GUIDE.md +73 -0
  44. keyframes/themes/default/assets/arc-top.webp +0 -0
  45. keyframes/themes/default/assets/orbit-ring.webp +0 -0
  46. keyframes/themes/default/assets/streaks-top.webp +0 -0
  47. keyframes/themes/default/assets/streaks-wide.webp +0 -0
  48. keyframes/themes/default/assets/sun-top.webp +0 -0
  49. keyframes/themes/default/assets/title-orbit.webp +0 -0
  50. keyframes/themes/default/layouts/content.html +16 -0
  51. keyframes/themes/default/layouts/figure.html +7 -0
  52. keyframes/themes/default/layouts/grid-2.html +19 -0
  53. keyframes/themes/default/layouts/grid-3x2.html +28 -0
  54. keyframes/themes/default/layouts/grid-4.html +44 -0
  55. keyframes/themes/default/layouts/media-side.html +16 -0
  56. keyframes/themes/default/layouts/section-arc.html +4 -0
  57. keyframes/themes/default/layouts/section-streaks.html +4 -0
  58. keyframes/themes/default/layouts/section.html +4 -0
  59. keyframes/themes/default/layouts/split-wide.html +7 -0
  60. keyframes/themes/default/layouts/split.html +13 -0
  61. keyframes/themes/default/layouts/statement.html +4 -0
  62. keyframes/themes/default/layouts/title.html +5 -0
  63. keyframes/themes/default/layouts/two-column.html +5 -0
  64. keyframes/themes/default/theme.css +330 -0
  65. keyframes/themes/minimal/STYLE_GUIDE.md +41 -0
  66. keyframes/themes/minimal/layouts/figure.html +7 -0
  67. keyframes/themes/minimal/layouts/section.html +4 -0
  68. keyframes/themes/minimal/layouts/statement.html +3 -0
  69. keyframes/themes/minimal/layouts/title-body.html +10 -0
  70. keyframes/themes/minimal/layouts/title.html +5 -0
  71. keyframes/themes/minimal/layouts/two-column.html +11 -0
  72. keyframes/themes/minimal/theme.css +196 -0
  73. keyframes/vendor.py +181 -0
  74. keyframes-0.1.0.dist-info/METADATA +119 -0
  75. keyframes-0.1.0.dist-info/RECORD +77 -0
  76. keyframes-0.1.0.dist-info/WHEEL +4 -0
  77. keyframes-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,594 @@
1
+ const deck = window.keyframes;
2
+ const params = new URLSearchParams(location.search);
3
+ const SNAP_PX = 6;
4
+ const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
5
+
6
+ const state = {
7
+ on: false,
8
+ selection: [],
9
+ drag: null,
10
+ history: [],
11
+ future: [],
12
+ nudge: null,
13
+ };
14
+
15
+ const ui = {};
16
+
17
+ function slide() {
18
+ return deck.current;
19
+ }
20
+
21
+ function section() {
22
+ return slide().querySelector(":scope > section");
23
+ }
24
+
25
+ function blocks() {
26
+ return [...section().querySelectorAll("[data-block]")];
27
+ }
28
+
29
+ function toSlide(e) {
30
+ const rect = section().getBoundingClientRect();
31
+ return { x: (e.clientX - rect.left) / deck.scale, y: (e.clientY - rect.top) / deck.scale };
32
+ }
33
+
34
+ function readGeom(el) {
35
+ if (el.dataset.x == null) pin(el);
36
+ const { x, y, w, h } = el.dataset;
37
+ return { x: Number(x), y: Number(y), w: Number(w), h: Number(h) };
38
+ }
39
+
40
+ function pin(el) {
41
+ const outer = section().getBoundingClientRect();
42
+ const rect = el.getBoundingClientRect();
43
+ writeGeom(el, {
44
+ x: (rect.left - outer.left) / deck.scale,
45
+ y: (rect.top - outer.top) / deck.scale,
46
+ w: rect.width / deck.scale,
47
+ h: rect.height / deck.scale,
48
+ });
49
+ }
50
+
51
+ function writeGeom(el, g) {
52
+ for (const key of ["x", "y", "w", "h"]) {
53
+ if (g[key] == null) continue;
54
+ el.dataset[key] = String(Math.round(g[key]));
55
+ const prop = { x: "left", y: "top", w: "width", h: "height" }[key];
56
+ el.style[prop] = `${el.dataset[key]}px`;
57
+ }
58
+ }
59
+
60
+ function findBlock(slideName, id) {
61
+ return document.querySelector(
62
+ `.keyframes-slide[data-slide="${CSS.escape(slideName)}"] [data-block="${CSS.escape(id)}"]`,
63
+ );
64
+ }
65
+
66
+ async function save(slideName, changes) {
67
+ setStatus("Saving…");
68
+ try {
69
+ const response = await fetch("_api/geometry", {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ slide: slideName, blocks: changes }),
73
+ });
74
+ const result = await response.json();
75
+ if (!response.ok) throw new Error(result.error);
76
+ setStatus("Saved");
77
+ deck.dispatchEvent(new CustomEvent("saved", { detail: { slide: slideName } }));
78
+ } catch (e) {
79
+ setStatus("Save failed", true);
80
+ toast(`Save failed: ${e.message}`);
81
+ }
82
+ }
83
+
84
+ function snapshot(els) {
85
+ return Object.fromEntries(els.map((el) => [el.dataset.block, readGeom(el)]));
86
+ }
87
+
88
+ function commit(slideName, before, after) {
89
+ const changed = Object.keys(after).filter(
90
+ (id) => JSON.stringify(roundGeom(before[id])) !== JSON.stringify(roundGeom(after[id])),
91
+ );
92
+ if (!changed.length) return;
93
+ const pick = (g) => Object.fromEntries(changed.map((id) => [id, roundGeom(g[id])]));
94
+ state.history.push({ slide: slideName, before: pick(before), after: pick(after) });
95
+ state.future = [];
96
+ save(slideName, pick(after));
97
+ }
98
+
99
+ function roundGeom(g) {
100
+ return Object.fromEntries(Object.entries(g).map(([k, v]) => [k, Math.round(v)]));
101
+ }
102
+
103
+ function applyEntry(entry, side) {
104
+ const geoms = entry[side];
105
+ for (const [id, g] of Object.entries(geoms)) {
106
+ const el = findBlock(entry.slide, id);
107
+ if (el) writeGeom(el, g);
108
+ }
109
+ save(entry.slide, geoms);
110
+ const index = deck.slides.findIndex((s) => s.dataset.slide === entry.slide);
111
+ if (index !== deck.index) deck.goTo(index);
112
+ state.selection = Object.keys(geoms).map((id) => findBlock(entry.slide, id)).filter(Boolean);
113
+ render();
114
+ }
115
+
116
+ function undo() {
117
+ const entry = state.history.pop();
118
+ if (!entry) return toast("Nothing to undo");
119
+ state.future.push(entry);
120
+ applyEntry(entry, "before");
121
+ }
122
+
123
+ function redo() {
124
+ const entry = state.future.pop();
125
+ if (!entry) return toast("Nothing to redo");
126
+ state.history.push(entry);
127
+ applyEntry(entry, "after");
128
+ }
129
+
130
+ function snapTargets(exclude) {
131
+ const { width: W, height: H } = deck.config;
132
+ const xs = [0, W / 2, W];
133
+ const ys = [0, H / 2, H];
134
+ for (const el of blocks()) {
135
+ if (exclude.includes(el) || el.dataset.x == null || !el.offsetParent) continue;
136
+ const g = readGeom(el);
137
+ xs.push(g.x, g.x + g.w / 2, g.x + g.w);
138
+ ys.push(g.y, g.y + g.h / 2, g.y + g.h);
139
+ }
140
+ return { xs, ys };
141
+ }
142
+
143
+ function bestSnap(edges, targets, threshold) {
144
+ let best = null;
145
+ for (const edge of edges) {
146
+ for (const t of targets) {
147
+ const d = t - edge;
148
+ if (Math.abs(d) <= threshold && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, at: t };
149
+ }
150
+ }
151
+ return best;
152
+ }
153
+
154
+ function bounds(geoms) {
155
+ const list = Object.values(geoms);
156
+ const x = Math.min(...list.map((g) => g.x));
157
+ const y = Math.min(...list.map((g) => g.y));
158
+ return {
159
+ x,
160
+ y,
161
+ w: Math.max(...list.map((g) => g.x + g.w)) - x,
162
+ h: Math.max(...list.map((g) => g.y + g.h)) - y,
163
+ };
164
+ }
165
+
166
+ function startMove(e) {
167
+ const els = state.selection;
168
+ state.drag = {
169
+ kind: "move",
170
+ start: toSlide(e),
171
+ before: snapshot(els),
172
+ targets: snapTargets(els),
173
+ };
174
+ }
175
+
176
+ function startResize(e, handle) {
177
+ const el = state.selection[0];
178
+ state.drag = {
179
+ kind: "resize",
180
+ handle,
181
+ start: toSlide(e),
182
+ before: snapshot([el]),
183
+ targets: snapTargets([el]),
184
+ };
185
+ }
186
+
187
+ function startMarquee(e) {
188
+ state.drag = { kind: "marquee", start: toSlide(e), additive: e.shiftKey, base: [...state.selection] };
189
+ }
190
+
191
+ function onDrag(e) {
192
+ const drag = state.drag;
193
+ const p = toSlide(e);
194
+ const dx = p.x - drag.start.x;
195
+ const dy = p.y - drag.start.y;
196
+ const threshold = SNAP_PX / deck.scale;
197
+ const guides = [];
198
+
199
+ if (drag.kind === "move") {
200
+ const box = bounds(drag.before);
201
+ let ox = dx;
202
+ let oy = dy;
203
+ if (!e.altKey) {
204
+ const sx = bestSnap([box.x + dx, box.x + dx + box.w / 2, box.x + dx + box.w], drag.targets.xs, threshold);
205
+ const sy = bestSnap([box.y + dy, box.y + dy + box.h / 2, box.y + dy + box.h], drag.targets.ys, threshold);
206
+ if (sx) (ox += sx.d), guides.push({ x: sx.at });
207
+ if (sy) (oy += sy.d), guides.push({ y: sy.at });
208
+ }
209
+ if (e.shiftKey) Math.abs(dx) > Math.abs(dy) ? (oy = 0) : (ox = 0);
210
+ for (const el of state.selection) {
211
+ const g = drag.before[el.dataset.block];
212
+ writeGeom(el, { x: g.x + ox, y: g.y + oy });
213
+ }
214
+ drag.moved = drag.moved || Math.hypot(dx, dy) * deck.scale > 2;
215
+ label(e, `${Math.round(box.x + ox)}, ${Math.round(box.y + oy)}`);
216
+ }
217
+
218
+ if (drag.kind === "resize") {
219
+ const el = state.selection[0];
220
+ const g0 = drag.before[el.dataset.block];
221
+ const h = drag.handle;
222
+ let left = g0.x;
223
+ let top = g0.y;
224
+ let right = g0.x + g0.w;
225
+ let bottom = g0.y + g0.h;
226
+ if (h.includes("w")) left += dx;
227
+ if (h.includes("e")) right += dx;
228
+ if (h.includes("n")) top += dy;
229
+ if (h.includes("s")) bottom += dy;
230
+ if (!e.altKey) {
231
+ const snapEdge = (value, axis) => {
232
+ const s = bestSnap([value], drag.targets[axis], threshold);
233
+ if (!s) return value;
234
+ guides.push(axis === "xs" ? { x: s.at } : { y: s.at });
235
+ return value + s.d;
236
+ };
237
+ if (h.includes("w")) left = snapEdge(left, "xs");
238
+ if (h.includes("e")) right = snapEdge(right, "xs");
239
+ if (h.includes("n")) top = snapEdge(top, "ys");
240
+ if (h.includes("s")) bottom = snapEdge(bottom, "ys");
241
+ }
242
+ let w = Math.max(16, right - left);
243
+ let hh = Math.max(16, bottom - top);
244
+ if (e.shiftKey && h.length === 2) {
245
+ const ratio = g0.w / g0.h;
246
+ if (w / hh > ratio) hh = w / ratio;
247
+ else w = hh * ratio;
248
+ }
249
+ if (h.includes("w")) left = right - w;
250
+ if (h.includes("n")) top = bottom - hh;
251
+ writeGeom(el, { x: left, y: top, w, h: hh });
252
+ drag.moved = true;
253
+ label(e, `${Math.round(w)} × ${Math.round(hh)}`);
254
+ }
255
+
256
+ if (drag.kind === "marquee") {
257
+ const box = {
258
+ x: Math.min(p.x, drag.start.x),
259
+ y: Math.min(p.y, drag.start.y),
260
+ w: Math.abs(dx),
261
+ h: Math.abs(dy),
262
+ };
263
+ Object.assign(ui.marquee.style, {
264
+ display: "block",
265
+ left: `${box.x}px`,
266
+ top: `${box.y}px`,
267
+ width: `${box.w}px`,
268
+ height: `${box.h}px`,
269
+ });
270
+ const hit = blocks().filter((el) => {
271
+ if (!el.offsetParent) return false;
272
+ const g = readGeom(el);
273
+ return g.x < box.x + box.w && g.x + g.w > box.x && g.y < box.y + box.h && g.y + g.h > box.y;
274
+ });
275
+ state.selection = drag.additive ? [...new Set([...drag.base, ...hit])] : hit;
276
+ }
277
+
278
+ render(guides);
279
+ }
280
+
281
+ function endDrag() {
282
+ const drag = state.drag;
283
+ state.drag = null;
284
+ ui.marquee.style.display = "none";
285
+ ui.label.style.display = "none";
286
+ if (drag.kind !== "marquee" && drag.moved) {
287
+ const els = state.selection;
288
+ commit(slide().dataset.slide, drag.before, snapshot(els));
289
+ }
290
+ render();
291
+ }
292
+
293
+ function nudge(dx, dy) {
294
+ const els = state.selection;
295
+ if (!state.nudge) state.nudge = { before: snapshot(els), slide: slide().dataset.slide };
296
+ for (const el of els) {
297
+ const g = readGeom(el);
298
+ writeGeom(el, { x: g.x + dx, y: g.y + dy });
299
+ }
300
+ clearTimeout(state.nudge.timer);
301
+ state.nudge.timer = setTimeout(() => {
302
+ const { before, slide: name } = state.nudge;
303
+ state.nudge = null;
304
+ commit(name, before, snapshot(els));
305
+ }, 400);
306
+ render();
307
+ }
308
+
309
+ function setZ(delta) {
310
+ const els = state.selection;
311
+ if (!els.length) return;
312
+ const name = slide().dataset.slide;
313
+ const changes = {};
314
+ for (const el of els) {
315
+ const z = Math.max(0, Number(el.dataset.z || 0) + delta);
316
+ el.dataset.z = String(z);
317
+ el.style.zIndex = String(z);
318
+ changes[el.dataset.block] = { z };
319
+ }
320
+ save(name, changes);
321
+ toast(`z-index ${els.map((el) => el.dataset.z).join(", ")}`);
322
+ }
323
+
324
+ function copyRef() {
325
+ const name = slide().dataset.slide;
326
+ const ids = state.selection.map((el) => el.dataset.block);
327
+ const ref = ids.length
328
+ ? ids.map((id) => `slides/${name}.html#${id}`).join(" ")
329
+ : `slides/${name}.html`;
330
+ navigator.clipboard.writeText(ref).then(
331
+ () => toast(`Copied ${ref}`),
332
+ () => toast(ref),
333
+ );
334
+ }
335
+
336
+ function ensureOverlay() {
337
+ if (ui.overlay.parentElement !== slide()) slide().append(ui.overlay);
338
+ }
339
+
340
+ function render(guides = []) {
341
+ if (!state.on) return;
342
+ ensureOverlay();
343
+ state.selection = state.selection.filter((el) => el.isConnected && slide().contains(el));
344
+ ui.boxes.replaceChildren(
345
+ ...state.selection.map((el) => {
346
+ const g = readGeom(el);
347
+ const box = document.createElement("div");
348
+ box.className = "keyframes-ed-box";
349
+ Object.assign(box.style, { left: `${g.x}px`, top: `${g.y}px`, width: `${g.w}px`, height: `${g.h}px` });
350
+ if (state.selection.length === 1) {
351
+ for (const handle of HANDLES) {
352
+ const knob = document.createElement("div");
353
+ knob.className = `keyframes-ed-handle keyframes-ed-${handle}`;
354
+ knob.dataset.handle = handle;
355
+ box.append(knob);
356
+ }
357
+ }
358
+ return box;
359
+ }),
360
+ );
361
+ announceSelection();
362
+ ui.guides.replaceChildren(
363
+ ...guides.map((g) => {
364
+ const line = document.createElement("div");
365
+ line.className = g.x != null ? "keyframes-ed-guide keyframes-ed-vguide" : "keyframes-ed-guide keyframes-ed-hguide";
366
+ line.style[g.x != null ? "left" : "top"] = `${g.x ?? g.y}px`;
367
+ return line;
368
+ }),
369
+ );
370
+ renderToolbar();
371
+ }
372
+
373
+ function announceSelection() {
374
+ const ids = state.selection.map((el) => el.dataset.block);
375
+ if (ids.join() === deck.selection?.join()) return;
376
+ deck.selection = ids;
377
+ deck.dispatchEvent(new CustomEvent("selection", { detail: { slide: slide().dataset.slide, ids } }));
378
+ }
379
+
380
+ function renderToolbar() {
381
+ const sel = state.selection;
382
+ ui.slideName.textContent = `${deck.index + 1} · ${slide().dataset.slide}`;
383
+ ui.blockName.textContent = sel.length > 1 ? `${sel.length} blocks` : sel[0]?.dataset.block ?? "—";
384
+ const single = sel.length === 1 ? readGeom(sel[0]) : null;
385
+ for (const key of ["x", "y", "w", "h"]) {
386
+ const input = ui.inputs[key];
387
+ input.disabled = !single;
388
+ if (document.activeElement !== input) input.value = single ? Math.round(single[key]) : "";
389
+ }
390
+ }
391
+
392
+ function label(e, text) {
393
+ ui.label.textContent = text;
394
+ Object.assign(ui.label.style, { display: "block", left: `${e.clientX + 14}px`, top: `${e.clientY + 14}px` });
395
+ }
396
+
397
+ function setStatus(text, error = false) {
398
+ ui.status.textContent = text;
399
+ ui.status.classList.toggle("keyframes-ed-error", error);
400
+ }
401
+
402
+ function toast(text) {
403
+ ui.toast.textContent = text;
404
+ ui.toast.classList.add("keyframes-ed-show");
405
+ clearTimeout(ui.toast.timer);
406
+ ui.toast.timer = setTimeout(() => ui.toast.classList.remove("keyframes-ed-show"), 1800);
407
+ }
408
+
409
+ function toggle(on = !state.on) {
410
+ state.on = on;
411
+ document.documentElement.classList.toggle("keyframes-editing", on);
412
+ ui.toolbar.hidden = !on;
413
+ if (on) render();
414
+ else {
415
+ state.selection = [];
416
+ announceSelection();
417
+ ui.overlay.remove();
418
+ }
419
+ deck.dispatchEvent(new CustomEvent("editmode", { detail: { on } }));
420
+ }
421
+
422
+ function onPointerDown(e) {
423
+ if (!state.on || e.button !== 0 || e.target.closest(".keyframes-ed-toolbar")) return;
424
+ const handle = e.target.closest(".keyframes-ed-handle");
425
+ const block = e.target.closest("[data-block]");
426
+ if (!handle && block?.classList.contains("keyframes-ed-live")) return;
427
+ if (!handle && !slide().contains(e.target)) return;
428
+ e.preventDefault();
429
+ e.stopPropagation();
430
+ exitLive();
431
+ if (handle) startResize(e, handle.dataset.handle);
432
+ else if (block && slide().contains(block)) {
433
+ if (e.shiftKey) {
434
+ state.selection = state.selection.includes(block)
435
+ ? state.selection.filter((el) => el !== block)
436
+ : [...state.selection, block];
437
+ } else if (!state.selection.includes(block)) state.selection = [block];
438
+ startMove(e);
439
+ } else startMarquee(e);
440
+ render();
441
+ const move = (ev) => onDrag(ev);
442
+ const up = () => {
443
+ removeEventListener("pointermove", move);
444
+ removeEventListener("pointerup", up);
445
+ endDrag();
446
+ };
447
+ addEventListener("pointermove", move);
448
+ addEventListener("pointerup", up);
449
+ }
450
+
451
+ function enterLive(e) {
452
+ if (!state.on) return;
453
+ const block = e.target.closest("[data-block]");
454
+ if (!block || !slide().contains(block)) return;
455
+ exitLive();
456
+ block.classList.add("keyframes-ed-live");
457
+ toast(`Interacting with ${block.dataset.block} · Esc to return`);
458
+ }
459
+
460
+ function exitLive() {
461
+ for (const el of document.querySelectorAll(".keyframes-ed-live")) el.classList.remove("keyframes-ed-live");
462
+ }
463
+
464
+ function onKey(e) {
465
+ if (e.target.closest?.("input, textarea, [contenteditable], .cm-editor")) return;
466
+ const mod = e.metaKey || e.ctrlKey;
467
+ if (e.key === "e" && !mod) {
468
+ e.preventDefault();
469
+ return toggle();
470
+ }
471
+ if (!state.on) return;
472
+ const sel = state.selection;
473
+ const step = e.shiftKey ? 10 : 1;
474
+ const arrows = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
475
+ let handled = true;
476
+ if (arrows[e.key] && sel.length) nudge(...arrows[e.key]);
477
+ else if (mod && e.key.toLowerCase() === "z") e.shiftKey ? redo() : undo();
478
+ else if (mod && e.key === "y") redo();
479
+ else if (mod && e.key === "a") (state.selection = blocks()), render();
480
+ else if (e.key === "Escape") {
481
+ if (document.querySelector(".keyframes-ed-live")) exitLive();
482
+ else if (sel.length) (state.selection = []), render();
483
+ else toggle(false);
484
+ } else if (e.key === "]") setZ(1);
485
+ else if (e.key === "[") setZ(-1);
486
+ else if (e.key === "c" && !mod) copyRef();
487
+ else if (e.key === "?") toggleHelp();
488
+ else handled = false;
489
+ if (handled) {
490
+ e.preventDefault();
491
+ e.stopImmediatePropagation();
492
+ }
493
+ }
494
+
495
+ function onInput(key, input) {
496
+ const el = state.selection[0];
497
+ const value = Number(input.value);
498
+ if (!el || !Number.isFinite(value)) return;
499
+ const before = snapshot([el]);
500
+ writeGeom(el, { [key]: value });
501
+ commit(slide().dataset.slide, before, snapshot([el]));
502
+ render();
503
+ }
504
+
505
+ function toggleHelp() {
506
+ ui.help.hidden = !ui.help.hidden;
507
+ }
508
+
509
+ const HELP = `
510
+ <b>Layout editor</b>
511
+ <table>
512
+ <tr><td>E</td><td>toggle editor</td></tr>
513
+ <tr><td>drag</td><td>move · <kbd>Shift</kbd> lock axis · <kbd>Alt</kbd> no snapping</td></tr>
514
+ <tr><td>handles</td><td>resize · <kbd>Shift</kbd> keep aspect (corners)</td></tr>
515
+ <tr><td>Shift+click / drag on empty</td><td>multi-select</td></tr>
516
+ <tr><td>arrows</td><td>nudge 1px · <kbd>Shift</kbd> 10px</td></tr>
517
+ <tr><td>[ / ]</td><td>send back / bring forward</td></tr>
518
+ <tr><td>double-click</td><td>interact with a block (3D, sim, code)</td></tr>
519
+ <tr><td>C</td><td>copy block reference for your LLM prompt</td></tr>
520
+ <tr><td>R</td><td>insert block reference into the panel's terminal</td></tr>
521
+ <tr><td>\`</td><td>show / hide the side panel (source, Claude, Codex, shell)</td></tr>
522
+ <tr><td>⌘Z / ⌘⇧Z</td><td>undo / redo</td></tr>
523
+ <tr><td>Esc</td><td>deselect · exit</td></tr>
524
+ </table>`;
525
+
526
+ function buildUi() {
527
+ document.body.insertAdjacentHTML(
528
+ "beforeend",
529
+ `<div class="keyframes-ed-toolbar" hidden>
530
+ <span class="keyframes-ed-badge">EDIT</span>
531
+ <span class="keyframes-ed-slide"></span>
532
+ <span class="keyframes-ed-block"></span>
533
+ ${["x", "y", "w", "h"].map((k) => `<label>${k}<input data-key="${k}" type="number"></label>`).join("")}
534
+ <button data-action="back" title="Send back ([)">⤓</button>
535
+ <button data-action="front" title="Bring forward (])">⤒</button>
536
+ <button data-action="ref" title="Copy block reference (C)">ref</button>
537
+ <button data-action="help" title="Shortcuts (?)">?</button>
538
+ <span class="keyframes-ed-status">Saved</span>
539
+ </div>
540
+ <div class="keyframes-ed-help" hidden>${HELP}</div>
541
+ <div class="keyframes-ed-toast"></div>`,
542
+ );
543
+ ui.toolbar = document.querySelector(".keyframes-ed-toolbar");
544
+ ui.help = document.querySelector(".keyframes-ed-help");
545
+ ui.toast = document.querySelector(".keyframes-ed-toast");
546
+ ui.slideName = ui.toolbar.querySelector(".keyframes-ed-slide");
547
+ ui.blockName = ui.toolbar.querySelector(".keyframes-ed-block");
548
+ ui.status = ui.toolbar.querySelector(".keyframes-ed-status");
549
+ ui.inputs = Object.fromEntries(
550
+ [...ui.toolbar.querySelectorAll("input")].map((input) => [input.dataset.key, input]),
551
+ );
552
+ for (const [key, input] of Object.entries(ui.inputs)) {
553
+ input.addEventListener("change", () => onInput(key, input));
554
+ input.addEventListener("keydown", (e) => e.key === "Enter" && input.blur());
555
+ }
556
+ const actions = { back: () => setZ(-1), front: () => setZ(1), ref: copyRef, help: toggleHelp };
557
+ ui.toolbar.addEventListener("click", (e) => actions[e.target.dataset.action]?.());
558
+
559
+ ui.overlay = document.createElement("div");
560
+ ui.overlay.className = "keyframes-ed-overlay";
561
+ ui.overlay.innerHTML = `<div class="keyframes-ed-guides"></div><div class="keyframes-ed-boxes"></div><div class="keyframes-ed-marquee"></div>`;
562
+ ui.guides = ui.overlay.querySelector(".keyframes-ed-guides");
563
+ ui.boxes = ui.overlay.querySelector(".keyframes-ed-boxes");
564
+ ui.marquee = ui.overlay.querySelector(".keyframes-ed-marquee");
565
+ ui.label = document.createElement("div");
566
+ ui.label.className = "keyframes-ed-label";
567
+ document.body.append(ui.label);
568
+ }
569
+
570
+ function start() {
571
+ buildUi();
572
+ import("./panel.js").then((m) => m.createPanel(deck));
573
+ document.addEventListener("keydown", onKey, true);
574
+ document.addEventListener("pointerdown", onPointerDown, true);
575
+ document.addEventListener("dblclick", enterLive, true);
576
+ deck.addEventListener("change", () => {
577
+ exitLive();
578
+ state.selection = state.selection.filter((el) => slide().contains(el));
579
+ render();
580
+ });
581
+ deck.addEventListener("slidereplaced", ({ detail }) => {
582
+ if (detail.slide !== slide()) return;
583
+ state.selection = state.selection
584
+ .map((el) => detail.slide.querySelector(`[data-block="${CSS.escape(el.dataset.block)}"]`))
585
+ .filter(Boolean);
586
+ render();
587
+ });
588
+ if (sessionStorage.getItem("keyframes-editing") === "1") toggle(true);
589
+ addEventListener("beforeunload", () => sessionStorage.setItem("keyframes-editing", state.on ? "1" : "0"));
590
+ }
591
+
592
+ if (!params.has("embed") && !params.has("print") && !params.has("presenter")) {
593
+ document.documentElement.dataset.loaded ? start() : addEventListener("load", start);
594
+ }