flowsvg 0.1.0 → 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 (98) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/PATTERNS.md +71 -4
  3. package/README.md +148 -438
  4. package/README.zh-CN.md +148 -438
  5. package/dist/adapters/react.d.ts +55 -0
  6. package/dist/adapters/react.js +138 -0
  7. package/dist/adapters/vue.d.ts +158 -0
  8. package/dist/adapters/vue.js +177 -0
  9. package/dist/adapters/web-component.d.ts +111 -0
  10. package/dist/adapters/web-component.js +425 -0
  11. package/dist/{player-controls.d.ts → browser/controls.d.ts} +7 -3
  12. package/dist/{player-controls.js → 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 +77 -0
  16. package/dist/browser/player.js +412 -0
  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 +5 -69
  20. package/dist/browser.js +3 -353
  21. package/dist/core/color.d.ts +3 -0
  22. package/dist/core/color.js +18 -0
  23. package/dist/core/diagram.d.ts +40 -0
  24. package/dist/core/diagram.js +481 -0
  25. package/dist/core/edge-geometry.d.ts +45 -0
  26. package/dist/core/edge-geometry.js +609 -0
  27. package/dist/{core.d.ts → core/model.d.ts} +37 -61
  28. package/dist/core/model.js +1 -0
  29. package/dist/core/node-content-measurement.d.ts +34 -0
  30. package/dist/core/node-content-measurement.js +66 -0
  31. package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
  32. package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
  33. package/dist/{node-content.d.ts → core/node-content.d.ts} +18 -1
  34. package/dist/{node-content.js → core/node-content.js} +30 -6
  35. package/dist/core/svg-number.d.ts +5 -0
  36. package/dist/core/svg-number.js +7 -0
  37. package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
  38. package/dist/core/theme.js +17 -0
  39. package/dist/{timeline.d.ts → core/timeline.d.ts} +11 -5
  40. package/dist/{timeline.js → core/timeline.js} +30 -22
  41. package/dist/core/validation.d.ts +11 -0
  42. package/dist/core/validation.js +399 -0
  43. package/dist/export/browser.d.ts +9 -0
  44. package/dist/export/browser.js +172 -0
  45. package/dist/export/captions.d.ts +18 -0
  46. package/dist/export/captions.js +44 -0
  47. package/dist/export/plan.d.ts +25 -0
  48. package/dist/export/plan.js +94 -0
  49. package/dist/export/types.d.ts +64 -0
  50. package/dist/export/types.js +9 -0
  51. package/dist/export/worker-source.d.ts +1 -0
  52. package/dist/export/worker-source.js +1 -0
  53. package/dist/export.d.ts +3 -0
  54. package/dist/export.js +2 -0
  55. package/dist/index.d.ts +7 -1
  56. package/dist/index.js +3 -1
  57. package/dist/node/cli.d.ts +2 -0
  58. package/dist/node/cli.js +72 -0
  59. package/dist/node/gif.d.ts +16 -0
  60. package/dist/node/gif.js +184 -0
  61. package/dist/node/html.d.ts +19 -0
  62. package/dist/node/html.js +61 -0
  63. package/dist/node/output.d.ts +2 -0
  64. package/dist/node/output.js +22 -0
  65. package/dist/node.d.ts +6 -0
  66. package/dist/node.js +3 -0
  67. package/dist/portable/LICENSE.esbuild.txt +21 -0
  68. package/dist/portable/LICENSE.flowsvg.txt +21 -0
  69. package/dist/portable/LICENSE.gifenc.txt +21 -0
  70. package/dist/portable/export.js +5 -0
  71. package/dist/portable/manifest.json +36 -0
  72. package/dist/portable/player.js +2 -0
  73. package/dist/portable/styles.css +227 -0
  74. package/dist/react.d.ts +3 -48
  75. package/dist/react.js +2 -126
  76. package/dist/styles.css +194 -14
  77. package/dist/vue.d.ts +3 -132
  78. package/dist/vue.js +2 -161
  79. package/dist/web-component.d.ts +3 -103
  80. package/dist/web-component.js +2 -396
  81. package/docs/images/entity-graph.png +0 -0
  82. package/docs/images/facts-timeline.png +0 -0
  83. package/docs/images/facts-timeline.zh-CN.png +0 -0
  84. package/docs/images/flowsvg-wordmark.gif +0 -0
  85. package/docs/images/node-content.png +0 -0
  86. package/docs/images/numbered-list.png +0 -0
  87. package/docs/images/numbered-list.zh-CN.png +0 -0
  88. package/docs/images/quick-start.png +0 -0
  89. package/docs/images/record-tags.png +0 -0
  90. package/package.json +38 -6
  91. package/dist/core.js +0 -1149
  92. package/dist/theme.js +0 -6
  93. /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
  94. /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
  95. /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
  96. /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
  97. /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
  98. /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Web Component boundary: convert attributes/objects/URLs and manage lifecycle while reusing browser playback/rendering.
3
+ * Imports do not register the element; DOM-free imports use a placeholder base, so real registration needs a browser load.
4
+ */
5
+ import { createDiagram } from "../core/diagram.js";
6
+ import { createPlayer } from "../browser/player.js";
7
+ /** Default custom-element name registered by {@link defineFlowPlayer}. */
8
+ export const FLOW_PLAYER_TAG = "flowsvg-player";
9
+ // typeof permits DOM-free imports, but the base is fixed at evaluation time; load this entry with a DOM to create elements.
10
+ const HTMLElementBase = (typeof HTMLElement === "undefined"
11
+ ? class {
12
+ }
13
+ : HTMLElement);
14
+ /**
15
+ * Custom-element adapter for attribute conversion, URL requests and playback/error events.
16
+ * The document property may be assigned before connection; disconnect releases the player/requests but retains the document.
17
+ * Rendering/timeline behavior delegates to createPlayer; registration requires an explicit defineFlowPlayer call.
18
+ */
19
+ export class FlowPlayerElement extends HTMLElementBase {
20
+ static observedAttributes = [
21
+ "document",
22
+ "src",
23
+ "autoplay",
24
+ "controls",
25
+ "speed",
26
+ "theme",
27
+ "reduced-motion",
28
+ "hover-fill",
29
+ "gif-export",
30
+ "loop"
31
+ ];
32
+ player;
33
+ playerHost;
34
+ sourceAbort;
35
+ requestToken = 0;
36
+ connected = false;
37
+ manuallyDestroyed = false;
38
+ currentDocument;
39
+ controlsOverride;
40
+ forwardedEvents = [];
41
+ /** Current object document; assigning undefined clears it, cancels requests and unmounts the player. */
42
+ get document() {
43
+ return this.currentDocument;
44
+ }
45
+ set document(value) {
46
+ if (value === undefined) {
47
+ this.currentDocument = undefined;
48
+ this.cancelSourceLoad();
49
+ this.unmountPlayer();
50
+ return;
51
+ }
52
+ this.setDocument(value);
53
+ }
54
+ /** Document URL resolved against ownerDocument.baseURI; changes cancel obsolete loads. */
55
+ get src() {
56
+ return this.getAttribute?.("src") ?? undefined;
57
+ }
58
+ set src(value) {
59
+ if (value === undefined || value === "")
60
+ this.removeAttribute("src");
61
+ else
62
+ this.setAttribute("src", value);
63
+ }
64
+ /** Whether to autoplay; false pauses a mounted player. */
65
+ get autoplay() {
66
+ return readBooleanAttribute(this, "autoplay", false);
67
+ }
68
+ set autoplay(value) {
69
+ setBooleanAttribute(this, "autoplay", value);
70
+ this.applyAutoplay();
71
+ }
72
+ /** Live loop mode, reflected by the loop attribute; changes retain time and play state. */
73
+ get loop() {
74
+ return readBooleanAttribute(this, "loop", false);
75
+ }
76
+ set loop(value) {
77
+ setBooleanAttribute(this, "loop", value);
78
+ }
79
+ /** Controls default on; an explicit false is retained in HTML as controls="false". */
80
+ get controls() {
81
+ return this.controlsOverride ?? readBooleanAttribute(this, "controls", true);
82
+ }
83
+ set controls(value) {
84
+ this.controlsOverride = value;
85
+ // An omitted attribute enables controls, so disabling must preserve the false text rather than remove the attribute.
86
+ if (value)
87
+ this.setAttribute("controls", "");
88
+ else
89
+ this.setAttribute("controls", "false");
90
+ this.rebuildForOptions();
91
+ }
92
+ /** Positive speed multiplier; invalid attribute text reads as 1, while invalid property assignments are ignored. */
93
+ get speed() {
94
+ return readPositiveNumber(this.getAttribute?.("speed"), 1);
95
+ }
96
+ set speed(value) {
97
+ if (!Number.isFinite(value) || value <= 0)
98
+ return;
99
+ this.setAttribute("speed", String(value));
100
+ this.player?.setSpeed(value);
101
+ }
102
+ /** Explicit light/dark theme; the player falls back to the document theme or light when unset. */
103
+ get theme() {
104
+ const value = this.getAttribute?.("theme");
105
+ return value === "light" || value === "dark" ? value : undefined;
106
+ }
107
+ set theme(value) {
108
+ if (value === undefined)
109
+ this.removeAttribute("theme");
110
+ else
111
+ this.setAttribute("theme", value);
112
+ }
113
+ /** undefined follows the system preference; true/false explicitly override it via reduced-motion. */
114
+ get reducedMotion() {
115
+ const value = this.getAttribute?.("reduced-motion");
116
+ if (value === null || value === undefined)
117
+ return undefined;
118
+ return parseBooleanValue(value, true);
119
+ }
120
+ set reducedMotion(value) {
121
+ if (value === undefined)
122
+ this.removeAttribute("reduced-motion");
123
+ else
124
+ this.setAttribute("reduced-motion", String(value));
125
+ }
126
+ /** Hover paints only borders by default. The hover-fill attribute enables background and content feedback. */
127
+ get hoverFill() {
128
+ return readBooleanAttribute(this, "hover-fill", false);
129
+ }
130
+ set hoverFill(value) {
131
+ setBooleanAttribute(this, "hover-fill", value);
132
+ }
133
+ /** Opt-in GIF controls, reflected by the gif-export attribute; defaults to false. */
134
+ get gifExport() {
135
+ return readBooleanAttribute(this, "gif-export", false);
136
+ }
137
+ set gifExport(value) {
138
+ setBooleanAttribute(this, "gif-export", value);
139
+ }
140
+ connectedCallback() {
141
+ this.connected = true;
142
+ this.manuallyDestroyed = false;
143
+ if (this.player)
144
+ return;
145
+ if (this.currentDocument) {
146
+ this.mountPlayer(this.currentDocument);
147
+ return;
148
+ }
149
+ const documentAttribute = this.getAttribute("document");
150
+ if (documentAttribute !== null) {
151
+ this.loadDocumentAttribute(documentAttribute);
152
+ return;
153
+ }
154
+ const source = this.getAttribute("src");
155
+ if (source)
156
+ void this.loadSource(source);
157
+ }
158
+ disconnectedCallback() {
159
+ this.connected = false;
160
+ this.cancelSourceLoad();
161
+ this.unmountPlayer();
162
+ }
163
+ attributeChangedCallback(name, _oldValue, value) {
164
+ if (_oldValue === value || !this.connected)
165
+ return;
166
+ switch (name) {
167
+ case "document":
168
+ if (value === null) {
169
+ this.currentDocument = undefined;
170
+ this.unmountPlayer();
171
+ }
172
+ else
173
+ this.loadDocumentAttribute(value);
174
+ break;
175
+ case "src":
176
+ this.cancelSourceLoad();
177
+ if (value)
178
+ void this.loadSource(value);
179
+ break;
180
+ case "controls":
181
+ this.controlsOverride = value === null ? undefined : parseBooleanValue(value, true);
182
+ case "theme":
183
+ case "reduced-motion":
184
+ case "hover-fill":
185
+ case "gif-export":
186
+ this.rebuildForOptions();
187
+ break;
188
+ case "loop":
189
+ this.player?.setLoop(this.loop);
190
+ break;
191
+ case "speed":
192
+ this.player?.setSpeed(this.speed);
193
+ break;
194
+ case "autoplay":
195
+ this.applyAutoplay();
196
+ break;
197
+ default:
198
+ break;
199
+ }
200
+ }
201
+ /** Forward controls to the mounted player; calls are inert when no player is mounted. */
202
+ play() {
203
+ this.player?.play();
204
+ }
205
+ /** Pause the mounted player, retaining its current frame; do nothing before mounting. */
206
+ pause() {
207
+ this.player?.pause();
208
+ }
209
+ /** Seek in milliseconds using browser-player clamping; do nothing before mounting. */
210
+ seek(timeMs) {
211
+ this.player?.seek(timeMs);
212
+ }
213
+ /** Pause and jump to a beat boundary; backward from inside a beat goes to that beat's start. */
214
+ step(direction) {
215
+ this.player?.step(direction);
216
+ }
217
+ /** Set the speed property, retaining it for future mounts; ignore non-finite or non-positive values. */
218
+ setSpeed(value) {
219
+ this.speed = value;
220
+ }
221
+ /** Set loop mode and retain it for future mounts. */
222
+ setLoop(value) {
223
+ this.loop = value;
224
+ }
225
+ /** Return undefined before mounting or after destruction. */
226
+ getState() {
227
+ return this.player?.getState();
228
+ }
229
+ /** Explicitly release the player and requests; the manual-destruction flag resets on the next connection. */
230
+ destroy() {
231
+ this.manuallyDestroyed = true;
232
+ this.cancelSourceLoad();
233
+ this.unmountPlayer();
234
+ }
235
+ setDocument(value) {
236
+ this.cancelSourceLoad();
237
+ try {
238
+ // Validate before replacement so an invalid document does not discard the last valid frame.
239
+ createDiagram(value);
240
+ this.currentDocument = value;
241
+ if (this.connected && !this.manuallyDestroyed) {
242
+ if (this.player)
243
+ this.player.load(value);
244
+ else
245
+ this.mountPlayer(value);
246
+ this.applyAutoplay();
247
+ }
248
+ }
249
+ catch (error) {
250
+ this.dispatchError(toError(error));
251
+ }
252
+ }
253
+ loadDocumentAttribute(value) {
254
+ try {
255
+ const parsed = JSON.parse(value);
256
+ this.setDocument(parsed);
257
+ }
258
+ catch (error) {
259
+ this.dispatchError(toError(error));
260
+ }
261
+ }
262
+ /** AbortController cancels network work; the token also rejects obsolete results already received but not yet applied. */
263
+ async loadSource(source) {
264
+ const token = ++this.requestToken;
265
+ const controller = new AbortController();
266
+ this.sourceAbort?.abort();
267
+ this.sourceAbort = controller;
268
+ try {
269
+ const owner = this.ownerDocument;
270
+ const url = new URL(source, owner.baseURI).href;
271
+ const response = await fetch(url, { signal: controller.signal });
272
+ if (!response.ok)
273
+ throw new Error(`Failed to load diagram source (${response.status})`);
274
+ const parsed = await response.json();
275
+ // Recheck connection and request ownership after parsing so stale responses cannot replace a newer document/src.
276
+ if (!this.connected || token !== this.requestToken || controller.signal.aborted)
277
+ return;
278
+ this.setDocument(parsed);
279
+ }
280
+ catch (error) {
281
+ if (controller.signal.aborted || token !== this.requestToken)
282
+ return;
283
+ this.dispatchError(toError(error));
284
+ }
285
+ finally {
286
+ if (this.sourceAbort === controller)
287
+ this.sourceAbort = undefined;
288
+ }
289
+ }
290
+ /** Restore an existing playback state during option rebuilds; autoplay applies only to a fresh mount. */
291
+ mountPlayer(document, previous) {
292
+ if (!this.connected || this.manuallyDestroyed || this.player)
293
+ return;
294
+ const owner = this.ownerDocument;
295
+ const host = owner.createElement("div");
296
+ host.className = "flowsvg-element-host";
297
+ this.playerHost = host;
298
+ this.append(host);
299
+ try {
300
+ this.player = createPlayer(host, document, this.playerOptions());
301
+ this.forwardPlayerEvents(host);
302
+ this.player.setSpeed(this.speed);
303
+ if (previous) {
304
+ this.player.seek(previous.timeMs);
305
+ if (previous.playing)
306
+ this.player.play();
307
+ }
308
+ else
309
+ this.applyAutoplay();
310
+ }
311
+ catch (error) {
312
+ this.unmountPlayer();
313
+ this.dispatchError(toError(error));
314
+ }
315
+ }
316
+ /** Rebuild for option changes and restore time/playback; document replacement instead follows load's reset-to-zero rule. */
317
+ rebuildForOptions() {
318
+ if (!this.connected || !this.currentDocument || this.manuallyDestroyed)
319
+ return;
320
+ const previous = this.player?.getState();
321
+ this.unmountPlayer();
322
+ this.mountPlayer(this.currentDocument, previous);
323
+ }
324
+ /** Detach forwarding before destruction so teardown cannot forward events from the old instance. */
325
+ unmountPlayer() {
326
+ this.removeForwardedEvents();
327
+ this.player?.destroy();
328
+ this.player = undefined;
329
+ this.playerHost?.remove();
330
+ this.playerHost = undefined;
331
+ }
332
+ forwardPlayerEvents(host) {
333
+ this.removeForwardedEvents();
334
+ for (const name of ["stepchange", "timeupdate", "playstatechange", "loopchange", "error"]) {
335
+ const listener = (event) => {
336
+ const source = event;
337
+ if (name === "loopchange") {
338
+ setBooleanAttribute(this, "loop", source.detail.loop);
339
+ }
340
+ this.dispatchEvent(new CustomEvent(name, {
341
+ detail: source.detail,
342
+ bubbles: true,
343
+ composed: true
344
+ }));
345
+ };
346
+ host.addEventListener(name, listener);
347
+ this.forwardedEvents.push({ name, listener });
348
+ }
349
+ }
350
+ removeForwardedEvents() {
351
+ if (!this.playerHost) {
352
+ this.forwardedEvents.length = 0;
353
+ return;
354
+ }
355
+ for (const { name, listener } of this.forwardedEvents)
356
+ this.playerHost.removeEventListener(name, listener);
357
+ this.forwardedEvents.length = 0;
358
+ }
359
+ playerOptions() {
360
+ return {
361
+ controls: this.controls,
362
+ loop: this.loop,
363
+ gifExport: this.gifExport,
364
+ hoverFill: this.hoverFill,
365
+ ...(this.theme ? { theme: this.theme } : {}),
366
+ ...(this.reducedMotion === undefined ? {} : { reducedMotion: this.reducedMotion })
367
+ };
368
+ }
369
+ applyAutoplay() {
370
+ if (!this.player)
371
+ return;
372
+ if (this.autoplay)
373
+ this.player.play();
374
+ else
375
+ this.player.pause();
376
+ }
377
+ cancelSourceLoad() {
378
+ this.requestToken += 1;
379
+ this.sourceAbort?.abort();
380
+ this.sourceAbort = undefined;
381
+ }
382
+ dispatchError(error) {
383
+ const detail = { error, ...(this.player ? this.player.getState() : {}) };
384
+ this.dispatchEvent(new CustomEvent("error", { detail, bubbles: true, composed: true }));
385
+ }
386
+ }
387
+ /**
388
+ * Register explicitly; repeated calls for the default name return the existing constructor, or undefined without customElements.
389
+ * Load the module with a DOM for real registration, since the HTMLElement base is fixed at module evaluation.
390
+ * @throws {DOMException} From the registry for invalid names or reusing the constructor under another name.
391
+ */
392
+ export function defineFlowPlayer(tagName = FLOW_PLAYER_TAG) {
393
+ if (typeof customElements === "undefined")
394
+ return undefined;
395
+ const existing = customElements.get(tagName);
396
+ if (existing)
397
+ return existing;
398
+ customElements.define(tagName, FlowPlayerElement);
399
+ return FlowPlayerElement;
400
+ }
401
+ function readBooleanAttribute(element, name, fallback) {
402
+ const value = element.getAttribute(name);
403
+ return value === null ? fallback : parseBooleanValue(value, true);
404
+ }
405
+ function parseBooleanValue(value, fallback) {
406
+ const normalized = value.trim().toLowerCase();
407
+ if (normalized === "false" || normalized === "0" || normalized === "no")
408
+ return false;
409
+ if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "")
410
+ return true;
411
+ return fallback;
412
+ }
413
+ function setBooleanAttribute(element, name, value) {
414
+ if (value)
415
+ element.setAttribute(name, "");
416
+ else
417
+ element.removeAttribute(name);
418
+ }
419
+ function readPositiveNumber(value, fallback) {
420
+ const parsed = Number(value);
421
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
422
+ }
423
+ function toError(error) {
424
+ return error instanceof Error ? error : new Error(String(error));
425
+ }
@@ -1,4 +1,3 @@
1
- /** Built-in controls consume state and forward actions; the browser player owns evaluation and scheduling. */
2
1
  /** Scene selection metadata with its global start time in milliseconds. */
