flowsvg 0.1.1 → 0.2.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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/PATTERNS.md +61 -5
  3. package/README.md +149 -461
  4. package/README.zh-CN.md +149 -461
  5. package/dist/adapters/react.d.ts +7 -0
  6. package/dist/adapters/react.js +14 -2
  7. package/dist/adapters/vue.d.ts +29 -3
  8. package/dist/adapters/vue.js +20 -4
  9. package/dist/adapters/web-component.d.ts +14 -2
  10. package/dist/adapters/web-component.js +32 -3
  11. package/dist/browser/controls.d.ts +7 -3
  12. package/dist/browser/controls.js +23 -31
  13. package/dist/browser/export-controls.d.ts +32 -0
  14. package/dist/browser/export-controls.js +151 -0
  15. package/dist/browser/player.d.ts +9 -1
  16. package/dist/browser/player.js +83 -24
  17. package/dist/browser/select-control.d.ts +33 -0
  18. package/dist/browser/select-control.js +171 -0
  19. package/dist/browser.d.ts +2 -1
  20. package/dist/browser.js +2 -1
  21. package/dist/core/diagram.d.ts +4 -2
  22. package/dist/core/diagram.js +111 -21
  23. package/dist/core/edge-geometry.d.ts +16 -3
  24. package/dist/core/edge-geometry.js +264 -37
  25. package/dist/core/model.d.ts +35 -9
  26. package/dist/core/node-content-measurement.d.ts +34 -0
  27. package/dist/core/node-content-measurement.js +66 -0
  28. package/dist/core/node-content.d.ts +17 -0
  29. package/dist/core/node-content.js +30 -6
  30. package/dist/core/timeline.d.ts +11 -3
  31. package/dist/core/timeline.js +29 -5
  32. package/dist/core/validation.d.ts +1 -1
  33. package/dist/core/validation.js +13 -3
  34. package/dist/export/browser.d.ts +9 -0
  35. package/dist/export/browser.js +172 -0
  36. package/dist/export/captions.d.ts +18 -0
  37. package/dist/export/captions.js +44 -0
  38. package/dist/export/plan.d.ts +25 -0
  39. package/dist/export/plan.js +94 -0
  40. package/dist/export/types.d.ts +64 -0
  41. package/dist/export/types.js +9 -0
  42. package/dist/export/worker-source.d.ts +1 -0
  43. package/dist/export/worker-source.js +1 -0
  44. package/dist/export.d.ts +3 -0
  45. package/dist/export.js +2 -0
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.js +1 -0
  48. package/dist/node/cli.d.ts +2 -0
  49. package/dist/node/cli.js +72 -0
  50. package/dist/node/gif.d.ts +16 -0
  51. package/dist/node/gif.js +184 -0
  52. package/dist/node/html.d.ts +19 -0
  53. package/dist/node/html.js +61 -0
  54. package/dist/node/output.d.ts +2 -0
  55. package/dist/node/output.js +22 -0
  56. package/dist/node.d.ts +6 -0
  57. package/dist/node.js +3 -0
  58. package/dist/portable/LICENSE.esbuild.txt +21 -0
  59. package/dist/portable/LICENSE.flowsvg.txt +21 -0
  60. package/dist/portable/LICENSE.gifenc.txt +21 -0
  61. package/dist/portable/export.js +5 -0
  62. package/dist/portable/manifest.json +36 -0
  63. package/dist/portable/player.js +2 -0
  64. package/dist/portable/styles.css +227 -0
  65. package/dist/styles.css +194 -14
  66. package/docs/images/flowsvg-wordmark.gif +0 -0
  67. package/package.json +37 -6
