@cesarvenzac/closeup 0.0.0-stage → 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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -2
  3. package/dist/actions.d.ts +27 -0
  4. package/dist/actions.js +13 -0
  5. package/dist/capture.d.ts +28 -0
  6. package/dist/capture.js +33 -0
  7. package/dist/compositor.d.ts +62 -0
  8. package/dist/compositor.js +164 -0
  9. package/dist/cursor.d.ts +49 -0
  10. package/dist/cursor.js +152 -0
  11. package/dist/cursors.d.ts +11 -0
  12. package/dist/cursors.js +24 -0
  13. package/dist/frame.d.ts +86 -0
  14. package/dist/frame.js +78 -0
  15. package/dist/hover.d.ts +23 -0
  16. package/dist/hover.js +89 -0
  17. package/dist/index.d.ts +24 -0
  18. package/dist/index.js +37 -0
  19. package/dist/path.d.ts +41 -0
  20. package/dist/path.js +65 -0
  21. package/dist/player.d.ts +39 -0
  22. package/dist/player.js +140 -0
  23. package/dist/pointer.d.ts +30 -0
  24. package/dist/pointer.js +134 -0
  25. package/dist/script.d.ts +75 -0
  26. package/dist/script.js +94 -0
  27. package/dist/selector.d.ts +22 -0
  28. package/dist/selector.js +83 -0
  29. package/dist/settings.d.ts +61 -0
  30. package/dist/settings.js +59 -0
  31. package/dist/shortcut.d.ts +23 -0
  32. package/dist/shortcut.js +48 -0
  33. package/dist/sounds.d.ts +22 -0
  34. package/dist/sounds.js +94 -0
  35. package/dist/stage.d.ts +12 -0
  36. package/dist/stage.js +53 -0
  37. package/dist/store.d.ts +4 -0
  38. package/dist/store.js +22 -0
  39. package/dist/take.d.ts +69 -0
  40. package/dist/take.js +220 -0
  41. package/dist/ui/editor.d.ts +90 -0
  42. package/dist/ui/editor.js +612 -0
  43. package/dist/ui/icons.d.ts +28 -0
  44. package/dist/ui/icons.js +28 -0
  45. package/dist/ui/panel.d.ts +83 -0
  46. package/dist/ui/panel.js +739 -0
  47. package/dist/ui/styles.d.ts +6 -0
  48. package/dist/ui/styles.js +183 -0
  49. package/package.json +36 -4
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The script editor, on the stage at the size the take is filmed: click where the cursor
3
+ * starts and where it goes, Figma's pen way (a click for a gentle arc, a drag to pull the curve's
4
+ * handles), drag points and handles to adjust them, and say what each point does. Points are
5
+ * tied to the element they're placed on (`selector.ts`), so they follow it if the layout moves.
6
+ * The curves drawn are the ones the player travels (`curve` in `player.ts`). Every change can be
7
+ * undone, and each is handed back to be saved.
8
+ */
9
+ import { toPage, toScreen } from "../frame.js";
10
+ import { curve, estimate, resolve } from "../player.js";
11
+ import { parseScript } from "../script.js";
12
+ import { anchorFor, describe, selectorFor } from "../selector.js";
13
+ import { ICONS } from "./icons.js";
14
+ /** How far the pointer goes before a press becomes a drag, in CSS pixels. */
15
+ const SLOP = 3;
16
+ /** What a new point does after arriving, by action: rests a little, longer after a click to show what it did. */
17
+ const NEW_STEP = {
18
+ move: { wait: 0.4 },
19
+ click: { hover: 0.3, wait: 0.8 },
20
+ type: { hover: 0.2, text: "", wait: 0.6 },
21
+ };
22
+ const ACTIONS = [
23
+ { action: "move", label: "Move", icon: ICONS.pointer },
24
+ { action: "click", label: "Click", icon: ICONS.pointer_click },
25
+ { action: "type", label: "Type", icon: ICONS.type, hint: "Type: clicks a text field, then types into it" },
26
+ ];
27
+ /** What typing can go into: a text input, a text area, or something editable. */
28
+ const TEXT_FIELD = "textarea, [contenteditable]:not([contenteditable='false']), input:not([type]), input[type='text'], input[type='search'], input[type='email'], input[type='url'], input[type='tel'], input[type='password'], input[type='number']";
29
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
30
+ const seconds = (value) => (value < 10 ? value.toFixed(1) : Math.round(value).toString());
31
+ const clamp01 = (value) => Math.max(0, Math.min(1, value));
32
+ export class Editor {
33
+ options;
34
+ element = document.createElement("div");
35
+ svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
36
+ labels = document.createElement("div");
37
+ info = document.createElement("div");
38
+ panel = document.createElement("div");
39
+ bar = document.createElement("div");
40
+ file = document.createElement("input");
41
+ script;
42
+ selected = null;
43
+ tool = "move";
44
+ drag = null;
45
+ aimed = null;
46
+ history = [];
47
+ future = [];
48
+ message = "";
49
+ frame = 0;
50
+ constructor(options) {
51
+ this.options = options;
52
+ this.script = options.script;
53
+ this.element.className = "editor";
54
+ this.svg.setAttribute("class", "paths");
55
+ this.labels.className = "labels";
56
+ this.info.className = "surface info";
57
+ this.panel.className = "surface options point";
58
+ this.panel.setAttribute("role", "group");
59
+ this.panel.setAttribute("aria-label", "Point");
60
+ this.bar.className = "surface bar";
61
+ this.bar.setAttribute("role", "toolbar");
62
+ this.bar.setAttribute("aria-label", "Script");
63
+ this.file.type = "file";
64
+ this.file.accept = ".json,application/json";
65
+ this.file.hidden = true;
66
+ this.element.append(this.svg, this.labels, this.info, this.panel, this.bar, this.file);
67
+ this.bar.innerHTML = `
68
+ <button data-edit="done" aria-label="Done" title="Done (Esc)">${ICONS.arrow_left}<span>Done</span></button>
69
+ <div class="sep"></div>
70
+ ${ACTIONS.map(({ action, label, icon, hint }) => `<button data-edit="tool" data-value="${action}" title="${hint ?? label}">${icon}<span>${label}</span></button>`).join("")}
71
+ <div class="sep"></div>
72
+ <button data-edit="undo" aria-label="Undo" title="Undo (⌘Z)">${ICONS.undo}</button>
73
+ <button data-edit="redo" aria-label="Redo" title="Redo (⇧⌘Z)">${ICONS.redo}</button>
74
+ <div class="sep"></div>
75
+ <button data-edit="import" aria-label="Import a script" title="Import a script">${ICONS.upload}</button>
76
+ <button data-edit="export" aria-label="Export the script" title="Export the script">${ICONS.download}</button>
77
+ <button data-edit="clear" aria-label="Clear the script" title="Clear the script">${ICONS.trash}</button>
78
+ <button class="go" data-edit="preview">${ICONS.play}<span>Preview</span></button>`;
79
+ this.svg.addEventListener("pointerdown", this.onDown);
80
+ this.svg.addEventListener("pointermove", this.onMove);
81
+ this.svg.addEventListener("pointerup", this.onUp);
82
+ this.svg.addEventListener("pointercancel", this.onUp);
83
+ this.svg.addEventListener("dblclick", this.onDouble);
84
+ this.svg.addEventListener("pointerleave", () => this.aim(null));
85
+ this.element.addEventListener("click", this.onClick);
86
+ this.panel.addEventListener("input", this.onInput);
87
+ this.file.addEventListener("change", this.onFile);
88
+ options.root.append(this.element);
89
+ this.render();
90
+ }
91
+ destroy() {
92
+ cancelAnimationFrame(this.frame);
93
+ this.element.remove();
94
+ }
95
+ /** Hidden while a preview plays, and shown again after. */
96
+ set hidden(hidden) {
97
+ this.element.hidden = hidden;
98
+ if (!hidden)
99
+ this.render();
100
+ }
101
+ /** Keys while editing; true when the editor used it. */
102
+ onKey(event) {
103
+ // In a field (the text, a slider), keys are the field's own; Escape leaves it.
104
+ const field = event.composedPath()[0];
105
+ const inField = field instanceof HTMLInputElement;
106
+ if (event.key === "Escape") {
107
+ if (inField)
108
+ field.blur();
109
+ else if (this.selected !== null)
110
+ this.select(null);
111
+ else
112
+ this.options.onDone();
113
+ return true;
114
+ }
115
+ if (inField)
116
+ return false;
117
+ const mod = event.metaKey || event.ctrlKey;
118
+ if (mod && event.key.toLowerCase() === "z") {
119
+ if (event.shiftKey)
120
+ this.redo();
121
+ else
122
+ this.undo();
123
+ return true;
124
+ }
125
+ if ((event.key === "Backspace" || event.key === "Delete") && this.selected !== null) {
126
+ this.remove(this.selected);
127
+ return true;
128
+ }
129
+ const nudge = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
130
+ if (nudge && this.selected !== null) {
131
+ const k = this.selected;
132
+ const step = event.shiftKey ? 10 : 1;
133
+ const place = this.place(k);
134
+ this.change(() => this.setTarget(k, this.targetAt({ x: place.x + nudge[0] * step * this.options.stage.scale, y: place.y + nudge[1] * step * this.options.stage.scale })));
135
+ return true;
136
+ }
137
+ return false;
138
+ }
139
+ /* ------------------------------------------------------------------ the script's points */
140
+ get count() {
141
+ return this.script ? this.script.steps.length + 1 : 0;
142
+ }
143
+ targetOf(k) {
144
+ return k === 0 ? this.script.start : this.script.steps[k - 1].to;
145
+ }
146
+ setTarget(k, target) {
147
+ if (k === 0)
148
+ this.script.start = target;
149
+ else
150
+ this.script.steps[k - 1].to = target;
151
+ }
152
+ outOf(k) {
153
+ return k === 0 ? this.script.out : this.script.steps[k - 1].out;
154
+ }
155
+ inOf(k) {
156
+ return k === 0 ? undefined : this.script.steps[k - 1].in;
157
+ }
158
+ setHandle(k, which, value) {
159
+ if (k === 0) {
160
+ if (which === "out")
161
+ this.script.out = value;
162
+ return;
163
+ }
164
+ const step = this.script.steps[k - 1];
165
+ if (value)
166
+ step[which] = value;
167
+ else
168
+ delete step[which];
169
+ }
170
+ screen = (p) => toScreen(this.options.stage, { x: scrollX, y: scrollY }, p);
171
+ /** Where point `k` is on screen now: on its element, as the player will find it. */
172
+ place(k) {
173
+ return resolve(this.targetOf(k), this.screen, this.options.stage.scale);
174
+ }
175
+ /** A target for a point on screen: where it is in the page, and the element it's on. */
176
+ targetAt(q) {
177
+ const page = toPage(this.options.stage, { x: scrollX, y: scrollY }, q);
178
+ const anchor = anchorFor(this.options.hit(q.x, q.y));
179
+ if (!anchor)
180
+ return { x: page.x, y: page.y };
181
+ const box = anchor.getBoundingClientRect();
182
+ const u = box.width ? clamp01((q.x - box.left) / box.width) : 0.5;
183
+ const v = box.height ? clamp01((q.y - box.top) / box.height) : 0.5;
184
+ return { x: page.x, y: page.y, on: { selector: selectorFor(anchor), u, v } };
185
+ }
186
+ /** The element a target follows, if it's still there. */
187
+ elementOf(target) {
188
+ if (!target.on)
189
+ return null;
190
+ try {
191
+ return document.querySelector(target.on.selector);
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ /* ------------------------------------------------------------------ changes, kept for undo */
198
+ change(fn) {
199
+ const before = JSON.stringify(this.script);
200
+ fn();
201
+ this.commit(before);
202
+ }
203
+ commit(before) {
204
+ if (before !== JSON.stringify(this.script)) {
205
+ this.history.push(before);
206
+ this.future = [];
207
+ this.options.onChange(this.script);
208
+ }
209
+ this.render();
210
+ }
211
+ restore(json) {
212
+ this.script = parseScript(JSON.parse(json));
213
+ if (this.selected !== null && this.selected >= this.count)
214
+ this.selected = this.count ? this.count - 1 : null;
215
+ this.options.onChange(this.script);
216
+ this.render();
217
+ }
218
+ undo() {
219
+ const previous = this.history.pop();
220
+ if (previous === undefined)
221
+ return;
222
+ this.future.push(JSON.stringify(this.script));
223
+ this.restore(previous);
224
+ }
225
+ redo() {
226
+ const next = this.future.pop();
227
+ if (next === undefined)
228
+ return;
229
+ this.history.push(JSON.stringify(this.script));
230
+ this.restore(next);
231
+ }
232
+ /** Adds a point at a spot on screen, after the selected one or at the end, doing what the tool says. */
233
+ add(q) {
234
+ const target = this.targetAt(q);
235
+ if (!this.script) {
236
+ this.script = { version: 1, start: target, steps: [] };
237
+ return 0;
238
+ }
239
+ const k = this.selected === null ? this.count : this.selected + 1;
240
+ const step = { to: target, action: this.tool, wait: 0, ...NEW_STEP[this.tool] };
241
+ this.script.steps.splice(k - 1, 0, step);
242
+ return k;
243
+ }
244
+ /** Removes point `k`; without the start, the first point becomes where the cursor starts. */
245
+ remove(k) {
246
+ this.change(() => {
247
+ const script = this.script;
248
+ if (k > 0)
249
+ script.steps.splice(k - 1, 1);
250
+ else if (script.steps.length) {
251
+ const [first, ...rest] = script.steps;
252
+ this.script = { version: 1, start: first.to, ...(first.out ? { out: first.out } : {}), steps: rest };
253
+ }
254
+ else
255
+ this.script = null;
256
+ this.selected = this.count ? Math.min(k, this.count - 1) : null;
257
+ if (this.selected === 0 && k > 0)
258
+ this.selected = k - 1;
259
+ });
260
+ }
261
+ select(k) {
262
+ this.selected = k;
263
+ if (k !== null && k > 0)
264
+ this.tool = this.script.steps[k - 1].action;
265
+ this.render();
266
+ }
267
+ /* ------------------------------------------------------------------ pointer */
268
+ point = (event) => ({ x: event.clientX, y: event.clientY });
269
+ onDown = (event) => {
270
+ if (event.button !== 0)
271
+ return;
272
+ event.preventDefault();
273
+ this.svg.setPointerCapture(event.pointerId);
274
+ const target = event.target;
275
+ const before = JSON.stringify(this.script);
276
+ const handle = target.closest("[data-handle]");
277
+ const node = target.closest("[data-node]");
278
+ if (handle) {
279
+ this.drag = { kind: "handle", k: Number(handle.dataset.node), which: handle.dataset.handle, before };
280
+ }
281
+ else if (node) {
282
+ const k = Number(node.dataset.node);
283
+ this.select(k);
284
+ this.drag = { kind: "node", k, from: this.point(event), moved: false, before };
285
+ }
286
+ else {
287
+ const k = this.add(this.point(event));
288
+ this.selected = k;
289
+ this.drag = { kind: "pull", k, from: this.point(event), before };
290
+ this.render();
291
+ }
292
+ };
293
+ onMove = (event) => {
294
+ const p = this.point(event);
295
+ const drag = this.drag;
296
+ if (!drag)
297
+ return this.aim(anchorFor(this.options.hit(p.x, p.y)));
298
+ const scale = this.options.stage.scale;
299
+ if (drag.kind === "node") {
300
+ if (!drag.moved && Math.hypot(p.x - drag.from.x, p.y - drag.from.y) < SLOP)
301
+ return;
302
+ drag.moved = true;
303
+ // Follows the pointer exactly while dragged; tied to what's under it on letting go.
304
+ const page = toPage(this.options.stage, { x: scrollX, y: scrollY }, p);
305
+ this.setTarget(drag.k, { x: page.x, y: page.y });
306
+ }
307
+ else {
308
+ const at = this.place(drag.k);
309
+ if (drag.kind === "pull" && Math.hypot(p.x - drag.from.x, p.y - drag.from.y) < SLOP)
310
+ return;
311
+ const handle = { x: (p.x - at.x) / scale, y: (p.y - at.y) / scale };
312
+ const mirror = { x: -handle.x, y: -handle.y };
313
+ if (drag.kind === "pull" || drag.which === "out") {
314
+ this.setHandle(drag.k, "out", handle);
315
+ if (drag.kind === "pull" || !event.altKey)
316
+ this.setHandle(drag.k, "in", drag.k > 0 ? mirror : undefined);
317
+ }
318
+ else {
319
+ this.setHandle(drag.k, "in", handle);
320
+ if (!event.altKey && this.outOf(drag.k))
321
+ this.setHandle(drag.k, "out", mirror);
322
+ }
323
+ }
324
+ this.schedule();
325
+ };
326
+ onUp = (event) => {
327
+ const drag = this.drag;
328
+ if (!drag)
329
+ return;
330
+ this.drag = null;
331
+ if (drag.kind === "node" && drag.moved)
332
+ this.setTarget(drag.k, this.targetAt(this.point(event)));
333
+ this.commit(drag.before);
334
+ };
335
+ /** A double click on a point turns its curve from handles to a gentle arc, or from an arc to smooth handles. */
336
+ onDouble = (event) => {
337
+ const node = event.target.closest("[data-node]");
338
+ if (!node)
339
+ return;
340
+ const k = Number(node.dataset.node);
341
+ this.change(() => {
342
+ if (this.inOf(k) || this.outOf(k)) {
343
+ this.setHandle(k, "in", undefined);
344
+ this.setHandle(k, "out", undefined);
345
+ return;
346
+ }
347
+ // Smooth: handles along the line from the point before to the point after.
348
+ const before = this.place(Math.max(0, k - 1));
349
+ const after = this.place(Math.min(this.count - 1, k + 1));
350
+ const scale = this.options.stage.scale;
351
+ const h = { x: (after.x - before.x) / 6 / scale, y: (after.y - before.y) / 6 / scale };
352
+ if (k > 0)
353
+ this.setHandle(k, "in", { x: -h.x, y: -h.y });
354
+ if (k < this.count - 1)
355
+ this.setHandle(k, "out", h);
356
+ });
357
+ };
358
+ /** Outlines what a point placed here would follow. */
359
+ aim(element) {
360
+ if (element === this.aimed)
361
+ return;
362
+ this.aimed = element;
363
+ this.schedule();
364
+ }
365
+ /** Redraws the curves and the summary on the next frame; the point's panel stays as it is, so its field keeps focus. */
366
+ schedule() {
367
+ cancelAnimationFrame(this.frame);
368
+ this.frame = requestAnimationFrame(() => {
369
+ this.renderPaths();
370
+ this.renderInfo();
371
+ });
372
+ }
373
+ /* ------------------------------------------------------------------ controls */
374
+ onClick = (event) => {
375
+ const button = event.target.closest("[data-edit]");
376
+ if (!button)
377
+ return;
378
+ const { edit, value } = button.dataset;
379
+ switch (edit) {
380
+ case "done":
381
+ return this.options.onDone();
382
+ case "tool": {
383
+ this.tool = value;
384
+ const k = this.selected;
385
+ if (k !== null && k > 0) {
386
+ this.change(() => {
387
+ const step = this.script.steps[k - 1];
388
+ if (step.action === this.tool)
389
+ return;
390
+ step.action = this.tool;
391
+ delete step.hover;
392
+ delete step.text;
393
+ Object.assign(step, { ...NEW_STEP[this.tool], wait: step.wait });
394
+ });
395
+ }
396
+ else
397
+ this.render();
398
+ return;
399
+ }
400
+ case "undo":
401
+ return this.undo();
402
+ case "redo":
403
+ return this.redo();
404
+ case "import":
405
+ return this.file.click();
406
+ case "export":
407
+ return this.export();
408
+ case "clear":
409
+ return this.change(() => {
410
+ this.script = null;
411
+ this.selected = null;
412
+ });
413
+ case "preview":
414
+ if (this.script?.steps.length)
415
+ this.options.onPreview(this.script);
416
+ return;
417
+ case "delete":
418
+ if (this.selected !== null)
419
+ this.remove(this.selected);
420
+ return;
421
+ case "curve": {
422
+ const k = this.selected;
423
+ if (k === null || k === 0)
424
+ return;
425
+ // The curve arriving at the point: a gentle arc (no handles), or a straight line (handles at the points).
426
+ return this.change(() => {
427
+ const straight = value === "straight" ? { x: 0, y: 0 } : undefined;
428
+ this.setHandle(k, "in", straight);
429
+ this.setHandle(k - 1, "out", straight);
430
+ });
431
+ }
432
+ }
433
+ };
434
+ /** Sliders and the text field change the selected point as they move; the change is kept once. */
435
+ pending = null;
436
+ onInput = (event) => {
437
+ const input = event.target;
438
+ const k = this.selected;
439
+ if (k === null || k === 0 || !input.dataset.field)
440
+ return;
441
+ this.pending ??= JSON.stringify(this.script);
442
+ const step = this.script.steps[k - 1];
443
+ const field = input.dataset.field;
444
+ if (field === "text")
445
+ step.text = input.value;
446
+ else {
447
+ const value = Number(input.value);
448
+ if (field === "duration" && value === 0)
449
+ delete step.duration;
450
+ else
451
+ step[field] = value;
452
+ const output = this.panel.querySelector(`output[data-for="${field}"]`);
453
+ if (output)
454
+ output.textContent = field === "duration" && value === 0 ? "Auto" : `${seconds(value)} s`;
455
+ }
456
+ this.options.onChange(this.script);
457
+ this.renderPaths();
458
+ this.renderInfo();
459
+ clearTimeout(this.settle);
460
+ this.settle = window.setTimeout(() => {
461
+ if (this.pending !== null) {
462
+ this.history.push(this.pending);
463
+ this.future = [];
464
+ this.pending = null;
465
+ }
466
+ }, 400);
467
+ };
468
+ settle = 0;
469
+ onFile = async () => {
470
+ const file = this.file.files?.[0];
471
+ this.file.value = "";
472
+ if (!file)
473
+ return;
474
+ try {
475
+ const script = parseScript(JSON.parse(await file.text()));
476
+ if (!script)
477
+ throw new Error();
478
+ this.change(() => {
479
+ this.script = script;
480
+ this.selected = null;
481
+ });
482
+ this.message = "";
483
+ }
484
+ catch {
485
+ this.message = "That file isn't a Closeup script.";
486
+ this.render();
487
+ }
488
+ };
489
+ export() {
490
+ if (!this.script)
491
+ return;
492
+ const blob = new Blob([JSON.stringify(this.script, null, 2)], { type: "application/json" });
493
+ const link = document.createElement("a");
494
+ link.href = URL.createObjectURL(blob);
495
+ const page = location.pathname.replace(/^\/|\/$/g, "").replace(/\//g, "-") || "home";
496
+ link.download = `closeup-${page}.json`;
497
+ link.click();
498
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
499
+ }
500
+ /* ------------------------------------------------------------------ drawing */
501
+ render() {
502
+ this.renderPaths();
503
+ this.renderInfo();
504
+ this.renderPanel();
505
+ this.renderBar();
506
+ }
507
+ renderPaths() {
508
+ const scale = this.options.stage.scale;
509
+ const places = Array.from({ length: this.count }, (_, k) => this.place(k));
510
+ const parts = [];
511
+ if (this.aimed && !this.drag) {
512
+ const box = this.aimed.getBoundingClientRect();
513
+ parts.push(`<rect class="aim" x="${box.left}" y="${box.top}" width="${box.width}" height="${box.height}" rx="3"/>`);
514
+ }
515
+ for (let k = 1; k < places.length; k++) {
516
+ const [a, b] = curve(places[k - 1], places[k], this.outOf(k - 1), this.inOf(k), scale);
517
+ const c1 = { x: a.at.x + a.out.x, y: a.at.y + a.out.y };
518
+ const c2 = { x: b.at.x + b.in.x, y: b.at.y + b.in.y };
519
+ parts.push(`<path class="segment" d="M${a.at.x} ${a.at.y}C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${b.at.x} ${b.at.y}"/>`);
520
+ }
521
+ const k = this.selected;
522
+ if (k !== null && k < places.length) {
523
+ const at = places[k];
524
+ for (const which of ["in", "out"]) {
525
+ const handle = which === "in" ? this.inOf(k) : this.outOf(k);
526
+ if (!handle || (!handle.x && !handle.y))
527
+ continue;
528
+ const h = { x: at.x + handle.x * scale, y: at.y + handle.y * scale };
529
+ parts.push(`<line class="handle-line" x1="${at.x}" y1="${at.y}" x2="${h.x}" y2="${h.y}"/>`);
530
+ parts.push(`<circle class="handle" data-node="${k}" data-handle="${which}" cx="${h.x}" cy="${h.y}" r="4.5"/>`);
531
+ }
532
+ }
533
+ places.forEach((at, n) => {
534
+ const classes = ["node", n === 0 ? "start" : "", n === this.selected ? "selected" : ""].filter(Boolean).join(" ");
535
+ parts.push(`<g class="${classes}" data-node="${n}"><circle cx="${at.x}" cy="${at.y}" r="${n === 0 ? 7 : 6}"/>${n === 0 ? `<circle class="dot" cx="${at.x}" cy="${at.y}" r="2.5"/>` : ""}</g>`);
536
+ });
537
+ this.svg.innerHTML = parts.join("");
538
+ this.labels.innerHTML = places
539
+ .map((at, n) => {
540
+ const step = n > 0 ? this.script.steps[n - 1] : null;
541
+ const what = !step ? "Start" : `${n} · ${ACTIONS.find((a) => a.action === step.action).label}${step.action === "type" && step.text ? ` “${escapeHtml(step.text.slice(0, 18))}${step.text.length > 18 ? "…" : ""}”` : ""}`;
542
+ return `<div class="node-label${n === this.selected ? " selected" : ""}" style="translate:${Math.round(at.x + 10)}px ${Math.round(at.y - 30)}px">${what}</div>`;
543
+ })
544
+ .join("");
545
+ }
546
+ renderInfo() {
547
+ if (this.selected !== null) {
548
+ this.info.hidden = true;
549
+ return;
550
+ }
551
+ this.info.hidden = false;
552
+ const keys = `<span class="keys"><kbd>⌫</kbd> deletes · <kbd>Esc</kbd> done</span>`;
553
+ let text;
554
+ if (this.message)
555
+ text = `<span class="warn">${escapeHtml(this.message)}</span>`;
556
+ else if (!this.script)
557
+ text = `<span class="what">Click where the cursor starts</span>`;
558
+ else if (!this.script.steps.length)
559
+ text = `<span class="what">Click where it goes · drag to curve</span>`;
560
+ else {
561
+ const total = estimate(this.script, (target) => resolve(target, this.screen, this.options.stage.scale), this.options.stage.scale);
562
+ const moves = this.script.steps.length;
563
+ text = `<span class="size">${moves} ${moves === 1 ? "move" : "moves"} · about ${seconds(total)} s</span><span class="what">Click to add · drag to curve</span>${keys}`;
564
+ }
565
+ this.info.innerHTML = text;
566
+ }
567
+ renderPanel() {
568
+ const k = this.selected;
569
+ if (k === null || !this.script) {
570
+ this.panel.hidden = true;
571
+ return;
572
+ }
573
+ this.panel.hidden = false;
574
+ const target = this.targetOf(k);
575
+ const element = this.elementOf(target);
576
+ const on = element ? escapeHtml(describe(element).slice(0, 40)) : "a place on the page";
577
+ const row = (name, body) => `<div class="row"><span class="name">${name}</span>${body}</div>`;
578
+ const slider = (field, label, max, step, value, show) => `<div class="slider"><input type="range" min="0" max="${max}" step="${step}" value="${value}" data-field="${field}" aria-label="${label}"><output data-for="${field}">${show}</output></div>`;
579
+ const head = `<div class="row"><span class="name">${k === 0 ? "Start" : `Point ${k}`}</span><div class="choices spread"><span class="what" title="${on}">On ${on}</span><button data-edit="delete" aria-label="Delete this point" title="Delete (⌫)">${ICONS.trash}</button></div></div>`;
580
+ if (k === 0) {
581
+ this.panel.innerHTML = `${head}<p class="hint">Where the cursor is when the take begins.</p>`;
582
+ return;
583
+ }
584
+ const step = this.script.steps[k - 1];
585
+ const custom = !!(step.in && (step.in.x || step.in.y)) || !!(this.outOf(k - 1) && (this.outOf(k - 1).x || this.outOf(k - 1).y));
586
+ const straight = !!step.in && !step.in.x && !step.in.y;
587
+ // Typing goes into what the click focused: say so when there's no field under the point.
588
+ const place = this.place(k);
589
+ const field = step.action === "type" && !!this.options.hit(place.x, place.y)?.closest(TEXT_FIELD);
590
+ this.panel.innerHTML = [
591
+ head,
592
+ step.action === "type" ? row("Text", `<input class="text" type="text" value="${escapeHtml(step.text ?? "")}" data-field="text" aria-label="Text to type" placeholder="What to type">`) : "",
593
+ step.action === "type" && !field ? `<p class="hint warn">Typing goes into the text field this point clicks: there's none under it. Put it on a field (a search box, a form) to see the text written.</p>` : "",
594
+ step.action !== "move" ? row("Hover", slider("hover", "Hover before acting", 3, 0.1, step.hover ?? 0, `${seconds(step.hover ?? 0)} s`)) : "",
595
+ row("Rest", slider("wait", "Rest after", 5, 0.1, step.wait, `${seconds(step.wait)} s`)),
596
+ row("Pace", slider("duration", "Seconds the move takes, or Auto", 3, 0.05, step.duration ?? 0, step.duration ? `${seconds(step.duration)} s` : "Auto")),
597
+ row("Curve", `<div class="choices"><button data-edit="curve" data-value="arc" aria-pressed="${!custom && !straight}">Arc</button><button data-edit="curve" data-value="straight" aria-pressed="${straight}">Straight</button>${custom && !straight ? `<span class="note">Drawn</span>` : ""}</div>`),
598
+ ].join("");
599
+ }
600
+ renderBar() {
601
+ const k = this.selected;
602
+ const action = k !== null && k > 0 ? this.script.steps[k - 1].action : this.tool;
603
+ // With the start selected (it does nothing), the tools say what the next point will do.
604
+ for (const button of this.bar.querySelectorAll("[data-edit='tool']"))
605
+ button.setAttribute("aria-pressed", String(button.dataset.value === action));
606
+ this.bar.querySelector("[data-edit='undo']").disabled = !this.history.length;
607
+ this.bar.querySelector("[data-edit='redo']").disabled = !this.future.length;
608
+ this.bar.querySelector("[data-edit='export']").disabled = !this.script;
609
+ this.bar.querySelector("[data-edit='clear']").disabled = !this.script;
610
+ this.bar.querySelector("[data-edit='preview']").disabled = !this.script?.steps.length;
611
+ }
612
+ }
@@ -0,0 +1,28 @@
1
+ /** Icons from Lucide (lucide.dev, ISC licence), as SVG markup on its 24-unit grid. */
2
+ export declare const svg: (body: string) => string;
3
+ export declare const ICONS: {
4
+ x: string;
5
+ crosshair: string;
6
+ square_dashed: string;
7
+ camera: string;
8
+ video: string;
9
+ sliders_horizontal: string;
10
+ square: string;
11
+ download: string;
12
+ copy: string;
13
+ rotate_ccw: string;
14
+ image: string;
15
+ check: string;
16
+ chevron_down: string;
17
+ circle: string;
18
+ spline: string;
19
+ play: string;
20
+ undo: string;
21
+ redo: string;
22
+ trash: string;
23
+ upload: string;
24
+ pointer: string;
25
+ pointer_click: string;
26
+ type: string;
27
+ arrow_left: string;
28
+ };
@@ -0,0 +1,28 @@
1
+ /** Icons from Lucide (lucide.dev, ISC licence), as SVG markup on its 24-unit grid. */
2
+ export const svg = (body) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
3
+ export const ICONS = {
4
+ x: svg(`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`),
5
+ crosshair: svg(`<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>`),
6
+ square_dashed: svg(`<path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/>`),
7
+ camera: svg(`<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/>`),
8
+ video: svg(`<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"/><rect x="2" y="6" width="14" height="12" rx="2"/>`),
9
+ sliders_horizontal: svg(`<path d="M10 5H3"/><path d="M12 19H3"/><path d="M14 3v4"/><path d="M16 17v4"/><path d="M21 12h-9"/><path d="M21 19h-5"/><path d="M21 5h-7"/><path d="M8 10v4"/><path d="M8 12H3"/>`),
10
+ square: svg(`<rect width="18" height="18" x="3" y="3" rx="2"/>`),
11
+ download: svg(`<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>`),
12
+ copy: svg(`<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>`),
13
+ rotate_ccw: svg(`<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>`),
14
+ image: svg(`<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>`),
15
+ check: svg(`<path d="M20 6 9 17l-5-5"/>`),
16
+ chevron_down: svg(`<path d="m6 9 6 6 6-6"/>`),
17
+ circle: svg(`<circle cx="12" cy="12" r="10"/>`),
18
+ spline: svg(`<circle cx="19" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><path d="M5 17A12 12 0 0 1 17 5"/>`),
19
+ play: svg(`<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>`),
20
+ undo: svg(`<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/>`),
21
+ redo: svg(`<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5A5.5 5.5 0 0 0 9.5 20H13"/>`),
22
+ trash: svg(`<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>`),
23
+ upload: svg(`<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>`),
24
+ pointer: svg(`<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/>`),
25
+ pointer_click: svg(`<path d="M14 4.1 12 6"/><path d="m5.1 8-2.9-.8"/><path d="m6 12-1.9 2"/><path d="M7.2 2.2 8 5.1"/><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"/>`),
26
+ type: svg(`<path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/>`),
27
+ arrow_left: svg(`<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>`),
28
+ };