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.
- package/CHANGELOG.md +28 -0
- package/PATTERNS.md +61 -5
- package/README.md +149 -461
- package/README.zh-CN.md +149 -461
- package/dist/adapters/react.d.ts +7 -0
- package/dist/adapters/react.js +14 -2
- package/dist/adapters/vue.d.ts +29 -3
- package/dist/adapters/vue.js +20 -4
- package/dist/adapters/web-component.d.ts +14 -2
- package/dist/adapters/web-component.js +32 -3
- package/dist/browser/controls.d.ts +7 -3
- package/dist/browser/controls.js +23 -31
- package/dist/browser/export-controls.d.ts +32 -0
- package/dist/browser/export-controls.js +151 -0
- package/dist/browser/player.d.ts +9 -1
- package/dist/browser/player.js +83 -24
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +2 -1
- package/dist/browser.js +2 -1
- package/dist/core/diagram.d.ts +4 -2
- package/dist/core/diagram.js +111 -21
- package/dist/core/edge-geometry.d.ts +16 -3
- package/dist/core/edge-geometry.js +264 -37
- package/dist/core/model.d.ts +35 -9
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/core/node-content.d.ts +17 -0
- package/dist/core/node-content.js +30 -6
- package/dist/core/timeline.d.ts +11 -3
- package/dist/core/timeline.js +29 -5
- package/dist/core/validation.d.ts +1 -1
- package/dist/core/validation.js +13 -3
- package/dist/export/browser.d.ts +9 -0
- package/dist/export/browser.js +172 -0
- package/dist/export/captions.d.ts +18 -0
- package/dist/export/captions.js +44 -0
- package/dist/export/plan.d.ts +25 -0
- package/dist/export/plan.js +94 -0
- package/dist/export/types.d.ts +64 -0
- package/dist/export/types.js +9 -0
- package/dist/export/worker-source.d.ts +1 -0
- package/dist/export/worker-source.js +1 -0
- package/dist/export.d.ts +3 -0
- package/dist/export.js +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +72 -0
- package/dist/node/gif.d.ts +16 -0
- package/dist/node/gif.js +184 -0
- package/dist/node/html.d.ts +19 -0
- package/dist/node/html.js +61 -0
- package/dist/node/output.d.ts +2 -0
- package/dist/node/output.js +22 -0
- package/dist/node.d.ts +6 -0
- package/dist/node.js +3 -0
- package/dist/portable/LICENSE.esbuild.txt +21 -0
- package/dist/portable/LICENSE.flowsvg.txt +21 -0
- package/dist/portable/LICENSE.gifenc.txt +21 -0
- package/dist/portable/export.js +5 -0
- package/dist/portable/manifest.json +36 -0
- package/dist/portable/player.js +2 -0
- package/dist/portable/styles.css +227 -0
- package/dist/styles.css +194 -14
- package/docs/images/flowsvg-wordmark.gif +0 -0
- 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
|
+
}
|
package/dist/browser/player.d.ts
CHANGED
|
@@ -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;
|
|
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>;
|
package/dist/browser/player.js
CHANGED
|
@@ -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.
|
|
52
|
-
this.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
|
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";
|
package/dist/core/diagram.d.ts
CHANGED
|
@@ -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.
|