@@ -0,0 +1,151 @@
1
+ const MAX_FILENAME_LENGTH = 120;
2
+ /** Own the visible export state and one cancellable task; destroy silences asynchronous results and releases downloads. */
3
+ export class GifExportControls {
4
+ owner;
5
+ captureInput;
6
+ element;
7
+ startButton;
8
+ cancelButton;
9
+ status;
10
+ progress;
11
+ task;
12
+ destroyed = false;
13
+ downloadUrls = new Map();
14
+ constructor(owner, captureInput) {
15
+ this.owner = owner;
16
+ this.captureInput = captureInput;
17
+ this.element = owner.createElement("span");
18
+ this.element.className = "flowsvg-export-controls";
19
+ this.startButton = owner.createElement("button");
20
+ this.startButton.type = "button";
21
+ this.startButton.textContent = "Export GIF";
22
+ this.startButton.setAttribute("aria-label", "Export GIF");
23
+ this.cancelButton = owner.createElement("button");
24
+ this.cancelButton.type = "button";
25
+ this.cancelButton.textContent = "Cancel export";
26
+ this.cancelButton.hidden = true;
27
+ this.progress = owner.createElement("progress");
28
+ this.progress.setAttribute("aria-label", "GIF export progress");
29
+ this.progress.hidden = true;
30
+ this.status = owner.createElement("span");
31
+ this.status.setAttribute("role", "status");
32
+ this.status.setAttribute("aria-live", "polite");
33
+ this.element.append(this.startButton, this.cancelButton, this.progress, this.status);
34
+ this.startButton.addEventListener("click", this.start);
35
+ this.cancelButton.addEventListener("click", this.cancel);
36
+ }
37
+ destroy() {
38
+ this.destroyed = true;
39
+ this.task?.abort();
40
+ this.startButton.removeEventListener("click", this.start);
41
+ this.cancelButton.removeEventListener("click", this.cancel);
42
+ for (const [url, timer] of this.downloadUrls) {
43
+ clearTimeout(timer);
44
+ URL.revokeObjectURL(url);
45
+ }
46
+ this.downloadUrls.clear();
47
+ }
48
+ /** Reserve the slot and capture input before loading code so repeated clicks and document loads cannot race the import. */
49
+ start = () => {
50
+ if (this.destroyed || this.task)
51
+ return;
52
+ const controller = new AbortController();
53
+ this.task = controller;
54
+ const input = this.captureInput();
55
+ this.startButton.disabled = true;
56
+ this.cancelButton.hidden = false;
57
+ this.progress.hidden = false;
58
+ this.progress.removeAttribute("value");
59
+ this.status.textContent = "Preparing GIF…";
60
+ void this.run(controller, input);
61
+ };
62
+ cancel = () => {
63
+ this.task?.abort();
64
+ };
65
+ async run(controller, input) {
66
+ try {
67
+ const { exportGif } = await loadExporter(controller.signal);
68
+ if (!this.isActive(controller))
69
+ return;
70
+ const bytes = await exportGif(input.document, {
71
+ theme: input.theme,
72
+ signal: controller.signal,
73
+ onProgress: (progress) => {
74
+ if (this.isActive(controller))
75
+ this.renderProgress(progress);
76
+ }
77
+ });
78
+ if (!this.isActive(controller))
79
+ return;
80
+ this.download(bytes, input.document.title);
81
+ this.status.textContent = "GIF export complete.";
82
+ }
83
+ catch (error) {
84
+ if (this.destroyed || this.task !== controller)
85
+ return;
86
+ this.status.textContent = controller.signal.aborted
87
+ ? "GIF export cancelled."
88
+ : `GIF export failed: ${error instanceof Error ? error.message : String(error)}`;
89
+ }
90
+ finally {
91
+ // A cancelled import may return without throwing; always release its slot, but never update destroyed UI.
92
+ if (this.task === controller) {
93
+ this.task = undefined;
94
+ if (!this.destroyed) {
95
+ if (controller.signal.aborted)
96
+ this.status.textContent = "GIF export cancelled.";
97
+ this.startButton.disabled = false;
98
+ this.cancelButton.hidden = true;
99
+ this.progress.hidden = true;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ isActive(controller) {
105
+ return !this.destroyed && this.task === controller && !controller.signal.aborted;
106
+ }
107
+ renderProgress(progress) {
108
+ this.progress.max = Math.max(1, progress.total);
109
+ this.progress.value = progress.completed;
110
+ const labels = { preparing: "Preparing", sampling: "Sampling", encoding: "Encoding", complete: "Completing" };
111
+ this.status.textContent = `${labels[progress.phase]} GIF: ${progress.completed}/${progress.total}`;
112
+ }
113
+ download(bytes, title) {
114
+ const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: "image/gif" }));
115
+ const link = this.owner.createElement("a");
116
+ link.href = url;
117
+ link.download = downloadFilename(title);
118
+ try {
119
+ this.owner.body.append(link);
120
+ link.click();
121
+ }
122
+ finally {
123
+ link.remove();
124
+ // Defer revocation until the browser consumes the click. Destruction also releases pending download URLs.
125
+ const timer = setTimeout(() => { URL.revokeObjectURL(url); this.downloadUrls.delete(url); }, 0);
126
+ this.downloadUrls.set(url, timer);
127
+ }
128
+ }
129
+ }
130
+ /** Keep titles as filename data, including on Windows, and retain the extension within a bounded name. */
131
+ function downloadFilename(title) {
132
+ const name = (title ?? "").replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, "_").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, MAX_FILENAME_LENGTH);
133
+ const safeName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name) ? `_${name}` : name;
134
+ return `${safeName || "FlowSVG"}.gif`;
135
+ }
136
+ /** Abort waiting for a module fetch as well as an active encoder; late module resolution cannot retain the task slot. */
137
+ async function loadExporter(signal) {
138
+ signal.throwIfAborted();
139
+ let stopListening = () => { };
140
+ const cancelled = new Promise((_resolve, reject) => {
141
+ const onAbort = () => reject(signal.reason);
142
+ signal.addEventListener("abort", onAbort, { once: true });
143
+ stopListening = () => signal.removeEventListener("abort", onAbort);
144
+ });
145
+ try {
146
+ return await Promise.race([import("../export.js"), cancelled]);
147
+ }
148
+ finally {
149
+ stopListening();
150
+ }
151
+ }
@@ -2,6 +2,10 @@ import type { DiagramDocument, DiagramFrameState, DiagramPoint, EvaluateOptions,
2
2
  /** Creation options: controls default on, motion follows browser preferences, and theme falls back to the document/light. */
3
3
  export interface PlayerOptions extends EvaluateOptions {
4
4
  controls?: boolean;
5
+ /** Defaults to false. Wrap positive-duration playback; manual seek and step keep their explicit position. */
6
+ loop?: boolean;
7
+ /** Defaults to false. GIF controls require visible playback controls; exports use the full timeline at 1×. */
8
+ gifExport?: boolean;
5
9
  /** Hover highlights borders by default; true also paints the background and adjusts content ink. Applies to groups too. */
6
10
  hoverFill?: boolean;
7
11
  }
@@ -10,6 +14,7 @@ export interface PlayerState {
10
14
  timeMs: number;
11
15
  totalDuration: number;
12
16
  playing: boolean;
17
+ loop: boolean;
13
18
  speed: number;
14
19
  frame: DiagramFrameState;
15
20
  }
@@ -24,7 +29,7 @@ export interface PlayerInteractionDetail extends HitObject {
24
29
  export interface FlowPlayer {
25
30
  /** The supplied host, also used as the CustomEvent dispatch target. */
26
31
  readonly element: HTMLElement;
27
- /** Play from the current position; do not start for static diagrams, at the end, or while the page is hidden. */
32
+ /** Play from the current position; loop mode restarts at the end. Static or page-hidden players cannot start. */
28
33
  play(): void;
29
34
  /** Stop time advancement, retaining the current frame. */
30
35
  pause(): void;
@@ -34,6 +39,8 @@ export interface FlowPlayer {
34
39
  step(direction: 1 | -1): void;
35
40
  /** Set a finite positive speed multiplier; ignore invalid values. */
36
41
  setSpeed(speed: number): void;
42
+ /** Change live loop mode without changing playback or position; emit loopchange only when the value changes. */
43
+ setLoop(loop: boolean): void;
37
44
  /** Compile before replacing; reset/pause at zero on success, or emit error and retain the old document/time/playback. */
38
45
  load(document: DiagramDocument): void;
39
46
  /** Read current player state; the last state remains readable after destruction. */
@@ -54,6 +61,7 @@ export type PlayerEventMap = {
54
61
  stepchange: CustomEvent<PlayerState>;
55
62
  timeupdate: CustomEvent<PlayerState>;
56
63
  playstatechange: CustomEvent<PlayerState>;
64
+ loopchange: CustomEvent<PlayerState>;
57
65
  error: CustomEvent<PlayerErrorDetail>;
58
66
  hover: CustomEvent<PlayerInteractionDetail | null>;
59
67
  click: CustomEvent<PlayerInteractionDetail | null>;
@@ -2,6 +2,7 @@
2
2
  * Browser player: own the clock, DOM, controls and input, forwarding time and visual options to the core.
3
3
  * Importing does not access the DOM; createPlayer allocates mount resources and destroy releases them.
4
4
  */
5
+ import { GifExportControls } from "./export-controls.js";
5
6
  import { paintContentHighlight } from "../core/node-content-paint.js";
6
7
  import { updateSvgDom } from "./svg-dom.js";
7
8
  import { attachPlayerLifecycle } from "./lifecycle.js";
@@ -25,10 +26,14 @@ export function createPlayer(host, document, options = {}) {
25
26
  class BrowserPlayer {
26
27
  element;
27
28
  diagram;
29
+ sourceDocument;
30
+ gifControls;
28
31
  options;
29
32
  timeMs = 0;
30
33
  speed = 1;
31
34
  playing = false;
35
+ loop;
36
+ playbackIntent = 0;
32
37
  destroyed = false;
33
38
  frameHandle;
34
39
  lastTimestamp;
@@ -47,9 +52,11 @@ class BrowserPlayer {
47
52
  hoveredHit = null;
48
53
  constructor(host, diagram, document, options) {
49
54
  this.element = host;
55
+ this.loop = options.loop ?? false;
50
56
  this.diagram = diagram;
51
- this.beatStarts = collectBeatStarts(document);
52
- this.scenes = collectScenes(document);
57
+ this.sourceDocument = cloneSourceDocument(document);
58
+ this.beatStarts = diagram.beats.map((beat) => beat.startMs);
59
+ this.scenes = collectScenes(document, diagram);
53
60
  const owner = host.ownerDocument;
54
61
  const motion = observeReducedMotion(owner, options.reducedMotion, (reducedMotion) => {
55
62
  if (this.destroyed)
@@ -73,9 +80,16 @@ class BrowserPlayer {
73
80
  toggle: this.togglePlayback,
74
81
  step: (direction) => this.step(direction),
75
82
  seek: (timeMs) => this.seek(timeMs),
76
- setSpeed: (speed) => this.setSpeed(speed)
83
+ setSpeed: (speed) => this.setSpeed(speed),
84
+ toggleLoop: () => this.setLoop(!this.loop)
77
85
  });
78
86
  }
87
+ if (options.gifExport && this.controls) {
88
+ this.gifControls = new GifExportControls(owner, () => ({
89
+ document: structuredClone(this.sourceDocument), theme: this.options.theme ?? "light"
90
+ }));
91
+ this.controls.element.append(this.gifControls.element);
92
+ }
79
93
  host.replaceChildren(this.canvas, this.caption, ...(this.controls ? [this.controls.element] : []));
80
94
  this.render();
81
95
  this.stopPointer = observePointer(this.canvas, {
@@ -86,8 +100,18 @@ class BrowserPlayer {
86
100
  this.detachLifecycle = attachPlayerLifecycle(host, this.canvas, () => this.pause());
87
101
  }
88
102
  play() {
89
- if (this.destroyed || this.playing || this.element.ownerDocument.visibilityState === "hidden" || this.diagram.totalDuration <= this.timeMs)
103
+ if (this.destroyed || this.playing || this.isPageHidden() || this.diagram.totalDuration <= 0)
90
104
  return;
105
+ if (this.timeMs >= this.diagram.totalDuration) {
106
+ if (!this.loop)
107
+ return;
108
+ const intent = this.playbackIntent;
109
+ this.timeMs = 0;
110
+ this.updateFrame();
111
+ // Rendering dispatches events; a newer pause, seek or load must override this restart.
112
+ if (this.destroyed || this.playing || intent !== this.playbackIntent || this.isPageHidden())
113
+ return;
114
+ }
91
115
  this.playing = true;
92
116
  this.lastTimestamp = undefined;
93
117
  this.renderControls();
@@ -95,7 +119,10 @@ class BrowserPlayer {
95
119
  this.scheduleFrame();
96
120
  }
97
121
  pause() {
98
- if (this.destroyed || !this.playing)
122
+ if (this.destroyed)
123
+ return;
124
+ this.playbackIntent += 1;
125
+ if (!this.playing)
99
126
  return;
100
127
  this.playing = false;
101
128
  if (this.frameHandle !== undefined)
@@ -107,6 +134,7 @@ class BrowserPlayer {
107
134
  seek(timeMs) {
108
135
  if (this.destroyed)
109
136
  return;
137
+ this.playbackIntent += 1;
110
138
  this.timeMs = clamp(timeMs, 0, this.diagram.totalDuration);
111
139
  this.lastTimestamp = undefined;
112
140
  this.updateFrame();
@@ -127,6 +155,13 @@ class BrowserPlayer {
127
155
  this.speed = speed;
128
156
  this.renderControls();
129
157
  }
158
+ setLoop(loop) {
159
+ if (this.destroyed || this.loop === loop)
160
+ return;
161
+ this.loop = loop;
162
+ this.renderControls();
163
+ this.emit("loopchange");
164
+ }
130
165
  load(document) {
131
166
  if (this.destroyed)
132
167
  return;
@@ -137,9 +172,10 @@ class BrowserPlayer {
137
172
  if (this.destroyed)
138
173
  return;
139
174
  this.diagram = diagram;
175
+ this.sourceDocument = cloneSourceDocument(document);
140
176
  this.options = { ...this.options, theme: this.themeOverride ?? document.theme ?? "light" };
141
- this.beatStarts = collectBeatStarts(document);
142
- this.scenes = collectScenes(document);
177
+ this.beatStarts = diagram.beats.map((beat) => beat.startMs);
178
+ this.scenes = collectScenes(document, diagram);
143
179
  this.timeMs = 0;
144
180
  // A replacement document owns new interaction targets, even when it reuses the same node IDs.
145
181
  this.pointerPosition = undefined;
@@ -157,6 +193,8 @@ class BrowserPlayer {
157
193
  return;
158
194
  // Block dispatch/rescheduling before releasing resources; do not call pause, which would emit an event.
159
195
  this.destroyed = true;
196
+ this.gifControls?.destroy();
197
+ this.controls?.destroy();
160
198
  this.playing = false;
161
199
  if (this.frameHandle !== undefined)
162
200
  cancelAnimationFrame(this.frameHandle);
@@ -177,8 +215,11 @@ class BrowserPlayer {
177
215
  this.frameHandle = undefined;
178
216
  const previous = this.lastTimestamp ?? timestamp;
179
217
  this.lastTimestamp = timestamp;
180
- this.timeMs += (timestamp - previous) * this.speed;
181
- if (this.timeMs >= this.diagram.totalDuration) {
218
+ const elapsed = timestamp - previous;
219
+ this.timeMs = this.loop
220
+ ? advanceLoopTime(this.timeMs, elapsed, this.speed, this.diagram.totalDuration)
221
+ : this.timeMs + elapsed * this.speed;
222
+ if (!this.loop && this.timeMs >= this.diagram.totalDuration) {
182
223
  this.timeMs = this.diagram.totalDuration;
183
224
  this.updateFrame();
184
225
  this.pause();
@@ -203,6 +244,9 @@ class BrowserPlayer {
203
244
  this.emit("stepchange");
204
245
  this.emit("timeupdate");
205
246
  }
247
+ isPageHidden() {
248
+ return this.element.ownerDocument.visibilityState === "hidden";
249
+ }
206
250
  togglePlayback = () => {
207
251
  if (this.playing)
208
252
  this.pause();
@@ -220,7 +264,7 @@ class BrowserPlayer {
220
264
  this.controls?.render({ ...this.state(), sceneId: this.frame.sceneId, scenes: this.scenes });
221
265
  }
222
266
  state() {
223
- return { timeMs: this.timeMs, totalDuration: this.diagram.totalDuration, playing: this.playing, speed: this.speed, frame: this.frame };
267
+ return { timeMs: this.timeMs, totalDuration: this.diagram.totalDuration, playing: this.playing, loop: this.loop, speed: this.speed, frame: this.frame };
224
268
  }
225
269
  handlePointer(type, event) {
226
270
  if (this.destroyed)
@@ -298,22 +342,13 @@ function paintHoveredContent(content, layout) {
298
342
  function clamp(value, min, max) {
299
343
  return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
300
344
  }
301
- function collectBeatStarts(document) {
302
- let startMs = 0;
303
- return document.scenes.flatMap((scene) => scene.beats.map((beat) => {
304
- const start = startMs;
305
- startMs += beat.durationMs;
306
- return start;
345
+ /** Reuse effective starts from core; authored durations can be shorter than a single transfer. */
346
+ function collectScenes(document, diagram) {
347
+ const labels = new Map(document.scenes.map((scene) => [scene.id, scene.label]));
348
+ return diagram.beats.filter((beat) => beat.beatIndex === 0).map((beat) => ({
349
+ id: beat.sceneId, label: labels.get(beat.sceneId), startMs: beat.startMs
307
350
  }));
308
351
  }
309
- function collectScenes(document) {
310
- let startMs = 0;
311
- return document.scenes.map((scene) => {
312
- const choice = { id: scene.id, label: scene.label, startMs };
313
- startMs += scene.beats.reduce((duration, beat) => duration + beat.durationMs, 0);
314
- return choice;
315
- });
316
- }
317
352
  function observePointer(canvas, handlers) {
318
353
  const onMove = (event) => handlers.move(event);
319
354
  const onLeave = () => handlers.leave();
@@ -351,3 +386,27 @@ function pointerToDiagramPoint(canvas, camera, event) {
351
386
  y: camera.y + ((event.clientY - bounds.top) / height) * camera.height
352
387
  };
353
388
  }
389
+ /** Validation admits JSON documents; the same serialization as the core also handles reactive host document objects. */
390
+ function cloneSourceDocument(document) {
391
+ return JSON.parse(JSON.stringify(document));
392
+ }
393
+ /** Preserve the modular elapsed result even when finite speed and elapsed values overflow their product. */
394
+ function advanceLoopTime(timeMs, elapsedMs, speed, duration) {
395
+ let scaledSpeed = speed;
396
+ let elapsed = elapsedMs * scaledSpeed;
397
+ let doublings = 0;
398
+ while (!Number.isFinite(elapsed)) {
399
+ scaledSpeed /= 2;
400
+ doublings += 1;
401
+ elapsed = elapsedMs * scaledSpeed;
402
+ }
403
+ let remainder = elapsed % duration;
404
+ // Reducing before each doubling avoids overflow without discarding elapsed remainder.
405
+ for (let index = 0; index < doublings; index += 1)
406
+ remainder = addLoopRemainder(remainder, remainder, duration);
407
+ return addLoopRemainder(timeMs % duration, remainder, duration);
408
+ }
409
+ function addLoopRemainder(left, right, duration) {
410
+ const gap = duration - right;
411
+ return left >= gap ? left - gap : left + right;
412
+ }
@@ -0,0 +1,33 @@
1
+ /** Shared select-only combobox for playback and host settings; the caller owns the committed value. */
2
+ /** One non-editable choice. Values must be unique within a control. */
3
+ export interface SelectChoice {
4
+ value: string;
5
+ label: string;
6
+ }
7
+ /**
8
+ * Styled choices keep focus on the trigger. Arrow keys preview; Enter/Space commit; Escape/Tab cancel.
9
+ * Append element before use, synchronize with render(), and call destroy() before removing the host.
10
+ */
11
+ export declare class SelectControl {
12
+ private readonly onChange;
13
+ readonly element: HTMLDivElement;
14
+ private readonly trigger;
15
+ private readonly list;
16
+ private choices;
17
+ private activeIndex;
18
+ private destroyed;
19
+ constructor(owner: Document, label: string, onChange: (value: string) => void);
20
+ /** Replace changed choices and reflect external state without committing a user action. */
21
+ render(choices: readonly SelectChoice[], value: string, disabled?: boolean): void;
22
+ /** Release outside-dismissal listeners and make detached controls inert. Idempotent. */
23
+ destroy(): void;
24
+ private createOption;
25
+ private readonly toggle;
26
+ private open;
27
+ private close;
28
+ private highlight;
29
+ private commit;
30
+ private readonly onOutside;
31
+ private readonly onBlur;
32
+ private readonly onKey;
33
+ }
@@ -0,0 +1,171 @@
1
+ let nextId = 0;
2
+ /**
3
+ * Styled choices keep focus on the trigger. Arrow keys preview; Enter/Space commit; Escape/Tab cancel.
4
+ * Append element before use, synchronize with render(), and call destroy() before removing the host.
5
+ */
6
+ export class SelectControl {
7
+ onChange;
8
+ element;
9
+ trigger;
10
+ list;
11
+ choices = [];
12
+ activeIndex = 0;
13
+ destroyed = false;
14
+ constructor(owner, label, onChange) {
15
+ this.onChange = onChange;
16
+ this.element = owner.createElement("div");
17
+ this.element.className = "flowsvg-select";
18
+ this.trigger = owner.createElement("button");
19
+ this.trigger.type = "button";
20
+ this.trigger.className = "flowsvg-select-trigger";
21
+ this.trigger.setAttribute("role", "combobox");
22
+ this.trigger.setAttribute("aria-label", label);
23
+ this.trigger.setAttribute("aria-haspopup", "listbox");
24
+ this.trigger.setAttribute("aria-expanded", "false");
25
+ this.list = owner.createElement("div");
26
+ this.list.id = `flowsvg-choices-${++nextId}`;
27
+ this.list.className = "flowsvg-select-options";
28
+ this.list.setAttribute("role", "listbox");
29
+ this.list.setAttribute("aria-label", label);
30
+ this.list.hidden = true;
31
+ this.trigger.setAttribute("aria-controls", this.list.id);
32
+ this.trigger.addEventListener("click", this.toggle);
33
+ this.trigger.addEventListener("keydown", this.onKey);
34
+ this.element.addEventListener("focusout", this.onBlur);
35
+ this.element.append(this.trigger, this.list);
36
+ }
37
+ /** Replace changed choices and reflect external state without committing a user action. */
38
+ render(choices, value, disabled = false) {
39
+ if (this.destroyed)
40
+ return;
41
+ const changed = choices.length !== this.choices.length || choices.some((choice, index) => choice.value !== this.choices[index]?.value || choice.label !== this.choices[index]?.label);
42
+ if (changed) {
43
+ this.close();
44
+ this.choices = choices;
45
+ this.list.replaceChildren(...choices.map((choice, index) => this.createOption(choice, index)));
46
+ }
47
+ this.trigger.value = value;
48
+ this.trigger.dataset.value = value;
49
+ this.trigger.textContent = choices.find((choice) => choice.value === value)?.label ?? "—";
50
+ this.trigger.disabled = disabled || choices.length === 0;
51
+ for (const option of Array.from(this.list.children)) {
52
+ option.setAttribute("aria-selected", String(option.getAttribute("data-value") === value));
53
+ }
54
+ if (this.trigger.disabled)
55
+ this.close();
56
+ }
57
+ /** Release outside-dismissal listeners and make detached controls inert. Idempotent. */
58
+ destroy() {
59
+ this.close();
60
+ this.destroyed = true;
61
+ this.trigger.removeEventListener("click", this.toggle);
62
+ this.trigger.removeEventListener("keydown", this.onKey);
63
+ this.element.removeEventListener("focusout", this.onBlur);
64
+ this.element.remove();
65
+ }
66
+ createOption(choice, index) {
67
+ const option = this.element.ownerDocument.createElement("div");
68
+ option.id = `${this.list.id}-${index}`;
69
+ option.setAttribute("role", "option");
70
+ option.dataset.value = choice.value;
71
+ option.textContent = choice.label;
72
+ // Pointer selection must retain trigger focus, including when the host synchronously replaces a player.
73
+ option.addEventListener("pointerdown", (event) => event.preventDefault());
74
+ option.addEventListener("click", () => this.commit(index));
75
+ return option;
76
+ }
77
+ toggle = () => {
78
+ if (this.list.hidden)
79
+ this.open();
80
+ else
81
+ this.close();
82
+ };
83
+ open() {
84
+ if (this.destroyed || this.trigger.disabled || !this.list.hidden)
85
+ return;
86
+ this.list.hidden = false;
87
+ this.trigger.setAttribute("aria-expanded", "true");
88
+ this.highlight(Math.max(0, this.choices.findIndex((choice) => choice.value === this.trigger.value)));
89
+ this.element.ownerDocument.addEventListener("pointerdown", this.onOutside);
90
+ }
91
+ close() {
92
+ this.list.hidden = true;
93
+ this.trigger.setAttribute("aria-expanded", "false");
94
+ this.trigger.removeAttribute("aria-activedescendant");
95
+ this.element.ownerDocument.removeEventListener("pointerdown", this.onOutside);
96
+ }
97
+ highlight(index) {
98
+ this.activeIndex = Math.max(0, Math.min(this.choices.length - 1, index));
99
+ Array.from(this.list.children).forEach((option, optionIndex) => {
100
+ option.toggleAttribute("data-active", optionIndex === this.activeIndex);
101
+ });
102
+ const active = this.list.children[this.activeIndex];
103
+ if (active) {
104
+ this.trigger.setAttribute("aria-activedescendant", active.id);
105
+ active.scrollIntoView({ block: "nearest" });
106
+ }
107
+ }
108
+ commit(index) {
109
+ if (this.destroyed || this.trigger.disabled || this.list.hidden)
110
+ return;
111
+ const choice = this.choices[index];
112
+ this.close();
113
+ if (choice && choice.value !== this.trigger.value)
114
+ this.onChange(choice.value);
115
+ }
116
+ onOutside = (event) => {
117
+ if (!event.composedPath().includes(this.element))
118
+ this.close();
119
+ };
120
+ onBlur = (event) => {
121
+ const target = event.relatedTarget;
122
+ if (!(target instanceof this.element.ownerDocument.defaultView.Node) || !this.element.contains(target))
123
+ this.close();
124
+ };
125
+ onKey = (event) => {
126
+ if (event.altKey || event.ctrlKey || event.metaKey || this.trigger.disabled)
127
+ return;
128
+ if (event.key === "Tab") {
129
+ this.close();
130
+ return;
131
+ }
132
+ if (event.key === "Escape") {
133
+ if (!this.list.hidden) {
134
+ event.preventDefault();
135
+ event.stopPropagation();
136
+ this.close();
137
+ }
138
+ return;
139
+ }
140
+ if (["Enter", " "].includes(event.key)) {
141
+ event.preventDefault();
142
+ if (this.list.hidden)
143
+ this.open();
144
+ else
145
+ this.commit(this.activeIndex);
146
+ return;
147
+ }
148
+ if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
149
+ event.preventDefault();
150
+ const wasOpen = !this.list.hidden;
151
+ this.open();
152
+ if (event.key === "Home")
153
+ this.highlight(0);
154
+ else if (event.key === "End")
155
+ this.highlight(this.choices.length - 1);
156
+ else if (wasOpen)
157
+ this.highlight(this.activeIndex + (event.key === "ArrowDown" ? 1 : -1));
158
+ return;
159
+ }
160
+ if (event.key.length === 1 && event.key.trim()) {
161
+ const start = this.list.hidden ? -1 : this.activeIndex;
162
+ this.open();
163
+ const indexes = this.choices.map((_, index) => (start + index + 1) % this.choices.length);
164
+ const match = indexes.find((index) => this.choices[index].label.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()));
165
+ if (match !== undefined) {
166
+ event.preventDefault();
167
+ this.highlight(match);
168
+ }
169
+ }
170
+ };
171
+ }
package/dist/browser.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- /** Public browser entry: expose the player contract while keeping its implementation under the browser module. */
1
+ /** Public browser entry: expose the player and reusable choice control while keeping implementations under the browser module. */
2
2
  export { createPlayer } from "./browser/player.js";
3
3
  export type { FlowPlayer, PlayerErrorDetail, PlayerEventMap, PlayerInteractionDetail, PlayerOptions, PlayerState } from "./browser/player.js";
4
4
  export type { ThemeName } from "./core/model.js";
5
+ export { SelectControl, type SelectChoice } from "./browser/select-control.js";
package/dist/browser.js CHANGED
@@ -1,2 +1,3 @@
1
- /** Public browser entry: expose the player contract while keeping its implementation under the browser module. */
1
+ /** Public browser entry: expose the player and reusable choice control while keeping implementations under the browser module. */
2
2
  export { createPlayer } from "./browser/player.js";
3
+ export { SelectControl } from "./browser/select-control.js";
@@ -1,7 +1,7 @@
1
- import type { DiagramDocument, EvaluateOptions, HitObject, DiagramPoint, DiagramFrameState } from "./model.js";
1
+ import type { DiagramDocument, DiagramBeat, EvaluateOptions, HitObject, DiagramPoint, DiagramFrameState } from "./model.js";
2
2
  /**
3
3
  * Validate and copy a document, then compile its timeline; later input mutations cannot affect evaluation.
4
- * @throws {DiagramValidationError} If document structure, values or references are invalid.
4
+ * @throws {DiagramValidationError} If document structure, values or references are invalid, or derived flow timing is non-finite.
5
5
  */
6
6
  export declare function createDiagram(input: DiagramDocument): Diagram;
7
7
  /** One-shot static SVG export; use createDiagram to reuse compilation across repeated evaluations or exports. */
@@ -10,6 +10,8 @@ export declare function renderSvg(document: DiagramDocument, timeMs?: number, op
10
10
  export interface Diagram {
11
11
  /** Global timeline duration in milliseconds; zero for static diagrams. */
12
12
  readonly totalDuration: number;
13
+ /** Frozen compiled intervals, including single-flow movement and arrival hold; use these for navigation and export scheduling. */
14
+ readonly beats: readonly DiagramBeat[];
13
15
  /**
14
16
  * Evaluate a frame; finite times clamp to [0, totalDuration], and non-finite times become zero.
15
17
  * Beat intervals include their start and exclude their end; totalDuration returns the completed final beat.