flowsvg 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
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.js";
6
+ import { createPlayer } from "./browser.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 the four 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
+ ];
30
+ player;
31
+ playerHost;
32
+ sourceAbort;
33
+ requestToken = 0;
34
+ connected = false;
35
+ manuallyDestroyed = false;
36
+ currentDocument;
37
+ controlsOverride;
38
+ forwardedEvents = [];
39
+ /** Current object document; assigning undefined clears it, cancels requests and unmounts the player. */
40
+ get document() {
41
+ return this.currentDocument;
42
+ }
43
+ set document(value) {
44
+ if (value === undefined) {
45
+ this.currentDocument = undefined;
46
+ this.cancelSourceLoad();
47
+ this.unmountPlayer();
48
+ return;
49
+ }
50
+ this.setDocument(value);
51
+ }
52
+ /** Document URL resolved against ownerDocument.baseURI; changes cancel obsolete loads. */
53
+ get src() {
54
+ return this.getAttribute?.("src") ?? undefined;
55
+ }
56
+ set src(value) {
57
+ if (value === undefined || value === "")
58
+ this.removeAttribute("src");
59
+ else
60
+ this.setAttribute("src", value);
61
+ }
62
+ /** Whether to autoplay; false pauses a mounted player. */
63
+ get autoplay() {
64
+ return readBooleanAttribute(this, "autoplay", false);
65
+ }
66
+ set autoplay(value) {
67
+ setBooleanAttribute(this, "autoplay", value);
68
+ this.applyAutoplay();
69
+ }
70
+ /** Controls default on; an explicit false is retained in HTML as controls="false". */
71
+ get controls() {
72
+ return this.controlsOverride ?? readBooleanAttribute(this, "controls", true);
73
+ }
74
+ set controls(value) {
75
+ this.controlsOverride = value;
76
+ // An omitted attribute enables controls, so disabling must preserve the false text rather than remove the attribute.
77
+ if (value)
78
+ this.setAttribute("controls", "");
79
+ else
80
+ this.setAttribute("controls", "false");
81
+ this.rebuildForOptions();
82
+ }
83
+ /** Positive speed multiplier; invalid attribute text reads as 1, while invalid property assignments are ignored. */
84
+ get speed() {
85
+ return readPositiveNumber(this.getAttribute?.("speed"), 1);
86
+ }
87
+ set speed(value) {
88
+ if (!Number.isFinite(value) || value <= 0)
89
+ return;
90
+ this.setAttribute("speed", String(value));
91
+ this.player?.setSpeed(value);
92
+ }
93
+ /** Explicit light/dark theme; the player falls back to the document theme or light when unset. */
94
+ get theme() {
95
+ const value = this.getAttribute?.("theme");
96
+ return value === "light" || value === "dark" ? value : undefined;
97
+ }
98
+ set theme(value) {
99
+ if (value === undefined)
100
+ this.removeAttribute("theme");
101
+ else
102
+ this.setAttribute("theme", value);
103
+ }
104
+ /** undefined follows the system preference; true/false explicitly override it via reduced-motion. */
105
+ get reducedMotion() {
106
+ const value = this.getAttribute?.("reduced-motion");
107
+ if (value === null || value === undefined)
108
+ return undefined;
109
+ return parseBooleanValue(value, true);
110
+ }
111
+ set reducedMotion(value) {
112
+ if (value === undefined)
113
+ this.removeAttribute("reduced-motion");
114
+ else
115
+ this.setAttribute("reduced-motion", String(value));
116
+ }
117
+ /** Hover paints only borders by default. The hover-fill attribute enables background and content feedback. */
118
+ get hoverFill() {
119
+ return readBooleanAttribute(this, "hover-fill", false);
120
+ }
121
+ set hoverFill(value) {
122
+ setBooleanAttribute(this, "hover-fill", value);
123
+ }
124
+ connectedCallback() {
125
+ this.connected = true;
126
+ this.manuallyDestroyed = false;
127
+ if (this.player)
128
+ return;
129
+ if (this.currentDocument) {
130
+ this.mountPlayer(this.currentDocument);
131
+ return;
132
+ }
133
+ const documentAttribute = this.getAttribute("document");
134
+ if (documentAttribute !== null) {
135
+ this.loadDocumentAttribute(documentAttribute);
136
+ return;
137
+ }
138
+ const source = this.getAttribute("src");
139
+ if (source)
140
+ void this.loadSource(source);
141
+ }
142
+ disconnectedCallback() {
143
+ this.connected = false;
144
+ this.cancelSourceLoad();
145
+ this.unmountPlayer();
146
+ }
147
+ attributeChangedCallback(name, _oldValue, value) {
148
+ if (_oldValue === value || !this.connected)
149
+ return;
150
+ switch (name) {
151
+ case "document":
152
+ if (value === null) {
153
+ this.currentDocument = undefined;
154
+ this.unmountPlayer();
155
+ }
156
+ else
157
+ this.loadDocumentAttribute(value);
158
+ break;
159
+ case "src":
160
+ this.cancelSourceLoad();
161
+ if (value)
162
+ void this.loadSource(value);
163
+ break;
164
+ case "controls":
165
+ this.controlsOverride = value === null ? undefined : parseBooleanValue(value, true);
166
+ case "theme":
167
+ case "reduced-motion":
168
+ case "hover-fill":
169
+ this.rebuildForOptions();
170
+ break;
171
+ case "speed":
172
+ this.player?.setSpeed(this.speed);
173
+ break;
174
+ case "autoplay":
175
+ this.applyAutoplay();
176
+ break;
177
+ default:
178
+ break;
179
+ }
180
+ }
181
+ /** Forward controls to the mounted player; calls are inert when no player is mounted. */
182
+ play() {
183
+ this.player?.play();
184
+ }
185
+ /** Pause the mounted player, retaining its current frame; do nothing before mounting. */
186
+ pause() {
187
+ this.player?.pause();
188
+ }
189
+ /** Seek in milliseconds using browser-player clamping; do nothing before mounting. */
190
+ seek(timeMs) {
191
+ this.player?.seek(timeMs);
192
+ }
193
+ /** Pause and jump to a beat boundary; backward from inside a beat goes to that beat's start. */
194
+ step(direction) {
195
+ this.player?.step(direction);
196
+ }
197
+ /** Set the speed property, retaining it for future mounts; ignore non-finite or non-positive values. */
198
+ setSpeed(value) {
199
+ this.speed = value;
200
+ }
201
+ /** Return undefined before mounting or after destruction. */
202
+ getState() {
203
+ return this.player?.getState();
204
+ }
205
+ /** Explicitly release the player and requests; the manual-destruction flag resets on the next connection. */
206
+ destroy() {
207
+ this.manuallyDestroyed = true;
208
+ this.cancelSourceLoad();
209
+ this.unmountPlayer();
210
+ }
211
+ setDocument(value) {
212
+ this.cancelSourceLoad();
213
+ try {
214
+ // Validate before replacement so an invalid document does not discard the last valid frame.
215
+ createDiagram(value);
216
+ this.currentDocument = value;
217
+ if (this.connected && !this.manuallyDestroyed) {
218
+ if (this.player)
219
+ this.player.load(value);
220
+ else
221
+ this.mountPlayer(value);
222
+ this.applyAutoplay();
223
+ }
224
+ }
225
+ catch (error) {
226
+ this.dispatchError(toError(error));
227
+ }
228
+ }
229
+ loadDocumentAttribute(value) {
230
+ try {
231
+ const parsed = JSON.parse(value);
232
+ this.setDocument(parsed);
233
+ }
234
+ catch (error) {
235
+ this.dispatchError(toError(error));
236
+ }
237
+ }
238
+ /** AbortController cancels network work; the token also rejects obsolete results already received but not yet applied. */
239
+ async loadSource(source) {
240
+ const token = ++this.requestToken;
241
+ const controller = new AbortController();
242
+ this.sourceAbort?.abort();
243
+ this.sourceAbort = controller;
244
+ try {
245
+ const owner = this.ownerDocument;
246
+ const url = new URL(source, owner.baseURI).href;
247
+ const response = await fetch(url, { signal: controller.signal });
248
+ if (!response.ok)
249
+ throw new Error(`Failed to load diagram source (${response.status})`);
250
+ const parsed = await response.json();
251
+ // Recheck connection and request ownership after parsing so stale responses cannot replace a newer document/src.
252
+ if (!this.connected || token !== this.requestToken || controller.signal.aborted)
253
+ return;
254
+ this.setDocument(parsed);
255
+ }
256
+ catch (error) {
257
+ if (controller.signal.aborted || token !== this.requestToken)
258
+ return;
259
+ this.dispatchError(toError(error));
260
+ }
261
+ finally {
262
+ if (this.sourceAbort === controller)
263
+ this.sourceAbort = undefined;
264
+ }
265
+ }
266
+ /** Restore an existing playback state during option rebuilds; autoplay applies only to a fresh mount. */
267
+ mountPlayer(document, previous) {
268
+ if (!this.connected || this.manuallyDestroyed || this.player)
269
+ return;
270
+ const owner = this.ownerDocument;
271
+ const host = owner.createElement("div");
272
+ host.className = "flowsvg-element-host";
273
+ this.playerHost = host;
274
+ this.append(host);
275
+ try {
276
+ this.player = createPlayer(host, document, this.playerOptions());
277
+ this.forwardPlayerEvents(host);
278
+ this.player.setSpeed(this.speed);
279
+ if (previous) {
280
+ this.player.seek(previous.timeMs);
281
+ if (previous.playing)
282
+ this.player.play();
283
+ }
284
+ else
285
+ this.applyAutoplay();
286
+ }
287
+ catch (error) {
288
+ this.unmountPlayer();
289
+ this.dispatchError(toError(error));
290
+ }
291
+ }
292
+ /** Rebuild for option changes and restore time/playback; document replacement instead follows load's reset-to-zero rule. */
293
+ rebuildForOptions() {
294
+ if (!this.connected || !this.currentDocument || this.manuallyDestroyed)
295
+ return;
296
+ const previous = this.player?.getState();
297
+ this.unmountPlayer();
298
+ this.mountPlayer(this.currentDocument, previous);
299
+ }
300
+ /** Detach forwarding before destruction so teardown cannot forward events from the old instance. */
301
+ unmountPlayer() {
302
+ this.removeForwardedEvents();
303
+ this.player?.destroy();
304
+ this.player = undefined;
305
+ this.playerHost?.remove();
306
+ this.playerHost = undefined;
307
+ }
308
+ forwardPlayerEvents(host) {
309
+ this.removeForwardedEvents();
310
+ for (const name of ["stepchange", "timeupdate", "playstatechange", "error"]) {
311
+ const listener = (event) => {
312
+ const source = event;
313
+ this.dispatchEvent(new CustomEvent(name, {
314
+ detail: source.detail,
315
+ bubbles: true,
316
+ composed: true
317
+ }));
318
+ };
319
+ host.addEventListener(name, listener);
320
+ this.forwardedEvents.push({ name, listener });
321
+ }
322
+ }
323
+ removeForwardedEvents() {
324
+ if (!this.playerHost) {
325
+ this.forwardedEvents.length = 0;
326
+ return;
327
+ }
328
+ for (const { name, listener } of this.forwardedEvents)
329
+ this.playerHost.removeEventListener(name, listener);
330
+ this.forwardedEvents.length = 0;
331
+ }
332
+ playerOptions() {
333
+ return {
334
+ controls: this.controls,
335
+ hoverFill: this.hoverFill,
336
+ ...(this.theme ? { theme: this.theme } : {}),
337
+ ...(this.reducedMotion === undefined ? {} : { reducedMotion: this.reducedMotion })
338
+ };
339
+ }
340
+ applyAutoplay() {
341
+ if (!this.player)
342
+ return;
343
+ if (this.autoplay)
344
+ this.player.play();
345
+ else
346
+ this.player.pause();
347
+ }
348
+ cancelSourceLoad() {
349
+ this.requestToken += 1;
350
+ this.sourceAbort?.abort();
351
+ this.sourceAbort = undefined;
352
+ }
353
+ dispatchError(error) {
354
+ const detail = { error, ...(this.player ? this.player.getState() : {}) };
355
+ this.dispatchEvent(new CustomEvent("error", { detail, bubbles: true, composed: true }));
356
+ }
357
+ }
358
+ /**
359
+ * Register explicitly; repeated calls for the default name return the existing constructor, or undefined without customElements.
360
+ * Load the module with a DOM for real registration, since the HTMLElement base is fixed at module evaluation.
361
+ * @throws {DOMException} From the registry for invalid names or reusing the constructor under another name.
362
+ */
363
+ export function defineFlowPlayer(tagName = FLOW_PLAYER_TAG) {
364
+ if (typeof customElements === "undefined")
365
+ return undefined;
366
+ const existing = customElements.get(tagName);
367
+ if (existing)
368
+ return existing;
369
+ customElements.define(tagName, FlowPlayerElement);
370
+ return FlowPlayerElement;
371
+ }
372
+ function readBooleanAttribute(element, name, fallback) {
373
+ const value = element.getAttribute(name);
374
+ return value === null ? fallback : parseBooleanValue(value, true);
375
+ }
376
+ function parseBooleanValue(value, fallback) {
377
+ const normalized = value.trim().toLowerCase();
378
+ if (normalized === "false" || normalized === "0" || normalized === "no")
379
+ return false;
380
+ if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "")
381
+ return true;
382
+ return fallback;
383
+ }
384
+ function setBooleanAttribute(element, name, value) {
385
+ if (value)
386
+ element.setAttribute(name, "");
387
+ else
388
+ element.removeAttribute(name);
389
+ }
390
+ function readPositiveNumber(value, fallback) {
391
+ const parsed = Number(value);
392
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
393
+ }
394
+ function toError(error) {
395
+ return error instanceof Error ? error : new Error(String(error));
396
+ }
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "flowsvg",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Declarative SVG player for logic and architecture diagrams, with scenes, beats, static export and browser framework adapters.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/yokingma/FlowSVG.git",
10
+ "directory": "packages/flowsvg"
11
+ },
12
+ "homepage": "https://github.com/yokingma/FlowSVG#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/yokingma/FlowSVG/issues"
15
+ },
16
+ "keywords": ["svg", "diagram", "architecture", "animation", "timeline", "web-component", "react", "vue"],
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./styles.css": "./dist/styles.css",
28
+ "./browser": {
29
+ "types": "./dist/browser.d.ts",
30
+ "import": "./dist/browser.js"
31
+ },
32
+ "./web-component": {
33
+ "types": "./dist/web-component.d.ts",
34
+ "import": "./dist/web-component.js"
35
+ },
36
+ "./element": {
37
+ "types": "./dist/web-component.d.ts",
38
+ "import": "./dist/web-component.js"
39
+ },
40
+ "./react": {
41
+ "types": "./dist/react.d.ts",
42
+ "import": "./dist/react.js"
43
+ },
44
+ "./vue": {
45
+ "types": "./dist/vue.d.ts",
46
+ "import": "./dist/vue.js"
47
+ }
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "README.md",
52
+ "README.zh-CN.md",
53
+ "CHANGELOG.md",
54
+ "PATTERNS.md",
55
+ "LICENSE"
56
+ ],
57
+ "sideEffects": ["./dist/styles.css"],
58
+ "engines": {
59
+ "node": ">=18"
60
+ },
61
+ "scripts": {
62
+ "build": "rm -rf dist && tsc -p tsconfig.core.json && tsc -p tsconfig.browser.json && tsc -p tsconfig.react.json && tsc -p tsconfig.vue.json && cp src/styles.css dist/styles.css",
63
+ "typecheck": "tsc -p tsconfig.core.json --noEmit && tsc -p tsconfig.browser.json --noEmit && tsc -p tsconfig.react.json --noEmit && tsc -p tsconfig.vue.json --noEmit",
64
+ "test": "vitest run",
65
+ "prepack": "pnpm --silent build",
66
+ "prepublishOnly": "pnpm typecheck && pnpm test",
67
+ "release:verify": "node ../../scripts/verify-release.mjs"
68
+ },
69
+ "devDependencies": {
70
+ "@types/react": "^19.3.0",
71
+ "@types/react-dom": "^19.3.0",
72
+ "jsdom": "^26.1.0",
73
+ "react": "^19.3.0",
74
+ "react-dom": "^19.3.0",
75
+ "vue": "3.5.0"
76
+ },
77
+ "peerDependencies": {
78
+ "react": ">=18.0.0",
79
+ "vue": ">=3.5.0"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "react": {
83
+ "optional": true
84
+ },
85
+ "vue": {
86
+ "optional": true
87
+ }
88
+ }
89
+ }