flowsvg 0.0.0-stage → 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.
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/PATTERNS.md +112 -0
- package/README.md +546 -2
- package/README.zh-CN.md +547 -0
- package/dist/adapters/react.d.ts +48 -0
- package/dist/adapters/react.js +126 -0
- package/dist/adapters/vue.d.ts +132 -0
- package/dist/adapters/vue.js +161 -0
- package/dist/adapters/web-component.d.ts +99 -0
- package/dist/adapters/web-component.js +396 -0
- package/dist/browser/accessibility.d.ts +16 -0
- package/dist/browser/accessibility.js +36 -0
- package/dist/browser/controls.d.ts +41 -0
- package/dist/browser/controls.js +80 -0
- package/dist/browser/lifecycle.d.ts +6 -0
- package/dist/browser/lifecycle.js +31 -0
- package/dist/browser/player.d.ts +69 -0
- package/dist/browser/player.js +353 -0
- package/dist/browser/svg-dom.d.ts +6 -0
- package/dist/browser/svg-dom.js +74 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +2 -0
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +38 -0
- package/dist/core/diagram.js +391 -0
- package/dist/core/edge-geometry.d.ts +32 -0
- package/dist/core/edge-geometry.js +382 -0
- package/dist/core/model.d.ts +256 -0
- package/dist/core/model.js +1 -0
- package/dist/core/node-content-paint.d.ts +10 -0
- package/dist/core/node-content-paint.js +45 -0
- package/dist/core/node-content.d.ts +184 -0
- package/dist/core/node-content.js +554 -0
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/core/theme.d.ts +21 -0
- package/dist/core/theme.js +17 -0
- package/dist/core/timeline.d.ts +56 -0
- package/dist/core/timeline.js +148 -0
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +389 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +2 -0
- package/dist/styles.css +47 -0
- package/dist/vue.d.ts +3 -0
- package/dist/vue.js +2 -0
- package/dist/web-component.d.ts +3 -0
- package/dist/web-component.js +2 -0
- 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/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 +88 -4
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mixColor } from "./color.js";
|
|
2
|
+
const DEFAULT_TRANSITION_MS = 250;
|
|
3
|
+
const NUMERIC_VISUAL_FIELDS = ["x", "y", "width", "height", "opacity", "strokeWidth"];
|
|
4
|
+
const DEFAULT_STROKE_WIDTH = 2;
|
|
5
|
+
/**
|
|
6
|
+
* Flatten scenes into continuous beats and compile snapshots once; queries locate beats by binary search.
|
|
7
|
+
* Each beat inherits the previous final state; evaluation is independent of query order and clocks.
|
|
8
|
+
* See docs/adr/0002-continuous-global-timeline.md for time semantics.
|
|
9
|
+
*/
|
|
10
|
+
export class CompiledTimeline {
|
|
11
|
+
totalDuration;
|
|
12
|
+
beats;
|
|
13
|
+
initial;
|
|
14
|
+
title;
|
|
15
|
+
constructor(document) {
|
|
16
|
+
this.initial = new Map([
|
|
17
|
+
...document.nodes.map((node) => [node.id, {
|
|
18
|
+
visible: node.visible !== false, highlighted: false, kind: "node", values: {
|
|
19
|
+
x: node.x, y: node.y, width: node.width, height: node.height, label: node.label, content: node.content, fill: node.style?.fill, stroke: node.style?.stroke, opacity: 1
|
|
20
|
+
}
|
|
21
|
+
}]),
|
|
22
|
+
...document.edges.map((edge) => [edge.id, {
|
|
23
|
+
visible: edge.visible !== false, highlighted: false, kind: "edge", values: {
|
|
24
|
+
label: edge.label, stroke: edge.style?.stroke, strokeWidth: edge.style?.strokeWidth ?? DEFAULT_STROKE_WIDTH, opacity: 1
|
|
25
|
+
}
|
|
26
|
+
}])
|
|
27
|
+
]);
|
|
28
|
+
this.title = document.title;
|
|
29
|
+
const beats = [];
|
|
30
|
+
let state = this.initial;
|
|
31
|
+
let focus = null;
|
|
32
|
+
let startMs = 0;
|
|
33
|
+
let caption = document.title;
|
|
34
|
+
for (const scene of document.scenes) {
|
|
35
|
+
scene.beats.forEach((beat, index) => {
|
|
36
|
+
// Copy the index, then replace changed objects/values so earlier beat snapshots retain their original state.
|
|
37
|
+
const after = new Map(state);
|
|
38
|
+
const focusBefore = focus;
|
|
39
|
+
const flows = [];
|
|
40
|
+
for (const action of beat.actions) {
|
|
41
|
+
if (action.type === "flow") {
|
|
42
|
+
flows.push({ target: action.target, direction: action.direction, ...(action.text !== undefined ? { text: action.text } : {}) });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (action.type === "focus") {
|
|
46
|
+
focus = action.target;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const previous = after.get(action.target);
|
|
50
|
+
if (!previous)
|
|
51
|
+
continue;
|
|
52
|
+
if (action.type === "updateNode" || action.type === "updateEdge") {
|
|
53
|
+
// Content and label share one display mode. Clear the other value in the snapshot,
|
|
54
|
+
// including content:null, so later beats cannot revive historical text.
|
|
55
|
+
const modeReset = action.type !== "updateNode" ? {}
|
|
56
|
+
: Object.hasOwn(action.value, "content") ? { label: undefined }
|
|
57
|
+
: Object.hasOwn(action.value, "label") ? { content: undefined } : {};
|
|
58
|
+
after.set(action.target, { ...previous, values: { ...previous.values, ...modeReset, ...action.value } });
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
after.set(action.target, action.type === "show" || action.type === "hide"
|
|
62
|
+
? { ...previous, visible: action.type === "show" }
|
|
63
|
+
: { ...previous, highlighted: action.type === "highlight" });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
caption = beat.caption ?? caption;
|
|
67
|
+
const endMs = startMs + beat.durationMs;
|
|
68
|
+
beats.push({ startMs, endMs, transitionMs: beat.transitionMs ?? Math.min(DEFAULT_TRANSITION_MS, beat.durationMs), sceneId: scene.id, beatId: `${scene.id}:${index}`, caption, focusBefore, focusAfter: focus, before: state, after, flows });
|
|
69
|
+
state = after;
|
|
70
|
+
startMs = endMs;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
this.beats = beats;
|
|
74
|
+
this.totalDuration = startMs;
|
|
75
|
+
}
|
|
76
|
+
/** Clamp finite times globally; reduced motion skips interpolation but preserves beat selection and total duration. */
|
|
77
|
+
evaluateAt(timeMs, options, palette) {
|
|
78
|
+
const time = Number.isFinite(timeMs) ? Math.max(0, Math.min(timeMs, this.totalDuration)) : 0;
|
|
79
|
+
const beat = this.findBeat(time);
|
|
80
|
+
const progress = !beat || options.reducedMotion || beat.transitionMs === 0 ? 1 : easeInOutCubic(Math.min(1, (time - beat.startMs) / beat.transitionMs));
|
|
81
|
+
const before = beat?.before ?? this.initial;
|
|
82
|
+
const after = beat?.after ?? this.initial;
|
|
83
|
+
const objects = new Map([...after].map(([id, target]) => {
|
|
84
|
+
const source = before.get(id) ?? target;
|
|
85
|
+
const values = interpolateValues(source, target, progress, palette);
|
|
86
|
+
const visibilityOpacity = Number(source.visible) + (Number(target.visible) - Number(source.visible)) * progress;
|
|
87
|
+
return [id, {
|
|
88
|
+
values,
|
|
89
|
+
// Reveals block routing from their start; hides remain obstacles until complete. Hit tests also check opacity.
|
|
90
|
+
visible: target.visible || (source.visible && progress < 1),
|
|
91
|
+
opacity: Math.min(values.opacity ?? 1, visibilityOpacity),
|
|
92
|
+
highlighted: target.highlighted,
|
|
93
|
+
highlight: Number(source.highlighted) + (Number(target.highlighted) - Number(source.highlighted)) * progress
|
|
94
|
+
}];
|
|
95
|
+
}));
|
|
96
|
+
return {
|
|
97
|
+
timeMs: time,
|
|
98
|
+
sceneId: beat?.sceneId ?? null,
|
|
99
|
+
beatId: beat?.beatId ?? null,
|
|
100
|
+
beatProgress: beat ? (time - beat.startMs) / (beat.endMs - beat.startMs) : 0,
|
|
101
|
+
transitionProgress: progress,
|
|
102
|
+
focusBefore: beat?.focusBefore ?? null,
|
|
103
|
+
focusAfter: beat?.focusAfter ?? null,
|
|
104
|
+
caption: beat?.caption ?? this.title,
|
|
105
|
+
objects,
|
|
106
|
+
flows: beat && time < beat.endMs ? beat.flows : []
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Select the next beat at endMs, retaining the last beat at total duration; static documents return undefined. */
|
|
110
|
+
findBeat(timeMs) {
|
|
111
|
+
let low = 0;
|
|
112
|
+
let high = this.beats.length - 1;
|
|
113
|
+
while (low < high) {
|
|
114
|
+
const middle = Math.floor((low + high) / 2);
|
|
115
|
+
if (timeMs < this.beats[middle].endMs)
|
|
116
|
+
high = middle;
|
|
117
|
+
else
|
|
118
|
+
low = middle + 1;
|
|
119
|
+
}
|
|
120
|
+
return this.beats[low];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function easeInOutCubic(progress) {
|
|
124
|
+
return progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Labels and whole node content take target values immediately; numbers/colors interpolate, returning exact values at endpoints.
|
|
128
|
+
* Interpolate base colors here; the core applies the highlight overlay afterward.
|
|
129
|
+
*/
|
|
130
|
+
function interpolateValues(source, target, progress, palette) {
|
|
131
|
+
if (progress === 0)
|
|
132
|
+
return { ...source.values, label: target.values.label, content: target.values.content };
|
|
133
|
+
if (progress === 1)
|
|
134
|
+
return { ...target.values };
|
|
135
|
+
const numericValues = Object.fromEntries(NUMERIC_VISUAL_FIELDS.flatMap((key) => {
|
|
136
|
+
const from = source.values[key];
|
|
137
|
+
const to = target.values[key];
|
|
138
|
+
// Weighted sums avoid overflow from subtracting widely separated finite endpoints first.
|
|
139
|
+
return from === undefined || to === undefined ? [] : [[key, from * (1 - progress) + to * progress]];
|
|
140
|
+
}));
|
|
141
|
+
const strokeFallback = target.kind === "node" ? palette.stroke : palette.edge;
|
|
142
|
+
return {
|
|
143
|
+
...target.values,
|
|
144
|
+
...numericValues,
|
|
145
|
+
fill: mixColor(source.values.fill ?? palette.fill, target.values.fill ?? palette.fill, progress),
|
|
146
|
+
stroke: mixColor(source.values.stroke ?? strokeFallback, target.values.stroke ?? strokeFallback, progress)
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ValidationIssue } from "./model.js";
|
|
2
|
+
/** Compilation error for invalid documents; issues contains all field problems found by validation. */
|
|
3
|
+
export declare class DiagramValidationError extends Error {
|
|
4
|
+
readonly issues: readonly ValidationIssue[];
|
|
5
|
+
constructor(issues: ValidationIssue[]);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate document structure, values and references; an empty list means validation passed.
|
|
9
|
+
* Accumulate issues across inspectable branches without mutating input or producing a partial Diagram.
|
|
10
|
+
*/
|
|
11
|
+
export declare function validateDocument(input: unknown): ValidationIssue[];
|
|
@@ -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
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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
ADDED
package/dist/react.d.ts
ADDED
package/dist/react.js
ADDED
package/dist/styles.css
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* Control and canvas focus styles; CSS variables customize controls only. The core theme resolves diagram colors. */
|
|
2
|
+
.flowsvg-canvas:focus-visible {
|
|
3
|
+
outline: 3px solid #2563eb;
|
|
4
|
+
outline-offset: 3px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.flowsvg-controls {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-wrap: wrap;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: 0.5rem;
|
|
12
|
+
padding: 0.75rem;
|
|
13
|
+
color: var(--flowsvg-control-color, #172033);
|
|
14
|
+
background: var(--flowsvg-control-background, #ffffff);
|
|
15
|
+
font: 1rem/1.4 system-ui, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.flowsvg-controls :is(button, input, select) {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
min-height: 2.75rem;
|
|
21
|
+
max-width: 100%;
|
|
22
|
+
color: var(--flowsvg-control-color, #172033);
|
|
23
|
+
background: var(--flowsvg-control-background, #ffffff);
|
|
24
|
+
border: 1px solid var(--flowsvg-control-border, #64748b);
|
|
25
|
+
border-radius: 0.25rem;
|
|
26
|
+
font: inherit;
|
|
27
|
+
accent-color: var(--flowsvg-control-accent, #2563eb);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.flowsvg-controls :is(button, select) {
|
|
31
|
+
padding: 0.5rem 0.75rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.flowsvg-controls :is(button, input, select):focus-visible {
|
|
35
|
+
outline: 3px solid var(--flowsvg-control-focus, #2563eb);
|
|
36
|
+
outline-offset: 3px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.flowsvg-controls :disabled {
|
|
40
|
+
opacity: 0.55;
|
|
41
|
+
cursor: default;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.flowsvg-controls input[type="range"] {
|
|
45
|
+
flex: 1 1 10rem;
|
|
46
|
+
min-width: 4rem;
|
|
47
|
+
}
|
package/dist/vue.d.ts
ADDED
package/dist/vue.js
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|