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.
- package/CHANGELOG.md +54 -1
- package/PATTERNS.md +71 -4
- package/README.md +148 -438
- package/README.zh-CN.md +148 -438
- package/dist/adapters/react.d.ts +55 -0
- package/dist/adapters/react.js +138 -0
- package/dist/adapters/vue.d.ts +158 -0
- package/dist/adapters/vue.js +177 -0
- package/dist/adapters/web-component.d.ts +111 -0
- package/dist/adapters/web-component.js +425 -0
- package/dist/{player-controls.d.ts → browser/controls.d.ts} +7 -3
- package/dist/{player-controls.js → 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 +77 -0
- package/dist/browser/player.js +412 -0
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +5 -69
- package/dist/browser.js +3 -353
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +40 -0
- package/dist/core/diagram.js +481 -0
- package/dist/core/edge-geometry.d.ts +45 -0
- package/dist/core/edge-geometry.js +609 -0
- package/dist/{core.d.ts → core/model.d.ts} +37 -61
- package/dist/core/model.js +1 -0
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
- package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
- package/dist/{node-content.d.ts → core/node-content.d.ts} +18 -1
- package/dist/{node-content.js → core/node-content.js} +30 -6
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
- package/dist/core/theme.js +17 -0
- package/dist/{timeline.d.ts → core/timeline.d.ts} +11 -5
- package/dist/{timeline.js → core/timeline.js} +30 -22
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +399 -0
- 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 +7 -1
- package/dist/index.js +3 -1
- 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/react.d.ts +3 -48
- package/dist/react.js +2 -126
- package/dist/styles.css +194 -14
- package/dist/vue.d.ts +3 -132
- package/dist/vue.js +2 -161
- package/dist/web-component.d.ts +3 -103
- package/dist/web-component.js +2 -396
- package/docs/images/entity-graph.png +0 -0
- package/docs/images/facts-timeline.png +0 -0
- package/docs/images/facts-timeline.zh-CN.png +0 -0
- package/docs/images/flowsvg-wordmark.gif +0 -0
- package/docs/images/node-content.png +0 -0
- package/docs/images/numbered-list.png +0 -0
- package/docs/images/numbered-list.zh-CN.png +0 -0
- package/docs/images/quick-start.png +0 -0
- package/docs/images/record-tags.png +0 -0
- package/package.json +38 -6
- package/dist/core.js +0 -1149
- package/dist/theme.js +0 -6
- /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
- /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
- /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
- /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
- /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
- /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { DiagramDocument, DiagramFrameState, DiagramPoint, EvaluateOptions, HitObject, ThemeName } from "../core/model.js";
|
|
2
|
+
/** Creation options: controls default on, motion follows browser preferences, and theme falls back to the document/light. */
|
|
3
|
+
export interface PlayerOptions extends EvaluateOptions {
|
|
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;
|
|
9
|
+
/** Hover highlights borders by default; true also paints the background and adjusts content ink. Applies to groups too. */
|
|
10
|
+
hoverFill?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Player snapshot: times are milliseconds, speed is a positive multiplier, and frame is the core result without hover. */
|
|
13
|
+
export interface PlayerState {
|
|
14
|
+
timeMs: number;
|
|
15
|
+
totalDuration: number;
|
|
16
|
+
playing: boolean;
|
|
17
|
+
loop: boolean;
|
|
18
|
+
speed: number;
|
|
19
|
+
frame: DiagramFrameState;
|
|
20
|
+
}
|
|
21
|
+
/** Object interaction detail; point uses document coordinates and time/beat IDs reflect the event-time player state. */
|
|
22
|
+
export interface PlayerInteractionDetail extends HitObject {
|
|
23
|
+
point: DiagramPoint;
|
|
24
|
+
timeMs: number;
|
|
25
|
+
sceneId: string | null;
|
|
26
|
+
beatId: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** Imperative browser player; creation/successful load pause at zero, and controls become inert after destruction. */
|
|
29
|
+
export interface FlowPlayer {
|
|
30
|
+
/** The supplied host, also used as the CustomEvent dispatch target. */
|
|
31
|
+
readonly element: HTMLElement;
|
|
32
|
+
/** Play from the current position; loop mode restarts at the end. Static or page-hidden players cannot start. */
|
|
33
|
+
play(): void;
|
|
34
|
+
/** Stop time advancement, retaining the current frame. */
|
|
35
|
+
pause(): void;
|
|
36
|
+
/** Seek to a clamped millisecond time; preserve playback except at the end. Non-finite times become zero. */
|
|
37
|
+
seek(timeMs: number): void;
|
|
38
|
+
/** Pause and jump to the adjacent start; backward goes to this beat's start from inside it, or the preceding start. */
|
|
39
|
+
step(direction: 1 | -1): void;
|
|
40
|
+
/** Set a finite positive speed multiplier; ignore invalid values. */
|
|
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;
|
|
44
|
+
/** Compile before replacing; reset/pause at zero on success, or emit error and retain the old document/time/playback. */
|
|
45
|
+
load(document: DiagramDocument): void;
|
|
46
|
+
/** Read current player state; the last state remains readable after destruction. */
|
|
47
|
+
getState(): PlayerState;
|
|
48
|
+
/** Silently and idempotently release frames/listeners/host content; no further player events are dispatched. */
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
/** Retained state and original load error; DiagramValidationError.issues provides field paths for validation failures. */
|
|
52
|
+
export interface PlayerErrorDetail extends PlayerState {
|
|
53
|
+
error: Error;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Host events and their detail types. Frame updates emit stepchange when needed, then timeupdate.
|
|
57
|
+
* hover/click use null for no hit; objecthover/objectclick report the same interactions under alternate names.
|
|
58
|
+
* Hover also changes when a frame update changes the object under a stationary pointer.
|
|
59
|
+
*/
|
|
60
|
+
export type PlayerEventMap = {
|
|
61
|
+
stepchange: CustomEvent<PlayerState>;
|
|
62
|
+
timeupdate: CustomEvent<PlayerState>;
|
|
63
|
+
playstatechange: CustomEvent<PlayerState>;
|
|
64
|
+
loopchange: CustomEvent<PlayerState>;
|
|
65
|
+
error: CustomEvent<PlayerErrorDetail>;
|
|
66
|
+
hover: CustomEvent<PlayerInteractionDetail | null>;
|
|
67
|
+
click: CustomEvent<PlayerInteractionDetail | null>;
|
|
68
|
+
objecthover: CustomEvent<PlayerInteractionDetail | null>;
|
|
69
|
+
objectclick: CustomEvent<PlayerInteractionDetail | null>;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Mount a player using the host document and observe browser motion preferences unless explicitly overridden.
|
|
73
|
+
* Replaces host children; destroy an existing player before replacing it on the same host.
|
|
74
|
+
* @throws {DiagramValidationError} For an invalid initial document, before an instance exists to emit error.
|
|
75
|
+
*/
|
|
76
|
+
export declare function createPlayer(host: HTMLElement, document: DiagramDocument, options?: PlayerOptions): FlowPlayer;
|
|
77
|
+
export type { ThemeName };
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser player: own the clock, DOM, controls and input, forwarding time and visual options to the core.
|
|
3
|
+
* Importing does not access the DOM; createPlayer allocates mount resources and destroy releases them.
|
|
4
|
+
*/
|
|
5
|
+
import { GifExportControls } from "./export-controls.js";
|
|
6
|
+
import { paintContentHighlight } from "../core/node-content-paint.js";
|
|
7
|
+
import { updateSvgDom } from "./svg-dom.js";
|
|
8
|
+
import { attachPlayerLifecycle } from "./lifecycle.js";
|
|
9
|
+
import { observeKeyboard, observeReducedMotion } from "./accessibility.js";
|
|
10
|
+
import { PlayerControls } from "./controls.js";
|
|
11
|
+
import { resolveThemePalette } from "../core/theme.js";
|
|
12
|
+
import { createDiagram } from "../core/diagram.js";
|
|
13
|
+
/**
|
|
14
|
+
* Mount a player using the host document and observe browser motion preferences unless explicitly overridden.
|
|
15
|
+
* Replaces host children; destroy an existing player before replacing it on the same host.
|
|
16
|
+
* @throws {DiagramValidationError} For an invalid initial document, before an instance exists to emit error.
|
|
17
|
+
*/
|
|
18
|
+
export function createPlayer(host, document, options = {}) {
|
|
19
|
+
const initial = createDiagram(document);
|
|
20
|
+
return new BrowserPlayer(host, initial, document, options);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Own one compiled Diagram and advance time with RAF; core results drive the canvas, caption and controls.
|
|
24
|
+
* Retain cleanup callbacks for lifecycle, media preferences and pointer listeners, releasing them on destroy.
|
|
25
|
+
*/
|
|
26
|
+
class BrowserPlayer {
|
|
27
|
+
element;
|
|
28
|
+
diagram;
|
|
29
|
+
sourceDocument;
|
|
30
|
+
gifControls;
|
|
31
|
+
options;
|
|
32
|
+
timeMs = 0;
|
|
33
|
+
speed = 1;
|
|
34
|
+
playing = false;
|
|
35
|
+
loop;
|
|
36
|
+
playbackIntent = 0;
|
|
37
|
+
destroyed = false;
|
|
38
|
+
frameHandle;
|
|
39
|
+
lastTimestamp;
|
|
40
|
+
frame;
|
|
41
|
+
canvas;
|
|
42
|
+
caption;
|
|
43
|
+
controls;
|
|
44
|
+
scenes;
|
|
45
|
+
beatStarts;
|
|
46
|
+
detachLifecycle;
|
|
47
|
+
stopKeyboard;
|
|
48
|
+
stopMotion;
|
|
49
|
+
stopPointer;
|
|
50
|
+
themeOverride;
|
|
51
|
+
pointerPosition;
|
|
52
|
+
hoveredHit = null;
|
|
53
|
+
constructor(host, diagram, document, options) {
|
|
54
|
+
this.element = host;
|
|
55
|
+
this.loop = options.loop ?? false;
|
|
56
|
+
this.diagram = diagram;
|
|
57
|
+
this.sourceDocument = cloneSourceDocument(document);
|
|
58
|
+
this.beatStarts = diagram.beats.map((beat) => beat.startMs);
|
|
59
|
+
this.scenes = collectScenes(document, diagram);
|
|
60
|
+
const owner = host.ownerDocument;
|
|
61
|
+
const motion = observeReducedMotion(owner, options.reducedMotion, (reducedMotion) => {
|
|
62
|
+
if (this.destroyed)
|
|
63
|
+
return;
|
|
64
|
+
this.options = { ...this.options, reducedMotion };
|
|
65
|
+
this.updateFrame();
|
|
66
|
+
});
|
|
67
|
+
this.stopMotion = motion.cleanup;
|
|
68
|
+
this.themeOverride = options.theme;
|
|
69
|
+
this.options = { ...options, theme: options.theme ?? document.theme ?? "light", reducedMotion: motion.value };
|
|
70
|
+
this.frame = diagram.evaluateAt(0, this.options);
|
|
71
|
+
this.canvas = owner.createElement("div");
|
|
72
|
+
this.canvas.className = "flowsvg-canvas";
|
|
73
|
+
this.stopKeyboard = observeKeyboard(this.canvas, { toggle: this.togglePlayback, step: (direction) => this.step(direction) });
|
|
74
|
+
this.caption = owner.createElement("p");
|
|
75
|
+
this.caption.className = "flowsvg-caption";
|
|
76
|
+
this.caption.setAttribute("aria-live", "polite");
|
|
77
|
+
this.caption.setAttribute("aria-atomic", "true");
|
|
78
|
+
if (options.controls !== false) {
|
|
79
|
+
this.controls = new PlayerControls(owner, {
|
|
80
|
+
toggle: this.togglePlayback,
|
|
81
|
+
step: (direction) => this.step(direction),
|
|
82
|
+
seek: (timeMs) => this.seek(timeMs),
|
|
83
|
+
setSpeed: (speed) => this.setSpeed(speed),
|
|
84
|
+
toggleLoop: () => this.setLoop(!this.loop)
|
|
85
|
+
});
|
|
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
|
+
}
|
|
93
|
+
host.replaceChildren(this.canvas, this.caption, ...(this.controls ? [this.controls.element] : []));
|
|
94
|
+
this.render();
|
|
95
|
+
this.stopPointer = observePointer(this.canvas, {
|
|
96
|
+
move: (event) => this.handlePointer("hover", event),
|
|
97
|
+
leave: () => this.clearPointer(),
|
|
98
|
+
click: (event) => this.handlePointer("click", event)
|
|
99
|
+
});
|
|
100
|
+
this.detachLifecycle = attachPlayerLifecycle(host, this.canvas, () => this.pause());
|
|
101
|
+
}
|
|
102
|
+
play() {
|
|
103
|
+
if (this.destroyed || this.playing || this.isPageHidden() || this.diagram.totalDuration <= 0)
|
|
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
|
+
}
|
|
115
|
+
this.playing = true;
|
|
116
|
+
this.lastTimestamp = undefined;
|
|
117
|
+
this.renderControls();
|
|
118
|
+
this.emit("playstatechange");
|
|
119
|
+
this.scheduleFrame();
|
|
120
|
+
}
|
|
121
|
+
pause() {
|
|
122
|
+
if (this.destroyed)
|
|
123
|
+
return;
|
|
124
|
+
this.playbackIntent += 1;
|
|
125
|
+
if (!this.playing)
|
|
126
|
+
return;
|
|
127
|
+
this.playing = false;
|
|
128
|
+
if (this.frameHandle !== undefined)
|
|
129
|
+
cancelAnimationFrame(this.frameHandle);
|
|
130
|
+
this.frameHandle = undefined;
|
|
131
|
+
this.renderControls();
|
|
132
|
+
this.emit("playstatechange");
|
|
133
|
+
}
|
|
134
|
+
seek(timeMs) {
|
|
135
|
+
if (this.destroyed)
|
|
136
|
+
return;
|
|
137
|
+
this.playbackIntent += 1;
|
|
138
|
+
this.timeMs = clamp(timeMs, 0, this.diagram.totalDuration);
|
|
139
|
+
this.lastTimestamp = undefined;
|
|
140
|
+
this.updateFrame();
|
|
141
|
+
if (this.timeMs >= this.diagram.totalDuration)
|
|
142
|
+
this.pause();
|
|
143
|
+
}
|
|
144
|
+
step(direction) {
|
|
145
|
+
if (this.destroyed)
|
|
146
|
+
return;
|
|
147
|
+
this.pause();
|
|
148
|
+
const starts = direction > 0 ? this.beatStarts : [...this.beatStarts].reverse();
|
|
149
|
+
const target = starts.find((start) => direction > 0 ? start > this.timeMs : start < this.timeMs);
|
|
150
|
+
this.seek(target ?? (direction > 0 ? this.diagram.totalDuration : 0));
|
|
151
|
+
}
|
|
152
|
+
setSpeed(speed) {
|
|
153
|
+
if (this.destroyed || !Number.isFinite(speed) || speed <= 0)
|
|
154
|
+
return;
|
|
155
|
+
this.speed = speed;
|
|
156
|
+
this.renderControls();
|
|
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
|
+
}
|
|
165
|
+
load(document) {
|
|
166
|
+
if (this.destroyed)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
// Keep the old player intact until compilation succeeds so invalid loads do not interrupt valid playback.
|
|
170
|
+
const diagram = createDiagram(document);
|
|
171
|
+
this.pause();
|
|
172
|
+
if (this.destroyed)
|
|
173
|
+
return;
|
|
174
|
+
this.diagram = diagram;
|
|
175
|
+
this.sourceDocument = cloneSourceDocument(document);
|
|
176
|
+
this.options = { ...this.options, theme: this.themeOverride ?? document.theme ?? "light" };
|
|
177
|
+
this.beatStarts = diagram.beats.map((beat) => beat.startMs);
|
|
178
|
+
this.scenes = collectScenes(document, diagram);
|
|
179
|
+
this.timeMs = 0;
|
|
180
|
+
// A replacement document owns new interaction targets, even when it reuses the same node IDs.
|
|
181
|
+
this.pointerPosition = undefined;
|
|
182
|
+
this.updateFrame();
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.emit("error", { ...this.state(), error: error instanceof Error ? error : new Error(String(error)) });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
getState() {
|
|
189
|
+
return this.state();
|
|
190
|
+
}
|
|
191
|
+
destroy() {
|
|
192
|
+
if (this.destroyed)
|
|
193
|
+
return;
|
|
194
|
+
// Block dispatch/rescheduling before releasing resources; do not call pause, which would emit an event.
|
|
195
|
+
this.destroyed = true;
|
|
196
|
+
this.gifControls?.destroy();
|
|
197
|
+
this.controls?.destroy();
|
|
198
|
+
this.playing = false;
|
|
199
|
+
if (this.frameHandle !== undefined)
|
|
200
|
+
cancelAnimationFrame(this.frameHandle);
|
|
201
|
+
this.frameHandle = undefined;
|
|
202
|
+
this.lastTimestamp = undefined;
|
|
203
|
+
this.detachLifecycle();
|
|
204
|
+
this.stopKeyboard();
|
|
205
|
+
this.stopMotion();
|
|
206
|
+
this.stopPointer();
|
|
207
|
+
this.pointerPosition = undefined;
|
|
208
|
+
this.hoveredHit = null;
|
|
209
|
+
this.element.replaceChildren();
|
|
210
|
+
}
|
|
211
|
+
/** The first RAF establishes a baseline; seek clears it so elapsed time from the old position is not reused. */
|
|
212
|
+
tick = (timestamp) => {
|
|
213
|
+
if (!this.playing || this.destroyed)
|
|
214
|
+
return;
|
|
215
|
+
this.frameHandle = undefined;
|
|
216
|
+
const previous = this.lastTimestamp ?? timestamp;
|
|
217
|
+
this.lastTimestamp = timestamp;
|
|
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) {
|
|
223
|
+
this.timeMs = this.diagram.totalDuration;
|
|
224
|
+
this.updateFrame();
|
|
225
|
+
this.pause();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.updateFrame();
|
|
229
|
+
this.scheduleFrame();
|
|
230
|
+
};
|
|
231
|
+
/** Event listeners may restart playback synchronously; never queue a second frame owned by the same player. */
|
|
232
|
+
scheduleFrame() {
|
|
233
|
+
if (this.destroyed || !this.playing || this.frameHandle !== undefined)
|
|
234
|
+
return;
|
|
235
|
+
this.frameHandle = requestAnimationFrame(this.tick);
|
|
236
|
+
}
|
|
237
|
+
/** Update the frame before notifying consumers so listeners observe the rendered position. */
|
|
238
|
+
updateFrame() {
|
|
239
|
+
const previousBeat = this.frame.beatId;
|
|
240
|
+
const previousScene = this.frame.sceneId;
|
|
241
|
+
this.frame = this.diagram.evaluateAt(this.timeMs, this.options);
|
|
242
|
+
this.render();
|
|
243
|
+
if (previousBeat !== this.frame.beatId || previousScene !== this.frame.sceneId)
|
|
244
|
+
this.emit("stepchange");
|
|
245
|
+
this.emit("timeupdate");
|
|
246
|
+
}
|
|
247
|
+
isPageHidden() {
|
|
248
|
+
return this.element.ownerDocument.visibilityState === "hidden";
|
|
249
|
+
}
|
|
250
|
+
togglePlayback = () => {
|
|
251
|
+
if (this.playing)
|
|
252
|
+
this.pause();
|
|
253
|
+
else
|
|
254
|
+
this.play();
|
|
255
|
+
};
|
|
256
|
+
render() {
|
|
257
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
258
|
+
this.canvas.style.aspectRatio = `${this.frame.camera.width} / ${this.frame.camera.height}`;
|
|
259
|
+
this.updateHover();
|
|
260
|
+
this.caption.textContent = this.frame.caption ?? "";
|
|
261
|
+
this.renderControls();
|
|
262
|
+
}
|
|
263
|
+
renderControls() {
|
|
264
|
+
this.controls?.render({ ...this.state(), sceneId: this.frame.sceneId, scenes: this.scenes });
|
|
265
|
+
}
|
|
266
|
+
state() {
|
|
267
|
+
return { timeMs: this.timeMs, totalDuration: this.diagram.totalDuration, playing: this.playing, loop: this.loop, speed: this.speed, frame: this.frame };
|
|
268
|
+
}
|
|
269
|
+
handlePointer(type, event) {
|
|
270
|
+
if (this.destroyed)
|
|
271
|
+
return;
|
|
272
|
+
if (type === "hover") {
|
|
273
|
+
this.pointerPosition = { clientX: event.clientX, clientY: event.clientY };
|
|
274
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
275
|
+
this.updateHover(true);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (type === "click")
|
|
279
|
+
event.stopPropagation();
|
|
280
|
+
const point = pointerToDiagramPoint(this.canvas, this.frame.camera, event);
|
|
281
|
+
const hit = this.diagram.hitTest(this.timeMs, point, this.options);
|
|
282
|
+
const detail = hit ? { ...hit, point, timeMs: this.timeMs, sceneId: this.frame.sceneId, beatId: this.frame.beatId } : null;
|
|
283
|
+
this.emit("click", detail);
|
|
284
|
+
this.emit("objectclick", detail);
|
|
285
|
+
}
|
|
286
|
+
/** Re-hit the saved screen position after camera/geometry updates; hover never changes the core hit geometry. */
|
|
287
|
+
updateHover(emitAlways = false) {
|
|
288
|
+
const point = this.pointerPosition ? pointerToDiagramPoint(this.canvas, this.frame.camera, this.pointerPosition) : undefined;
|
|
289
|
+
const hit = point ? this.diagram.hitTest(this.timeMs, point, this.options) : null;
|
|
290
|
+
if (hit?.type === "node") {
|
|
291
|
+
const shape = Array.from(this.canvas.querySelectorAll("[data-id]")).find((element) => element.getAttribute("data-id") === hit.id);
|
|
292
|
+
const palette = resolveThemePalette(this.options.theme);
|
|
293
|
+
const node = this.frame.nodes.find((candidate) => candidate.id === hit.id);
|
|
294
|
+
// Border-only hover preserves all current timeline and content paint unless fill feedback is explicitly enabled.
|
|
295
|
+
if (this.options.hoverFill)
|
|
296
|
+
shape?.setAttribute("fill", palette.highlightFill);
|
|
297
|
+
shape?.setAttribute("stroke", palette.highlight);
|
|
298
|
+
const layout = node?.contentLayout;
|
|
299
|
+
const content = Array.from(this.canvas.querySelectorAll("[data-content-id]")).find((element) => element.getAttribute("data-content-id") === hit.id);
|
|
300
|
+
if (this.options.hoverFill && content && layout)
|
|
301
|
+
paintHoveredContent(content, paintContentHighlight(layout, 1, palette.highlightFill));
|
|
302
|
+
}
|
|
303
|
+
const changed = hit?.id !== this.hoveredHit?.id || hit?.type !== this.hoveredHit?.type;
|
|
304
|
+
this.hoveredHit = hit;
|
|
305
|
+
if (!emitAlways && !changed)
|
|
306
|
+
return;
|
|
307
|
+
const detail = hit && point ? { ...hit, point, timeMs: this.timeMs, sceneId: this.frame.sceneId, beatId: this.frame.beatId } : null;
|
|
308
|
+
this.emit("hover", detail);
|
|
309
|
+
this.emit("objecthover", detail);
|
|
310
|
+
}
|
|
311
|
+
clearPointer() {
|
|
312
|
+
if (this.destroyed)
|
|
313
|
+
return;
|
|
314
|
+
this.pointerPosition = undefined;
|
|
315
|
+
updateSvgDom(this.canvas, this.diagram.renderSvg(this.timeMs, this.options));
|
|
316
|
+
this.updateHover(true);
|
|
317
|
+
}
|
|
318
|
+
emit(type, detail) {
|
|
319
|
+
if (!this.destroyed)
|
|
320
|
+
this.element.dispatchEvent(new CustomEvent(type, { detail: detail === undefined ? this.state() : detail }));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Apply the shared paint result to current decorative children; no child receives a separate interaction identity. */
|
|
324
|
+
function paintHoveredContent(content, layout) {
|
|
325
|
+
const tags = content.querySelectorAll("rect");
|
|
326
|
+
layout.tags.forEach((tag, index) => tags[index]?.setAttribute("fill", tag.fill));
|
|
327
|
+
const texts = content.querySelectorAll("text");
|
|
328
|
+
layout.lines.forEach((line, index) => {
|
|
329
|
+
const text = texts[index];
|
|
330
|
+
text?.setAttribute("fill", line.color);
|
|
331
|
+
const spans = text?.querySelectorAll("tspan");
|
|
332
|
+
line.spans.forEach((span, spanIndex) => spans?.[spanIndex]?.setAttribute("fill", span.color));
|
|
333
|
+
});
|
|
334
|
+
const graphs = content.querySelectorAll("[data-content-graph]");
|
|
335
|
+
layout.graphs?.forEach((graph, index) => {
|
|
336
|
+
const circles = graphs[index]?.querySelectorAll("circle");
|
|
337
|
+
const edges = graphs[index]?.querySelectorAll("line");
|
|
338
|
+
graph.nodes.forEach((node, nodeIndex) => circles?.[nodeIndex]?.setAttribute("fill", node.fill));
|
|
339
|
+
graph.edges.forEach((edge, edgeIndex) => edges?.[edgeIndex]?.setAttribute("stroke", edge.color));
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
function clamp(value, min, max) {
|
|
343
|
+
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
|
344
|
+
}
|
|
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
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
function observePointer(canvas, handlers) {
|
|
353
|
+
const onMove = (event) => handlers.move(event);
|
|
354
|
+
const onLeave = () => handlers.leave();
|
|
355
|
+
const onClick = (event) => handlers.click(event);
|
|
356
|
+
canvas.addEventListener("pointermove", onMove);
|
|
357
|
+
canvas.addEventListener("pointerleave", onLeave);
|
|
358
|
+
canvas.addEventListener("pointercancel", onLeave);
|
|
359
|
+
canvas.addEventListener("click", onClick);
|
|
360
|
+
return () => {
|
|
361
|
+
canvas.removeEventListener("pointermove", onMove);
|
|
362
|
+
canvas.removeEventListener("pointerleave", onLeave);
|
|
363
|
+
canvas.removeEventListener("pointercancel", onLeave);
|
|
364
|
+
canvas.removeEventListener("click", onClick);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Prefer the inverse SVG screen transform for scaling/letterboxing; otherwise map the element rectangle to the camera.
|
|
369
|
+
* The rectangle fallback lacks full SVG transform information and is only an approximation.
|
|
370
|
+
*/
|
|
371
|
+
function pointerToDiagramPoint(canvas, camera, event) {
|
|
372
|
+
const svg = canvas.querySelector("svg");
|
|
373
|
+
const matrix = svg?.getScreenCTM?.();
|
|
374
|
+
if (matrix) {
|
|
375
|
+
const inverse = matrix.inverse();
|
|
376
|
+
return {
|
|
377
|
+
x: event.clientX * inverse.a + event.clientY * inverse.c + inverse.e,
|
|
378
|
+
y: event.clientX * inverse.b + event.clientY * inverse.d + inverse.f
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const bounds = svg?.getBoundingClientRect() ?? canvas.getBoundingClientRect();
|
|
382
|
+
const width = bounds.width || 1;
|
|
383
|
+
const height = bounds.height || 1;
|
|
384
|
+
return {
|
|
385
|
+
x: camera.x + ((event.clientX - bounds.left) / width) * camera.width,
|
|
386
|
+
y: camera.y + ((event.clientY - bounds.top) / height) * camera.height
|
|
387
|
+
};
|
|
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
|
+
}
|