flowsvg 0.1.0 → 0.1.1

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 (61) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/PATTERNS.md +12 -1
  3. package/README.md +139 -117
  4. package/README.zh-CN.md +139 -117
  5. package/dist/adapters/react.d.ts +48 -0
  6. package/dist/adapters/react.js +126 -0
  7. package/dist/adapters/vue.d.ts +132 -0
  8. package/dist/adapters/vue.js +161 -0
  9. package/dist/adapters/web-component.d.ts +99 -0
  10. package/dist/adapters/web-component.js +396 -0
  11. package/dist/browser/player.d.ts +69 -0
  12. package/dist/browser/player.js +353 -0
  13. package/dist/browser.d.ts +4 -69
  14. package/dist/browser.js +2 -353
  15. package/dist/core/color.d.ts +3 -0
  16. package/dist/core/color.js +18 -0
  17. package/dist/core/diagram.d.ts +38 -0
  18. package/dist/core/diagram.js +391 -0
  19. package/dist/core/edge-geometry.d.ts +32 -0
  20. package/dist/core/edge-geometry.js +382 -0
  21. package/dist/{core.d.ts → core/model.d.ts} +2 -52
  22. package/dist/core/model.js +1 -0
  23. package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
  24. package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
  25. package/dist/{node-content.d.ts → core/node-content.d.ts} +1 -1
  26. package/dist/core/svg-number.d.ts +5 -0
  27. package/dist/core/svg-number.js +7 -0
  28. package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
  29. package/dist/core/theme.js +17 -0
  30. package/dist/{timeline.d.ts → core/timeline.d.ts} +1 -3
  31. package/dist/{timeline.js → core/timeline.js} +1 -17
  32. package/dist/core/validation.d.ts +11 -0
  33. package/dist/core/validation.js +389 -0
  34. package/dist/index.d.ts +5 -1
  35. package/dist/index.js +2 -1
  36. package/dist/react.d.ts +3 -48
  37. package/dist/react.js +2 -126
  38. package/dist/vue.d.ts +3 -132
  39. package/dist/vue.js +2 -161
  40. package/dist/web-component.d.ts +3 -103
  41. package/dist/web-component.js +2 -396
  42. package/docs/images/entity-graph.png +0 -0
  43. package/docs/images/facts-timeline.png +0 -0
  44. package/docs/images/facts-timeline.zh-CN.png +0 -0
  45. package/docs/images/node-content.png +0 -0
  46. package/docs/images/numbered-list.png +0 -0
  47. package/docs/images/numbered-list.zh-CN.png +0 -0
  48. package/docs/images/quick-start.png +0 -0
  49. package/docs/images/record-tags.png +0 -0
  50. package/package.json +2 -1
  51. package/dist/core.js +0 -1149
  52. package/dist/theme.js +0 -6
  53. /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
  54. /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
  55. /package/dist/{player-controls.d.ts → browser/controls.d.ts} +0 -0
  56. /package/dist/{player-controls.js → browser/controls.js} +0 -0
  57. /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
  58. /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
  59. /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
  60. /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
  61. /package/dist/{node-content.js → core/node-content.js} +0 -0