3
2
  interface SceneChoice {
4
3
  id: string;
@@ -11,23 +10,26 @@ interface ControlActions {
11
10
  step(direction: 1 | -1): void;
12
11
  seek(timeMs: number): void;
13
12
  setSpeed(speed: number): void;
13
+ toggleLoop(): void;
14
14
  }
15
15
  /** Player-state projection for controls; the scene list is replaced on document load. */
16
16
  interface ControlState {
17
17
  timeMs: number;
18
18
  totalDuration: number;
19
19
  playing: boolean;
20
+ loop: boolean;
20
21
  speed: number;
21
22
  sceneId: string | null;
22
23
  scenes: readonly SceneChoice[];
23
24
  }
24
25
  /**
25
26
  * Persistent control DOM translates input to commands; render synchronizes labels, progress and disabled states.
26
- * Own no clock or independent position; time-related controls are disabled for static documents.
27
+ * Own no clock or independent position; Time-related controls are disabled for static documents.
27
28
  */
28
29
  export declare class PlayerControls {
29
30
  readonly element: HTMLDivElement;
30
31
  private readonly play;
32
+ private readonly loop;
31
33
  private readonly previous;
32
34
  private readonly next;
33
35
  private readonly progress;
@@ -35,7 +37,9 @@ export declare class PlayerControls {
35
37
  private readonly speed;
36
38
  private sceneChoices;
37
39
  constructor(owner: Document, actions: ControlActions);
38
- /** Update existing controls; scene choices change by reference and custom speeds are added as options. */
40
+ /** Update existing controls; custom speeds join the default choices without changing committed state. */
39
41
  render(state: ControlState): void;
42
+ /** Release menu listeners before the player removes its DOM. */
43
+ destroy(): void;
40
44
  }
41
45
  export type { SceneChoice };
@@ -1,11 +1,14 @@
1
+ /** Built-in controls consume state and forward actions; the browser player owns evaluation and scheduling. */
2
+ import { SelectControl } from "./select-control.js";
1
3
  const DEFAULT_SPEEDS = [0.5, 1, 1.5, 2];
2
4
  /**
3
5
  * Persistent control DOM translates input to commands; render synchronizes labels, progress and disabled states.
4
- * Own no clock or independent position; time-related controls are disabled for static documents.
6
+ * Own no clock or independent position; Time-related controls are disabled for static documents.
5
7
  */
6
8
  export class PlayerControls {
7
9
  element;
8
10
  play;
11
+ loop;
9
12
  previous;
10
13
  next;
11
14
  progress;
@@ -16,6 +19,9 @@ export class PlayerControls {
16
19
  this.element = owner.createElement("div");
17
20
  this.element.className = "flowsvg-controls";
18
21
  this.play = createButton(owner, "Play", actions.toggle);
22
+ this.play.className = "flowsvg-play-toggle";
23
+ this.loop = createButton(owner, "Loop", actions.toggleLoop);
24
+ this.loop.className = "flowsvg-loop-toggle";
19
25
  this.previous = createButton(owner, "Previous step", () => actions.step(-1));
20
26
  this.next = createButton(owner, "Next step", () => actions.step(1));
21
27
  this.progress = owner.createElement("input");
@@ -24,39 +30,36 @@ export class PlayerControls {
24
30
  this.progress.step = "any";
25
31
  this.progress.setAttribute("aria-label", "Progress");
26
32
  this.progress.addEventListener("input", () => actions.seek(Number(this.progress.value)));
27
- this.scene = createSelect(owner, "Scene");
28
- this.scene.addEventListener("change", () => {
29
- const choice = this.sceneChoices?.find((scene) => scene.id === this.scene.value);
33
+ this.scene = new SelectControl(owner, "Scene", (value) => {
34
+ const choice = this.sceneChoices?.find((scene) => scene.id === value);
30
35
  if (choice)
31
36
  actions.seek(choice.startMs);
32
37
  });
33
- this.speed = createSelect(owner, "Speed");
34
- this.speed.addEventListener("change", () => actions.setSpeed(Number(this.speed.value)));
35
- this.element.append(this.previous, this.play, this.next, this.progress, this.scene, this.speed);
38
+ this.speed = new SelectControl(owner, "Speed", (value) => actions.setSpeed(Number(value)));
39
+ this.element.append(this.previous, this.play, this.next, this.progress, this.scene.element, this.speed.element, this.loop);
36
40
  }
37
- /** Update existing controls; scene choices change by reference and custom speeds are added as options. */
41
+ /** Update existing controls; custom speeds join the default choices without changing committed state. */
38
42
  render(state) {
39
43
  const isStatic = state.totalDuration === 0;
40
44
  this.play.textContent = state.playing ? "Pause" : "Play";
41
45
  this.play.setAttribute("aria-label", this.play.textContent);
42
- this.play.disabled = isStatic || (!state.playing && state.timeMs >= state.totalDuration);
46
+ this.play.disabled = isStatic || (!state.playing && !state.loop && state.timeMs >= state.totalDuration);
47
+ this.loop.setAttribute("aria-pressed", String(state.loop));
48
+ this.loop.disabled = isStatic;
43
49
  this.previous.disabled = isStatic || state.timeMs <= 0;
44
50
  this.next.disabled = isStatic || state.timeMs >= state.totalDuration;
45
51
  this.progress.disabled = isStatic;
46
52
  this.progress.max = String(state.totalDuration);
47
53
  this.progress.value = String(state.timeMs);
48
- this.scene.disabled = isStatic;
49
- this.speed.disabled = isStatic;
50
- if (this.sceneChoices !== state.scenes) {
51
- this.sceneChoices = state.scenes;
52
- this.scene.replaceChildren(...state.scenes.map((scene) => createOption(this.scene.ownerDocument, scene.id, scene.label)));
53
- }
54
- this.scene.value = state.sceneId ?? "";
54
+ this.sceneChoices = state.scenes;
55
+ this.scene.render(state.scenes.map((scene) => ({ value: scene.id, label: scene.label })), state.sceneId ?? "", isStatic);
55
56
  const speeds = [...new Set([...DEFAULT_SPEEDS, state.speed])].sort((left, right) => left - right);
56
- if (!Array.from(this.speed.options).some((option) => Number(option.value) === state.speed)) {
57
- this.speed.replaceChildren(...speeds.map((speed) => createOption(this.speed.ownerDocument, String(speed), `${speed}×`)));
58
- }
59
- this.speed.value = String(state.speed);
57
+ this.speed.render(speeds.map((speed) => ({ value: String(speed), label: `${speed}×` })), String(state.speed), isStatic);
58
+ }
59
+ /** Release menu listeners before the player removes its DOM. */
60
+ destroy() {
61
+ this.scene.destroy();
62
+ this.speed.destroy();
60
63
  }
61
64
  }
62
65
  function createButton(owner, label, click) {
@@ -67,14 +70,3 @@ function createButton(owner, label, click) {
67
70
  button.addEventListener("click", click);
68
71
  return button;
69
72
  }
70
- function createSelect(owner, label) {
71
- const select = owner.createElement("select");
72
- select.setAttribute("aria-label", label);
73
- return select;
74
- }
75
- function createOption(owner, value, label) {
76
- const option = owner.createElement("option");
77
- option.value = value;
78
- option.textContent = label;
79
- return option;
80
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Optional player GIF UI. One task owns its input, abort signal and download resources independently of playback.
3
+ * The browser exporter stays behind a dynamic import; portable builds replace only that import with an inline bridge.
4
+ */
5
+ import type { DiagramDocument, ThemeName } from "../core/model.js";
6
+ interface ExportInput {
7
+ document: DiagramDocument;
8
+ theme: ThemeName;
9
+ }
10
+ /** Own the visible export state and one cancellable task; destroy silences asynchronous results and releases downloads. */
11
+ export declare class GifExportControls {
12
+ private readonly owner;
13
+ private readonly captureInput;
14
+ readonly element: HTMLSpanElement;
15
+ private readonly startButton;
16
+ private readonly cancelButton;
17
+ private readonly status;
18
+ private readonly progress;
19
+ private task;
20
+ private destroyed;
21
+ private readonly downloadUrls;
22
+ constructor(owner: Document, captureInput: () => ExportInput);
23
+ destroy(): void;
24
+ /** Reserve the slot and capture input before loading code so repeated clicks and document loads cannot race the import. */
25
+ private start;
26
+ private cancel;
27
+ private run;
28
+ private isActive;
29
+ private renderProgress;
30
+ private download;
31
+ }
32
+ export {};