@@ -0,0 +1,389 @@
1
+ /** Validate document fields, references and action conflicts without mutating input; content rules stay shared. */
2
+ import { supportsNodeContent, validateNodeContent } from "./node-content.js";
3
+ /** Compilation error for invalid documents; issues contains all field problems found by validation. */
4
+ export class DiagramValidationError extends Error {
5
+ issues;
6
+ constructor(issues) {
7
+ super(`Invalid diagram document (${issues.length} issue${issues.length === 1 ? "" : "s"})`);
8
+ this.name = "DiagramValidationError";
9
+ this.issues = issues;
10
+ }
11
+ }
12
+ /**
13
+ * Validate document structure, values and references; an empty list means validation passed.
14
+ * Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
15
+ */
16
+ export function validateDocument(input) {
17
+ const issues = [];
18
+ if (!isRecord(input)) {
19
+ return [{ path: "$", message: "document must be an object" }];
20
+ }
21
+ validateUnknownFields(input, ["version", "title", "viewport", "theme", "nodes", "edges", "scenes"], "$", issues);
22
+ if (input.version !== 1)
23
+ issues.push({ path: "version", message: "must be 1" });
24
+ if (input.title !== undefined && typeof input.title !== "string")
25
+ issues.push({ path: "title", message: "must be a string" });
26
+ if (input.theme !== undefined && input.theme !== "light" && input.theme !== "dark")
27
+ issues.push({ path: "theme", message: "must be light or dark" });
28
+ const viewport = input.viewport;
29
+ if (!isRecord(viewport)) {
30
+ issues.push({ path: "viewport", message: "must be an object" });
31
+ }
32
+ else {
33
+ validateUnknownFields(viewport, ["width", "height"], "viewport", issues);
34
+ validatePositiveFinite(viewport.width, "viewport.width", issues);
35
+ validatePositiveFinite(viewport.height, "viewport.height", issues);
36
+ }
37
+ if (!Array.isArray(input.nodes))
38
+ issues.push({ path: "nodes", message: "must be an array" });
39
+ if (!Array.isArray(input.edges))
40
+ issues.push({ path: "edges", message: "must be an array" });
41
+ if (!Array.isArray(input.scenes))
42
+ issues.push({ path: "scenes", message: "must be an array" });
43
+ const nodes = Array.isArray(input.nodes) ? input.nodes : [];
44
+ const edges = Array.isArray(input.edges) ? input.edges : [];
45
+ const scenes = Array.isArray(input.scenes) ? input.scenes : [];
46
+ validateDenseArray(nodes, "nodes", issues);
47
+ validateDenseArray(edges, "edges", issues);
48
+ validateDenseArray(scenes, "scenes", issues);
49
+ const ids = new Set();
50
+ nodes.forEach((node, index) => {
51
+ const path = `nodes[${index}]`;
52
+ if (!isRecord(node)) {
53
+ issues.push({ path, message: "must be an object" });
54
+ return;
55
+ }
56
+ validateUnknownFields(node, ["id", "kind", "x", "y", "width", "height", "label", "content", "parentId", "visible", "style"], path, issues);
57
+ if (!isNonEmptyString(node.id))
58
+ issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
59
+ else if (ids.has(node.id))
60
+ issues.push({ path: `${path}.id`, message: `duplicate id \"${node.id}\"` });
61
+ else
62
+ ids.add(node.id);
63
+ if (!isNodeKind(node.kind))
64
+ issues.push({ path: `${path}.kind`, message: "must be a supported node kind" });
65
+ validateFinite(node.x, `${path}.x`, issues);
66
+ validateFinite(node.y, `${path}.y`, issues);
67
+ validatePositiveFinite(node.width, `${path}.width`, issues);
68
+ validatePositiveFinite(node.height, `${path}.height`, issues);
69
+ if (node.label !== undefined && typeof node.label !== "string")
70
+ issues.push({ path: `${path}.label`, message: "must be a string" });
71
+ if (node.content !== undefined) {
72
+ validateNodeContent(node.content, `${path}.content`, issues);
73
+ if (node.label !== undefined)
74
+ issues.push({ path: `${path}.content`, message: "must not be declared together with label" });
75
+ if (!supportsNodeContent(node.kind))
76
+ issues.push({ path: `${path}.content`, message: "requires a rect, roundedRect or cylinder node" });
77
+ }
78
+ if (node.parentId !== undefined && !isNonEmptyString(node.parentId))
79
+ issues.push({ path: `${path}.parentId`, message: "must be a non-empty string" });
80
+ if (node.visible !== undefined && typeof node.visible !== "boolean")
81
+ issues.push({ path: `${path}.visible`, message: "must be a boolean" });
82
+ validateNodeStyle(node.style, `${path}.style`, issues);
83
+ });
84
+ const nodeById = new Map();
85
+ nodes.forEach((node) => {
86
+ if (isRecord(node) && isNonEmptyString(node.id))
87
+ nodeById.set(node.id, node);
88
+ });
89
+ nodes.forEach((node, index) => {
90
+ if (isRecord(node) && node.parentId !== undefined && isNonEmptyString(node.parentId)) {
91
+ const parent = nodeById.get(node.parentId);
92
+ if (!parent)
93
+ issues.push({ path: `nodes[${index}].parentId`, message: "must reference an existing node" });
94
+ else if (parent.kind !== "group")
95
+ issues.push({ path: `nodes[${index}].parentId`, message: "must reference a group node" });
96
+ }
97
+ });
98
+ validateParentCycles(nodes, nodeById, issues);
99
+ const edgeById = new Map();
100
+ edges.forEach((edge, index) => {
101
+ const path = `edges[${index}]`;
102
+ if (!isRecord(edge)) {
103
+ issues.push({ path, message: "must be an object" });
104
+ return;
105
+ }
106
+ validateUnknownFields(edge, ["id", "from", "to", "label", "route", "fromPort", "toPort", "visible", "style"], path, issues);
107
+ if (!isNonEmptyString(edge.id))
108
+ issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
109
+ else if (ids.has(edge.id))
110
+ issues.push({ path: `${path}.id`, message: `duplicate id \"${edge.id}\"` });
111
+ else {
112
+ ids.add(edge.id);
113
+ edgeById.set(edge.id, edge);
114
+ }
115
+ validateEdgeEndpoint(edge.from, `${path}.from`, nodeById, issues);
116
+ validateEdgeEndpoint(edge.to, `${path}.to`, nodeById, issues);
117
+ if (edge.label !== undefined && typeof edge.label !== "string")
118
+ issues.push({ path: `${path}.label`, message: "must be a string" });
119
+ if (edge.route !== undefined && edge.route !== "straight" && edge.route !== "orthogonal" && edge.route !== "curved")
120
+ issues.push({ path: `${path}.route`, message: "must be straight, orthogonal or curved" });
121
+ if (edge.fromPort !== undefined && !isPortDirection(edge.fromPort))
122
+ issues.push({ path: `${path}.fromPort`, message: "must be top, right, bottom or left" });
123
+ if (edge.toPort !== undefined && !isPortDirection(edge.toPort))
124
+ issues.push({ path: `${path}.toPort`, message: "must be top, right, bottom or left" });
125
+ if (edge.visible !== undefined && typeof edge.visible !== "boolean")
126
+ issues.push({ path: `${path}.visible`, message: "must be a boolean" });
127
+ validateEdgeStyle(edge.style, `${path}.style`, issues);
128
+ });
129
+ const allIds = new Set([...nodeById.keys(), ...edgeById.keys()]);
130
+ const sceneIds = new Set();
131
+ scenes.forEach((scene, sceneIndex) => {
132
+ const path = `scenes[${sceneIndex}]`;
133
+ if (!isRecord(scene)) {
134
+ issues.push({ path, message: "must be an object" });
135
+ return;
136
+ }
137
+ validateUnknownFields(scene, ["id", "label", "beats"], path, issues);
138
+ if (!isNonEmptyString(scene.id))
139
+ issues.push({ path: `${path}.id`, message: "must be a non-empty string" });
140
+ else if (sceneIds.has(scene.id))
141
+ issues.push({ path: `${path}.id`, message: `duplicate id \"${scene.id}\"` });
142
+ else
143
+ sceneIds.add(scene.id);
144
+ if (!isNonEmptyString(scene.label))
145
+ issues.push({ path: `${path}.label`, message: "must be a non-empty string" });
146
+ if (!Array.isArray(scene.beats))
147
+ issues.push({ path: `${path}.beats`, message: "must be an array" });
148
+ else {
149
+ validateDenseArray(scene.beats, `${path}.beats`, issues);
150
+ if (scene.beats.length === 0)
151
+ issues.push({ path: `${path}.beats`, message: "must contain at least one beat" });
152
+ scene.beats.forEach((beat, beatIndex) => validateBeat(beat, `${path}.beats[${beatIndex}]`, allIds, nodeById, edgeById, issues));
153
+ }
154
+ });
155
+ return issues;
156
+ }
157
+ function isRecord(value) {
158
+ if (typeof value !== "object" || value === null || Array.isArray(value))
159
+ return false;
160
+ const prototype = Object.getPrototypeOf(value);
161
+ return prototype === Object.prototype || prototype === null;
162
+ }
163
+ function isNonEmptyString(value) {
164
+ return typeof value === "string" && value.length > 0;
165
+ }
166
+ function isNodeKind(value) {
167
+ return value === "rect" || value === "roundedRect" || value === "ellipse" || value === "cylinder" || value === "group";
168
+ }
169
+ function isPortDirection(value) {
170
+ return value === "top" || value === "right" || value === "bottom" || value === "left";
171
+ }
172
+ function validateFinite(value, path, issues) {
173
+ if (typeof value !== "number" || !Number.isFinite(value))
174
+ issues.push({ path, message: "must be a finite number" });
175
+ }
176
+ function validatePositiveFinite(value, path, issues) {
177
+ validateFinite(value, path, issues);
178
+ if (typeof value === "number" && Number.isFinite(value) && value <= 0)
179
+ issues.push({ path, message: "must be greater than zero" });
180
+ }
181
+ function validateNonNegativeFinite(value, path, issues) {
182
+ validateFinite(value, path, issues);
183
+ if (typeof value === "number" && Number.isFinite(value) && value < 0)
184
+ issues.push({ path, message: "must be zero or greater" });
185
+ }
186
+ function validateUnknownFields(record, allowed, path, issues) {
187
+ const allowedFields = new Set(allowed);
188
+ for (const key of Object.keys(record))
189
+ if (!allowedFields.has(key))
190
+ issues.push({ path: path === "$" ? key : `${path}.${key}`, message: "unknown field" });
191
+ }
192
+ function validateDenseArray(values, path, issues) {
193
+ for (let index = 0; index < values.length; index += 1)
194
+ if (!(index in values))
195
+ issues.push({ path: `${path}[${index}]`, message: "must be a value" });
196
+ }
197
+ /** Restrict colors to interpolable hexadecimal data, excluding CSS expressions and external resource references. */
198
+ function validateColor(value, path, issues) {
199
+ if (typeof value !== "string" || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))
200
+ issues.push({ path, message: "must be a hexadecimal color" });
201
+ }
202
+ function validateNodeStyle(style, path, issues) {
203
+ if (style === undefined)
204
+ return;
205
+ if (!isRecord(style)) {
206
+ issues.push({ path, message: "must be an object" });
207
+ return;
208
+ }
209
+ validateUnknownFields(style, ["fill", "stroke", "strokeWidth", "textColor", "dashed"], path, issues);
210
+ for (const key of ["fill", "stroke", "textColor"])
211
+ if (style[key] !== undefined)
212
+ validateColor(style[key], `${path}.${key}`, issues);
213
+ if (style.strokeWidth !== undefined)
214
+ validateNonNegativeFinite(style.strokeWidth, `${path}.strokeWidth`, issues);
215
+ if (style.dashed !== undefined && typeof style.dashed !== "boolean")
216
+ issues.push({ path: `${path}.dashed`, message: "must be a boolean" });
217
+ }
218
+ function validateEdgeStyle(style, path, issues) {
219
+ if (style === undefined)
220
+ return;
221
+ if (!isRecord(style)) {
222
+ issues.push({ path, message: "must be an object" });
223
+ return;
224
+ }
225
+ validateUnknownFields(style, ["stroke", "strokeWidth", "dashed"], path, issues);
226
+ if (style.stroke !== undefined)
227
+ validateColor(style.stroke, `${path}.stroke`, issues);
228
+ if (style.strokeWidth !== undefined)
229
+ validateNonNegativeFinite(style.strokeWidth, `${path}.strokeWidth`, issues);
230
+ if (style.dashed !== undefined && typeof style.dashed !== "boolean")
231
+ issues.push({ path: `${path}.dashed`, message: "must be a boolean" });
232
+ }
233
+ function validateEdgeEndpoint(value, path, nodeById, issues) {
234
+ if (!isNonEmptyString(value)) {
235
+ issues.push({ path, message: "must reference an existing node" });
236
+ return;
237
+ }
238
+ const node = nodeById.get(value);
239
+ if (!node)
240
+ issues.push({ path, message: "must reference an existing node" });
241
+ }
242
+ /** Reject parent cycles at load time so later position and visibility recursion terminates. */
243
+ function validateParentCycles(nodes, nodeById, issues) {
244
+ const reported = new Set();
245
+ for (const [id, node] of nodeById) {
246
+ const chain = new Set();
247
+ let current = id;
248
+ while (current) {
249
+ if (chain.has(current)) {
250
+ const index = nodes.findIndex((candidate) => isRecord(candidate) && candidate.id === id);
251
+ const path = index >= 0 ? `nodes[${index}].parentId` : "nodes.parentId";
252
+ if (!reported.has(path)) {
253
+ issues.push({ path, message: "group parent references form a cycle" });
254
+ reported.add(path);
255
+ }
256
+ break;
257
+ }
258
+ chain.add(current);
259
+ const parentValue = nodeById.get(current)?.parentId;
260
+ current = isNonEmptyString(parentValue) ? parentValue : undefined;
261
+ }
262
+ }
263
+ }
264
+ /** Track assignment keys per beat: allow different fields on one target but reject duplicate writes to a field. */
265
+ function validateBeat(beat, path, allIds, nodeById, edgeById, issues) {
266
+ if (!isRecord(beat)) {
267
+ issues.push({ path, message: "must be an object" });
268
+ return;
269
+ }
270
+ validateUnknownFields(beat, ["durationMs", "transitionMs", "caption", "actions"], path, issues);
271
+ validatePositiveFinite(beat.durationMs, `${path}.durationMs`, issues);
272
+ if (beat.transitionMs !== undefined) {
273
+ validateNonNegativeFinite(beat.transitionMs, `${path}.transitionMs`, issues);
274
+ if (typeof beat.durationMs === "number" && Number.isFinite(beat.durationMs) && typeof beat.transitionMs === "number" && Number.isFinite(beat.transitionMs) && beat.transitionMs > beat.durationMs)
275
+ issues.push({ path: `${path}.transitionMs`, message: "must not exceed durationMs" });
276
+ }
277
+ if (beat.caption !== undefined && typeof beat.caption !== "string")
278
+ issues.push({ path: `${path}.caption`, message: "must be a string" });
279
+ if (!Array.isArray(beat.actions)) {
280
+ issues.push({ path: `${path}.actions`, message: "must be an array" });
281
+ return;
282
+ }
283
+ validateDenseArray(beat.actions, `${path}.actions`, issues);
284
+ const assignments = new Set();
285
+ beat.actions.forEach((action, index) => validateAction(action, `${path}.actions[${index}]`, allIds, nodeById, edgeById, assignments, issues));
286
+ }
287
+ /** Validate fields/targets by action kind; visibility and highlight keys are separate from ordinary style updates. */
288
+ function validateAction(action, path, allIds, nodeById, edgeById, assignments, issues) {
289
+ if (!isRecord(action)) {
290
+ issues.push({ path, message: "must be an object" });
291
+ return;
292
+ }
293
+ if (typeof action.type !== "string") {
294
+ issues.push({ path: `${path}.type`, message: "must be a supported action" });
295
+ return;
296
+ }
297
+ const type = action.type;
298
+ const allowedByType = {
299
+ show: ["type", "target"], hide: ["type", "target"], highlight: ["type", "target"], unhighlight: ["type", "target"],
300
+ updateNode: ["type", "target", "value"], updateEdge: ["type", "target", "value"], focus: ["type", "target"],
301
+ flow: ["type", "target", "direction", "text"]
302
+ };
303
+ const allowed = Object.hasOwn(allowedByType, type) ? allowedByType[type] : undefined;
304
+ if (!allowed) {
305
+ issues.push({ path: `${path}.type`, message: "unknown action" });
306
+ return;
307
+ }
308
+ validateUnknownFields(action, allowed, path, issues);
309
+ if (["show", "hide", "highlight", "unhighlight"].includes(type)) {
310
+ if (!isNonEmptyString(action.target)) {
311
+ issues.push({ path: `${path}.target`, message: "must reference an existing object" });
312
+ return;
313
+ }
314
+ if (!allIds.has(action.target))
315
+ issues.push({ path: `${path}.target`, message: "must reference an existing object" });
316
+ addAssignment(`${type === "show" || type === "hide" ? "visibility" : "highlight"}:${action.target}`, path, assignments, issues);
317
+ return;
318
+ }
319
+ if (type === "focus") {
320
+ if (action.target !== null && (!isNonEmptyString(action.target) || !nodeById.has(action.target)))
321
+ issues.push({ path: `${path}.target`, message: "must reference a node or group" });
322
+ if (action.target === null || (isNonEmptyString(action.target) && nodeById.has(action.target)))
323
+ addAssignment("focus", path, assignments, issues);
324
+ return;
325
+ }
326
+ if (type === "flow") {
327
+ if (!isNonEmptyString(action.target))
328
+ issues.push({ path: `${path}.target`, message: "must reference an existing edge" });
329
+ else if (!edgeById.has(action.target))
330
+ issues.push({ path: `${path}.target`, message: "must reference an existing edge" });
331
+ if (action.direction !== "forward" && action.direction !== "reverse")
332
+ issues.push({ path: `${path}.direction`, message: "must be forward or reverse" });
333
+ if (action.text !== undefined && typeof action.text !== "string")
334
+ issues.push({ path: `${path}.text`, message: "must be a string" });
335
+ // Reserve the edge independently of other field errors so invalid directions cannot hide duplicate transfers.
336
+ if (isNonEmptyString(action.target) && edgeById.has(action.target))
337
+ addAssignment(`flow:${action.target}`, `${path}.target`, assignments, issues);
338
+ return;
339
+ }
340
+ if (!isNonEmptyString(action.target)) {
341
+ issues.push({ path: `${path}.target`, message: `must reference a ${type === "updateNode" ? "node" : "edge"}` });
342
+ }
343
+ else if (type === "updateNode" ? !nodeById.has(action.target) : !edgeById.has(action.target))
344
+ issues.push({ path: `${path}.target`, message: `must reference an existing ${type === "updateNode" ? "node" : "edge"}` });
345
+ validateUpdateValue(action.value, `${path}.value`, type === "updateNode", assignments, action.target, issues);
346
+ const targetNode = typeof action.target === "string" ? nodeById.get(action.target) : undefined;
347
+ if (type === "updateNode" && targetNode && isRecord(action.value) && action.value.content !== undefined && action.value.content !== null && !supportsNodeContent(targetNode.kind)) {
348
+ issues.push({ path: `${path}.value.content`, message: "requires a rect, roundedRect or cylinder node" });
349
+ }
350
+ }
351
+ function validateUpdateValue(value, path, isNode, assignments, target, issues) {
352
+ if (!isRecord(value)) {
353
+ issues.push({ path, message: "must be an object" });
354
+ return;
355
+ }
356
+ const allowed = isNode ? ["x", "y", "width", "height", "label", "content", "fill", "stroke", "opacity"] : ["label", "stroke", "strokeWidth", "opacity"];
357
+ validateUnknownFields(value, allowed, path, issues);
358
+ if (isNode && Object.hasOwn(value, "label") && Object.hasOwn(value, "content")) {
359
+ issues.push({ path: `${path}.content`, message: "must not be declared together with label" });
360
+ }
361
+ for (const key of Object.keys(value)) {
362
+ if (!allowed.includes(key))
363
+ continue;
364
+ // Both node text fields replace the same display mode, even across separate actions.
365
+ const assignmentField = isNode && (key === "label" || key === "content") ? "text-mode" : key;
366
+ addAssignment(`${isNode ? "node" : "edge"}:${String(target)}:${assignmentField}`, `${path}.${key}`, assignments, issues);
367
+ const field = value[key];
368
+ if (["x", "y", "width", "height", "opacity"].includes(key))
369
+ validateFinite(field, `${path}.${key}`, issues);
370
+ if (key === "strokeWidth")
371
+ validateNonNegativeFinite(field, `${path}.${key}`, issues);
372
+ if (["width", "height"].includes(key) && typeof field === "number" && field <= 0)
373
+ issues.push({ path: `${path}.${key}`, message: "must be greater than zero" });
374
+ if (key === "opacity" && typeof field === "number" && (field < 0 || field > 1))
375
+ issues.push({ path: `${path}.${key}`, message: "must be between zero and one" });
376
+ if (["fill", "stroke"].includes(key))
377
+ validateColor(field, `${path}.${key}`, issues);
378
+ if (key === "label" && typeof field !== "string")
379
+ issues.push({ path: `${path}.${key}`, message: "must be a string" });
380
+ if (key === "content" && field !== null)
381
+ validateNodeContent(field, `${path}.${key}`, issues);
382
+ }
383
+ }
384
+ function addAssignment(key, path, assignments, issues) {
385
+ if (assignments.has(key))
386
+ issues.push({ path, message: "duplicate beat assignment" });
387
+ else
388
+ assignments.add(key);
389
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,5 @@
1
- export * from "./core.js";
1
+ export { createDiagram, renderSvg, wrapText } from "./core/diagram.js";
2
+ export type { Diagram } from "./core/diagram.js";
3
+ export { validateDocument, DiagramValidationError } from "./core/validation.js";
4
+ export type { NodeKind, EdgeRoute, PortDirection, FlowDirection, ThemeName, Viewport, NodeStyle, EdgeStyle, DiagramNode, DiagramEdge, DiagramDocument, Scene, Beat, NodeVisualState, EdgeVisualState, Action, ValidationIssue, EvaluateOptions, TextLine, EvaluatedNode, EvaluatedEdge, EdgeFlowMarker, LayoutWarning, CameraState, HitObjectType, HitObject, DiagramPoint, DiagramFrameState } from "./core/model.js";
5
+ export type { NodeContent, ContentBlock, ContentListItem, ContentListStyle, ContentTag, ContentTagStyle, ContentTagBackground, ContentGraphBlock, ContentGraphNode, ContentGraphEdge, ContentGraphLayout, ContentText, ContentTextSpan, ContentSpanStyle, ContentSpan, ContentTextStyle, ContentAlignment, ContentSafeArea, ContentLine, NodeContentLayout } from "./core/node-content.js";
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
- export * from "./core.js";
1
+ export { createDiagram, renderSvg, wrapText } from "./core/diagram.js";
2
+ export { validateDocument, DiagramValidationError } from "./core/validation.js";
package/dist/react.d.ts CHANGED
@@ -1,48 +1,3 @@
1
- /**
2
- * React adapter for component lifecycle, props, events and imperative controls.
3
- * Playback/rendering reuse browser; imports do not create DOM, and the player is created only on mount.
4
- */
5
- import { type CSSProperties } from "react";
6
- import { type PlayerErrorDetail, type PlayerState } from "./browser.js";
7
- import type { DiagramDocument, ThemeName } from "./core.js";
8
- /** React document, visual options and callbacks; document reference changes trigger a reload. */
9
- export interface FlowPlayerProps {
10
- document: DiagramDocument;
11
- /** Positive playback multiplier; omit to use the player default. */
12
- speed?: number;
13
- theme?: ThemeName;
14
- controls?: boolean;
15
- /** Defaults to false: hover marks borders only. True also highlights backgrounds, including groups. */
16
- hoverFill?: boolean;
17
- reducedMotion?: boolean;
18
- /** Initial position in milliseconds, applied once; use seek afterward, and document reloads reset to zero. */
19
- initialTimeMs?: number;
20
- className?: string;
21
- style?: CSSProperties;
22
- onStepChange?: (state: PlayerState) => void;
23
- onTimeUpdate?: (state: PlayerState) => void;
24
- onPlayStateChange?: (state: PlayerState) => void;
25
- onError?: (detail: PlayerErrorDetail) => void;
26
- }
27
- /** Imperative controls for a mounted component; calls before mounting or after unmounting throw a not-mounted error. */
28
- export interface FlowPlayerHandle {
29
- /** Resume from the current position when playback can advance. */
30
- play(): void;
31
- /** Pause while retaining the current frame. */
32
- pause(): void;
33
- /** Seek in milliseconds, clamping to the timeline; non-finite values become zero. */
34
- seek(timeMs: number): void;
35
- /** Pause and jump to a beat boundary; backward from inside a beat goes to that beat's start. */
36
- step(direction: 1 | -1): void;
37
- /** Set a finite positive multiplier; ignore invalid values. */
38
- setSpeed(speed: number): void;
39
- /** Reset/pause at zero on success; emit an error and retain playback state if validation fails. */
40
- load(document: DiagramDocument): void;
41
- /** Read the current browser-player snapshot. */
42
- getState(): PlayerState;
43
- }
44
- /**
45
- * SSR renders only a host container; an effect mounts the browser player and unmount cleanup releases it.
46
- * Document reference changes call load; visual/control options rebuild the instance, with initial positioning applied once.
47
- */
48
- export declare const FlowPlayer: import("react").ForwardRefExoticComponent<FlowPlayerProps & import("react").RefAttributes<FlowPlayerHandle>>;
1
+ /** Public React adapter entry. */
2
+ export { FlowPlayer } from "./adapters/react.js";
3
+ export type { FlowPlayerHandle, FlowPlayerProps } from "./adapters/react.js";
package/dist/react.js CHANGED
@@ -1,126 +1,2 @@
1
- /**
2
- * React adapter for component lifecycle, props, events and imperative controls.
3
- * Playback/rendering reuse browser; imports do not create DOM, and the player is created only on mount.
4
- */
5
- import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
6
- import { createPlayer } from "./browser.js";
7
- /**
8
- * SSR renders only a host container; an effect mounts the browser player and unmount cleanup releases it.
9
- * Document reference changes call load; visual/control options rebuild the instance, with initial positioning applied once.
10
- */
11
- export const FlowPlayer = forwardRef(function FlowPlayer(props, ref) {
12
- const hostRef = useRef(null);
13
- const playerRef = useRef(null);
14
- const eventCleanupRef = useRef(null);
15
- const callbacksRef = useRef({});
16
- const documentRef = useRef(null);
17
- const optionsRef = useRef(null);
18
- const initialTimeRef = useRef(props.initialTimeMs);
19
- const initialAppliedRef = useRef(false);
20
- // Listeners read current callbacks through a stable ref so callback identity changes do not rebuild the player.
21
- callbacksRef.current = {
22
- onStepChange: props.onStepChange,
23
- onTimeUpdate: props.onTimeUpdate,
24
- onPlayStateChange: props.onPlayStateChange,
25
- onError: props.onError
26
- };
27
- useImperativeHandle(ref, () => ({
28
- play: () => requirePlayer(playerRef).play(),
29
- pause: () => requirePlayer(playerRef).pause(),
30
- seek: (timeMs) => requirePlayer(playerRef).seek(timeMs),
31
- step: (direction) => requirePlayer(playerRef).step(direction),
32
- setSpeed: (speed) => requirePlayer(playerRef).setSpeed(speed),
33
- load: (document) => requirePlayer(playerRef).load(document),
34
- getState: () => requirePlayer(playerRef).getState()
35
- }), []);
36
- useEffect(() => {
37
- const host = hostRef.current;
38
- if (!host)
39
- return;
40
- const nextOptions = {
41
- theme: props.theme,
42
- controls: props.controls,
43
- hoverFill: props.hoverFill,
44
- reducedMotion: props.reducedMotion
45
- };
46
- const existing = playerRef.current;
47
- const optionsChanged = !sameOptions(optionsRef.current, nextOptions);
48
- if (existing && !optionsChanged && documentRef.current !== props.document) {
49
- existing.load(props.document);
50
- documentRef.current = props.document;
51
- }
52
- else if (!existing || optionsChanged) {
53
- // Detach old callbacks before replacement so old lifecycle events cannot reach the new component state.
54
- eventCleanupRef.current?.();
55
- existing?.destroy();
56
- const player = createPlayer(host, props.document, nextOptions);
57
- // A new instance needs current props even when the separate speed effect has no changed dependency.
58
- if (props.speed !== undefined)
59
- player.setSpeed(props.speed);
60
- eventCleanupRef.current = attachPlayerEvents(player, callbacksRef);
61
- playerRef.current = player;
62
- documentRef.current = props.document;
63
- optionsRef.current = nextOptions;
64
- if (!initialAppliedRef.current && initialTimeRef.current !== undefined) {
65
- player.seek(initialTimeRef.current);
66
- }
67
- initialAppliedRef.current = true;
68
- return;
69
- }
70
- optionsRef.current = nextOptions;
71
- }, [props.document, props.theme, props.controls, props.reducedMotion, props.hoverFill]);
72
- useEffect(() => {
73
- const player = playerRef.current;
74
- if (player && props.speed !== undefined)
75
- player.setSpeed(props.speed);
76
- }, [props.speed]);
77
- useEffect(() => () => {
78
- eventCleanupRef.current?.();
79
- eventCleanupRef.current = null;
80
- playerRef.current?.destroy();
81
- playerRef.current = null;
82
- documentRef.current = null;
83
- optionsRef.current = null;
84
- initialAppliedRef.current = false;
85
- }, []);
86
- const className = props.className ? `flowsvg ${props.className}` : "flowsvg";
87
- return createElement("div", { ref: hostRef, className, style: props.style });
88
- });
89
- function requirePlayer(playerRef) {
90
- if (!playerRef.current)
91
- throw new Error("FlowPlayer is not mounted");
92
- return playerRef.current;
93
- }
94
- function sameOptions(previous, next) {
95
- if (!previous)
96
- return false;
97
- return previous.theme === next.theme
98
- && previous.controls === next.controls
99
- && previous.hoverFill === next.hoverFill
100
- && previous.reducedMotion === next.reducedMotion;
101
- }
102
- /** Translate browser event details to component callbacks; share cleanup between replacement and unmount. */
103
- function attachPlayerEvents(player, callbacksRef) {
104
- const onStepChange = (event) => {
105
- callbacksRef.current.onStepChange?.(event.detail);
106
- };
107
- const onTimeUpdate = (event) => {
108
- callbacksRef.current.onTimeUpdate?.(event.detail);
109
- };
110
- const onPlayStateChange = (event) => {
111
- callbacksRef.current.onPlayStateChange?.(event.detail);
112
- };
113
- const onError = (event) => {
114
- callbacksRef.current.onError?.(event.detail);
115
- };
116
- player.element.addEventListener("stepchange", onStepChange);
117
- player.element.addEventListener("timeupdate", onTimeUpdate);
118
- player.element.addEventListener("playstatechange", onPlayStateChange);
119
- player.element.addEventListener("error", onError);
120
- return () => {
121
- player.element.removeEventListener("stepchange", onStepChange);
122
- player.element.removeEventListener("timeupdate", onTimeUpdate);
123
- player.element.removeEventListener("playstatechange", onPlayStateChange);
124
- player.element.removeEventListener("error", onError);
125
- };
126
- }
1
+ /** Public React adapter entry. */
2
+ export { FlowPlayer } from "./adapters/react.js";