@dxos/plugin-illustrator 0.10.0 → 0.11.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/dist/lib/IllustratorPlugin.mjs +7 -3
- package/dist/lib/IllustratorPlugin.mjs.map +1 -1
- package/dist/lib/IllustratorPlugin.node.mjs +3 -3
- package/dist/lib/IllustratorPlugin.node.mjs.map +1 -1
- package/dist/lib/IllustratorPlugin.workerd.mjs +2 -2
- package/dist/lib/IllustratorPlugin.workerd.mjs.map +1 -1
- package/dist/lib/capabilities.mjs +2 -1
- package/dist/lib/capabilities.mjs.map +1 -1
- package/dist/lib/chunk-Drawing.mjs +64 -0
- package/dist/lib/chunk-Drawing.mjs.map +1 -0
- package/dist/lib/chunk-IllustratorCapabilities.mjs +2 -2
- package/dist/lib/chunk-IllustratorCapabilities.mjs.map +1 -1
- package/dist/lib/chunk-comment-config.mjs +3 -3
- package/dist/lib/chunk-comment-config.mjs.map +1 -1
- package/dist/lib/chunk-create-object.mjs +13 -12
- package/dist/lib/chunk-create-object.mjs.map +1 -1
- package/dist/lib/chunk-create.mjs +5 -4
- package/dist/lib/chunk-create.mjs.map +1 -1
- package/dist/lib/chunk-edit.mjs +4 -4
- package/dist/lib/chunk-edit.mjs.map +1 -1
- package/dist/lib/chunk-generate.mjs +20 -0
- package/dist/lib/chunk-generate.mjs.map +1 -0
- package/dist/lib/chunk-load-drawing.mjs +35 -0
- package/dist/lib/chunk-load-drawing.mjs.map +1 -0
- package/dist/lib/chunk-meta.mjs +2 -1
- package/dist/lib/chunk-meta.mjs.map +1 -1
- package/dist/lib/chunk-migrations.mjs +23 -0
- package/dist/lib/chunk-migrations.mjs.map +1 -0
- package/dist/lib/chunk-model.mjs +387 -4
- package/dist/lib/chunk-model.mjs.map +1 -1
- package/dist/lib/chunk-react-surface.mjs +14 -13
- package/dist/lib/chunk-react-surface.mjs.map +1 -1
- package/dist/lib/chunk-read.mjs +4 -4
- package/dist/lib/chunk-read.mjs.map +1 -1
- package/dist/lib/chunk-skill-definition.mjs +2 -2
- package/dist/lib/chunk-skill-definition.mjs.map +1 -1
- package/dist/lib/chunk-skills.mjs +10 -10
- package/dist/lib/chunk-skills.mjs.map +1 -1
- package/dist/lib/chunk-types.mjs +41 -49
- package/dist/lib/chunk-types.mjs.map +1 -1
- package/dist/lib/components.mjs +6 -6
- package/dist/lib/components.mjs.map +1 -1
- package/dist/lib/containers.mjs +17 -19
- package/dist/lib/containers.mjs.map +1 -1
- package/dist/lib/index.mjs +4 -3
- package/dist/lib/model.mjs +2 -2
- package/dist/lib/operations.mjs +1 -1
- package/dist/lib/operations.mjs.map +1 -1
- package/dist/lib/skills.mjs +1 -1
- package/dist/lib/translations.mjs +13 -13
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/lib/types.mjs +3 -2
- package/dist/lib/util.mjs +2 -2
- package/dist/types/src/IllustratorPlugin.d.ts.map +1 -1
- package/dist/types/src/IllustratorPlugin.node.d.ts +1 -1
- package/dist/types/src/IllustratorPlugin.test.d.ts +2 -0
- package/dist/types/src/IllustratorPlugin.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +1 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -1
- package/dist/types/src/capabilities/migrations.d.ts +7 -0
- package/dist/types/src/capabilities/migrations.d.ts.map +1 -0
- package/dist/types/src/capabilities/migrations.test.d.ts +2 -0
- package/dist/types/src/capabilities/migrations.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/react-surface.d.ts.map +1 -1
- package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -1
- package/dist/types/src/components/CreateDrawingPanel.d.ts +15 -0
- package/dist/types/src/components/CreateDrawingPanel.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/containers/DrawingArticle/DrawingArticle.d.ts +11 -0
- package/dist/types/src/containers/DrawingArticle/DrawingArticle.d.ts.map +1 -0
- package/dist/types/src/containers/DrawingArticle/index.d.ts +2 -0
- package/dist/types/src/containers/DrawingArticle/index.d.ts.map +1 -0
- package/dist/types/src/containers/DrawingCard/DrawingCard.d.ts +10 -0
- package/dist/types/src/containers/DrawingCard/DrawingCard.d.ts.map +1 -0
- package/dist/types/src/containers/DrawingCard/index.d.ts +2 -0
- package/dist/types/src/containers/DrawingCard/index.d.ts.map +1 -0
- package/dist/types/src/containers/index.d.ts +2 -2
- package/dist/types/src/containers/index.d.ts.map +1 -1
- package/dist/types/src/model/builder.d.ts +18 -26
- package/dist/types/src/model/builder.d.ts.map +1 -1
- package/dist/types/src/model/content.d.ts +60 -0
- package/dist/types/src/model/content.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +2 -0
- package/dist/types/src/model/index.d.ts.map +1 -1
- package/dist/types/src/model/mermaid.d.ts +38 -0
- package/dist/types/src/model/mermaid.d.ts.map +1 -0
- package/dist/types/src/model/mermaid.test.d.ts +3 -0
- package/dist/types/src/model/mermaid.test.d.ts.map +1 -0
- package/dist/types/src/operations/create.d.ts +2 -2
- package/dist/types/src/operations/create.d.ts.map +1 -1
- package/dist/types/src/operations/edit.d.ts +2 -2
- package/dist/types/src/operations/edit.d.ts.map +1 -1
- package/dist/types/src/operations/generate.d.ts +5 -0
- package/dist/types/src/operations/generate.d.ts.map +1 -0
- package/dist/types/src/operations/index.d.ts.map +1 -1
- package/dist/types/src/operations/read.d.ts +2 -2
- package/dist/types/src/operations/read.d.ts.map +1 -1
- package/dist/types/src/skills/{sketch-skill.d.ts → drawing-skill.d.ts} +1 -1
- package/dist/types/src/skills/drawing-skill.d.ts.map +1 -0
- package/dist/types/src/skills/index.d.ts +1 -1
- package/dist/types/src/skills/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +10 -10
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types/Drawing.d.ts +50 -0
- package/dist/types/src/types/Drawing.d.ts.map +1 -0
- package/dist/types/src/types/{SketchOperation.d.ts → DrawingOperation.d.ts} +111 -5
- package/dist/types/src/types/DrawingOperation.d.ts.map +1 -0
- package/dist/types/src/types/IllustratorCapabilities.d.ts +4 -4
- package/dist/types/src/types/IllustratorCapabilities.d.ts.map +1 -1
- package/dist/types/src/types/LegacySketch.d.ts +17 -0
- package/dist/types/src/types/LegacySketch.d.ts.map +1 -0
- package/dist/types/src/types/index.d.ts +3 -2
- package/dist/types/src/types/index.d.ts.map +1 -1
- package/dist/types/src/types/types.d.ts +18 -18
- package/dist/types/src/types/types.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +1 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/load-drawing.d.ts +22 -0
- package/dist/types/src/util/load-drawing.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dx.config.ts +1 -0
- package/package.json +21 -19
- package/src/IllustratorPlugin.node.tsx +3 -3
- package/src/IllustratorPlugin.test.ts +32 -0
- package/src/IllustratorPlugin.tsx +15 -3
- package/src/IllustratorPlugin.workerd.tsx +2 -2
- package/src/capabilities/comment-config.ts +2 -2
- package/src/capabilities/create-object.ts +16 -14
- package/src/capabilities/index.ts +1 -0
- package/src/capabilities/migrations.test.ts +142 -0
- package/src/capabilities/migrations.ts +28 -0
- package/src/capabilities/react-surface.ts +36 -0
- package/src/capabilities/skill-definition.ts +2 -2
- package/src/components/{CreateSketchPanel.tsx → CreateDrawingPanel.tsx} +7 -7
- package/src/components/index.ts +1 -1
- package/src/containers/{SketchArticle/SketchArticle.tsx → DrawingArticle/DrawingArticle.tsx} +10 -11
- package/src/containers/{SketchArticle → DrawingArticle}/index.ts +1 -1
- package/src/containers/DrawingCard/DrawingCard.tsx +38 -0
- package/src/containers/{SketchCard → DrawingCard}/index.ts +1 -1
- package/src/containers/index.ts +2 -2
- package/src/model/builder.ts +34 -26
- package/src/model/content.ts +180 -0
- package/src/model/index.ts +2 -0
- package/src/model/mermaid.test.ts +116 -0
- package/src/model/mermaid.ts +299 -0
- package/src/operations/create.ts +6 -6
- package/src/operations/edit.ts +5 -5
- package/src/operations/generate.ts +25 -0
- package/src/operations/index.ts +1 -0
- package/src/operations/read.ts +5 -5
- package/src/skills/{sketch-skill.ts → drawing-skill.ts} +7 -7
- package/src/skills/index.ts +1 -1
- package/src/translations.ts +12 -12
- package/src/types/Drawing.ts +69 -0
- package/src/types/{SketchOperation.ts → DrawingOperation.ts} +28 -9
- package/src/types/IllustratorCapabilities.ts +4 -4
- package/src/types/LegacySketch.ts +25 -0
- package/src/types/index.ts +3 -2
- package/src/types/types.ts +18 -18
- package/src/util/index.ts +1 -1
- package/src/util/load-drawing.ts +48 -0
- package/dist/lib/chunk-load-sketch.mjs +0 -64
- package/dist/lib/chunk-load-sketch.mjs.map +0 -1
- package/dist/types/src/components/CreateSketchPanel.d.ts +0 -15
- package/dist/types/src/components/CreateSketchPanel.d.ts.map +0 -1
- package/dist/types/src/containers/SketchArticle/SketchArticle.d.ts +0 -11
- package/dist/types/src/containers/SketchArticle/SketchArticle.d.ts.map +0 -1
- package/dist/types/src/containers/SketchArticle/index.d.ts +0 -2
- package/dist/types/src/containers/SketchArticle/index.d.ts.map +0 -1
- package/dist/types/src/containers/SketchCard/SketchCard.d.ts +0 -10
- package/dist/types/src/containers/SketchCard/SketchCard.d.ts.map +0 -1
- package/dist/types/src/containers/SketchCard/index.d.ts +0 -2
- package/dist/types/src/containers/SketchCard/index.d.ts.map +0 -1
- package/dist/types/src/skills/sketch-skill.d.ts.map +0 -1
- package/dist/types/src/types/Sketch.d.ts +0 -50
- package/dist/types/src/types/Sketch.d.ts.map +0 -1
- package/dist/types/src/types/SketchOperation.d.ts.map +0 -1
- package/dist/types/src/util/load-sketch.d.ts +0 -37
- package/dist/types/src/util/load-sketch.d.ts.map +0 -1
- package/src/capabilities/react-surface.tsx +0 -37
- package/src/containers/SketchCard/SketchCard.tsx +0 -41
- package/src/types/Sketch.ts +0 -69
- package/src/util/load-sketch.ts +0 -74
package/src/model/builder.ts
CHANGED
|
@@ -3,39 +3,47 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
//
|
|
6
|
-
// Renderer-neutral builder contract: each
|
|
7
|
-
// `
|
|
6
|
+
// Renderer-neutral builder contract: each variant (tldraw, excalidraw, …) implements
|
|
7
|
+
// `DrawingBuilder` to compile the scene DSL onto its canvas records and derive the scene back
|
|
8
8
|
// from them. Implementations are contributed via `IllustratorCapabilities.VariantProvider`.
|
|
9
9
|
//
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { Obj } from '@dxos/echo';
|
|
12
|
+
import { invariant } from '@dxos/invariant';
|
|
12
13
|
|
|
14
|
+
import * as Drawing from '../types/Drawing';
|
|
15
|
+
import { type ApplyResult, type ContentHandler, type ContentMap, type ReadResult, applyCommands } from './content';
|
|
13
16
|
import type * as Scene from './scene';
|
|
14
17
|
|
|
15
|
-
/** A world object as derived from the canvas: placement is always resolved. */
|
|
16
|
-
export type ReadWorldObject = Scene.WorldObject & { origin: Scene.Point; scale: number };
|
|
17
|
-
|
|
18
|
-
export type ReadResult = {
|
|
19
|
-
scene: { objects: ReadWorldObject[] };
|
|
20
|
-
/** Shapes on the canvas not managed by the DSL (hand-drawn by users). */
|
|
21
|
-
unmanaged: number;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export type ApplyResult = {
|
|
25
|
-
/** Object ids created or modified. */
|
|
26
|
-
upserted: string[];
|
|
27
|
-
/** Shape records removed. */
|
|
28
|
-
removed: number;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
18
|
/**
|
|
32
|
-
* Maps the scene DSL onto a
|
|
33
|
-
*
|
|
34
|
-
* coherent after users drag or resize shapes in the renderer's UI.
|
|
19
|
+
* Maps the scene DSL onto a canvas. Geometry is derived from the live records — not a stored
|
|
20
|
+
* copy — so the model stays coherent after users drag or resize shapes in the renderer's UI.
|
|
35
21
|
*/
|
|
36
|
-
export type
|
|
22
|
+
export type DrawingBuilder = {
|
|
23
|
+
/** Renderer dialect this builder handles; matched against `Canvas.schema`. */
|
|
24
|
+
readonly schema: string;
|
|
37
25
|
/** Derive the neutral scene from the canvas. */
|
|
38
|
-
read: (canvas:
|
|
39
|
-
/** Apply a batch of edit commands atomically
|
|
40
|
-
apply: (canvas:
|
|
26
|
+
read: (canvas: Drawing.Canvas) => ReadResult;
|
|
27
|
+
/** Apply a batch of edit commands atomically. */
|
|
28
|
+
apply: (canvas: Drawing.Canvas, commands: readonly Scene.Command[]) => ApplyResult;
|
|
41
29
|
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds a {@link DrawingBuilder} from the renderer's {@link ContentHandler}, so a variant only
|
|
33
|
+
* supplies its record encoding and inherits the ECHO write semantics and command bookkeeping.
|
|
34
|
+
*/
|
|
35
|
+
export const makeBuilder = ({ schema, handler }: { schema: string; handler: ContentHandler }): DrawingBuilder => ({
|
|
36
|
+
schema,
|
|
37
|
+
read: (canvas) => {
|
|
38
|
+
invariant(Obj.instanceOf(Drawing.Canvas, canvas));
|
|
39
|
+
return handler.read(canvas.content);
|
|
40
|
+
},
|
|
41
|
+
apply: (canvas, commands) => {
|
|
42
|
+
invariant(Obj.instanceOf(Drawing.Canvas, canvas));
|
|
43
|
+
let result: ApplyResult = { upserted: [], removed: 0 };
|
|
44
|
+
Obj.update(canvas, (canvas) => {
|
|
45
|
+
result = applyCommands(canvas.content as ContentMap, commands, handler);
|
|
46
|
+
});
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Renderer-neutral command application over a canvas content map. Every renderer stores its
|
|
7
|
+
// canvas as `Record<recordId, record>` and stamps DSL identity onto each managed record, so the
|
|
8
|
+
// upsert/remove/move bookkeeping is identical; only the encoding differs. Renderers supply that
|
|
9
|
+
// encoding via `ContentHandler` and inherit the command semantics.
|
|
10
|
+
//
|
|
11
|
+
|
|
12
|
+
import { invariant } from '@dxos/invariant';
|
|
13
|
+
|
|
14
|
+
import type * as Scene from './scene';
|
|
15
|
+
|
|
16
|
+
/** Opaque map of record id → renderer record. */
|
|
17
|
+
export type ContentMap = Record<string, any>;
|
|
18
|
+
|
|
19
|
+
/** DSL identity stamped onto a managed record. */
|
|
20
|
+
export type Identity = {
|
|
21
|
+
object: string;
|
|
22
|
+
element: string;
|
|
23
|
+
/** Set on companion records that belong to an element rather than being one (e.g. arrow labels). */
|
|
24
|
+
part?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Where an object sits on the canvas: `canvas = origin + local * scale`. */
|
|
28
|
+
export type Placement = {
|
|
29
|
+
origin: Scene.Point;
|
|
30
|
+
scale: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** An object as accepted by the renderer's compiler; `upsert-elements` supplies only a subset. */
|
|
34
|
+
export type ObjectInput = Scene.WorldObject | { id: string; elements: readonly Scene.Element[] };
|
|
35
|
+
|
|
36
|
+
/** A world object as derived from the canvas: placement is always resolved. */
|
|
37
|
+
export type ReadWorldObject = Scene.WorldObject & Placement;
|
|
38
|
+
|
|
39
|
+
export type ReadResult = {
|
|
40
|
+
scene: { objects: ReadWorldObject[] };
|
|
41
|
+
/** Records on the canvas not managed by the DSL (drawn by hand). */
|
|
42
|
+
unmanaged: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ApplyResult = {
|
|
46
|
+
/** Object ids created or modified. */
|
|
47
|
+
upserted: string[];
|
|
48
|
+
/** Records removed. */
|
|
49
|
+
removed: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The renderer-specific half of command application: how records carry identity, how objects
|
|
54
|
+
* compile to records, and any bookkeeping the renderer's store requires.
|
|
55
|
+
*/
|
|
56
|
+
export type ContentHandler = {
|
|
57
|
+
/** DSL identity for a record, or undefined when the record isn't managed by the DSL. */
|
|
58
|
+
identify: (record: any) => Identity | undefined;
|
|
59
|
+
/** Compile an object's elements into records keyed by record id. */
|
|
60
|
+
render: (object: ObjectInput, placement: Placement, content: ContentMap) => ContentMap;
|
|
61
|
+
/** Derive the scene from the content map. */
|
|
62
|
+
read: (content: ContentMap) => ReadResult;
|
|
63
|
+
/** Translate an object move into a per-record mutation. */
|
|
64
|
+
translate: (record: any, delta: Scene.Point) => void;
|
|
65
|
+
/** Create any scaffolding the renderer needs before its first record (e.g. a page). */
|
|
66
|
+
scaffold?: (content: ContentMap) => void;
|
|
67
|
+
/** Merge compiled records into the content map; defaults to a plain assign. */
|
|
68
|
+
merge?: (content: ContentMap, records: ContentMap) => void;
|
|
69
|
+
/** Clean up after deletions (e.g. drop bindings left dangling). */
|
|
70
|
+
prune?: (content: ContentMap) => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Apply a batch of edit commands to the canvas content (mutates `content`). */
|
|
74
|
+
export const applyCommands = (
|
|
75
|
+
content: ContentMap,
|
|
76
|
+
commands: readonly Scene.Command[],
|
|
77
|
+
handler: ContentHandler,
|
|
78
|
+
): ApplyResult => {
|
|
79
|
+
handler.scaffold?.(content);
|
|
80
|
+
const upserted = new Set<string>();
|
|
81
|
+
let removed = 0;
|
|
82
|
+
|
|
83
|
+
const find = (objectId: string) => handler.read(content).scene.objects.find((object) => object.id === objectId);
|
|
84
|
+
|
|
85
|
+
const merge = (object: ObjectInput, placement: Placement) => {
|
|
86
|
+
const records = handler.render(object, placement, content);
|
|
87
|
+
if (handler.merge) {
|
|
88
|
+
handler.merge(content, records);
|
|
89
|
+
} else {
|
|
90
|
+
Object.assign(content, records);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Companion records (labels) are deleted alongside their element but aren't themselves
|
|
95
|
+
// elements, so they don't count toward the reported total.
|
|
96
|
+
const remove = (predicate: (identity: Identity) => boolean): number => {
|
|
97
|
+
let count = 0;
|
|
98
|
+
for (const [key, record] of Object.entries(content)) {
|
|
99
|
+
const identity = handler.identify(record);
|
|
100
|
+
if (identity && predicate(identity)) {
|
|
101
|
+
// Read before delete: ECHO proxies dereference lazily, so fields vanish once removed.
|
|
102
|
+
const isPart = identity.part !== undefined;
|
|
103
|
+
delete content[key];
|
|
104
|
+
if (!isPart) {
|
|
105
|
+
count++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
handler.prune?.(content);
|
|
110
|
+
return count;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
for (const command of commands) {
|
|
114
|
+
switch (command.op) {
|
|
115
|
+
case 'upsert-object': {
|
|
116
|
+
const previous = find(command.object.id);
|
|
117
|
+
removed += remove((identity) => identity.object === command.object.id);
|
|
118
|
+
merge(command.object, {
|
|
119
|
+
origin: command.object.origin ?? previous?.origin ?? { x: 0, y: 0 },
|
|
120
|
+
scale: command.object.scale ?? previous?.scale ?? 1,
|
|
121
|
+
});
|
|
122
|
+
upserted.add(command.object.id);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case 'upsert-elements': {
|
|
127
|
+
const previous = find(command.objectId);
|
|
128
|
+
invariant(previous, `unknown object: ${command.objectId} (use upsert-object to create it)`);
|
|
129
|
+
const ids = new Set(command.elements.map((element) => element.id));
|
|
130
|
+
removed += remove((identity) => identity.object === command.objectId && ids.has(identity.element));
|
|
131
|
+
merge({ id: command.objectId, elements: command.elements }, previous);
|
|
132
|
+
upserted.add(command.objectId);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case 'remove-elements': {
|
|
137
|
+
const ids = new Set(command.elementIds);
|
|
138
|
+
removed += remove((identity) => identity.object === command.objectId && ids.has(identity.element));
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'remove-object': {
|
|
143
|
+
removed += remove((identity) => identity.object === command.objectId);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'move-object': {
|
|
148
|
+
const previous = find(command.objectId);
|
|
149
|
+
invariant(previous, `unknown object: ${command.objectId}`);
|
|
150
|
+
const delta = { x: command.origin.x - previous.origin.x, y: command.origin.y - previous.origin.y };
|
|
151
|
+
for (const record of Object.values(content)) {
|
|
152
|
+
if (handler.identify(record)?.object === command.objectId) {
|
|
153
|
+
handler.translate(record, delta);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
upserted.add(command.objectId);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { upserted: [...upserted], removed };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Seed a fractional-index counter past any existing `a<n>` indexes. */
|
|
166
|
+
export const nextIndex = (content: ContentMap, isCounted: (record: any) => boolean = () => true): number => {
|
|
167
|
+
let max = 0;
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (const record of Object.values(content)) {
|
|
170
|
+
if (!record || !isCounted(record)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
count++;
|
|
174
|
+
const match = /^a(\d+)$/.exec(record.index ?? '');
|
|
175
|
+
if (match) {
|
|
176
|
+
max = Math.max(max, Number(match[1]));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return Math.max(max, count);
|
|
180
|
+
};
|
package/src/model/index.ts
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { compile, parse } from './mermaid';
|
|
8
|
+
import type * as Scene from './scene';
|
|
9
|
+
|
|
10
|
+
/** Exercises node declarations, a labelled-blank subgraph, fan-out, and a `C <-> Y` cycle. */
|
|
11
|
+
export const FLOWCHART = `
|
|
12
|
+
flowchart TB
|
|
13
|
+
X[X]
|
|
14
|
+
|
|
15
|
+
subgraph CORE[" "]
|
|
16
|
+
A[A]
|
|
17
|
+
B[B]
|
|
18
|
+
C[C]
|
|
19
|
+
|
|
20
|
+
A --> B
|
|
21
|
+
A --> C
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Y[Y]
|
|
25
|
+
|
|
26
|
+
X --> A
|
|
27
|
+
X --> B
|
|
28
|
+
X --> C
|
|
29
|
+
C --> Y
|
|
30
|
+
Y --> C
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const objectsOf = (commands: Scene.Command[]) =>
|
|
34
|
+
commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
|
|
35
|
+
|
|
36
|
+
describe('mermaid', () => {
|
|
37
|
+
test('parses nodes, subgraph membership and edges', ({ expect }) => {
|
|
38
|
+
const graph = parse(FLOWCHART);
|
|
39
|
+
|
|
40
|
+
expect(graph.direction).toBe('TB');
|
|
41
|
+
expect(graph.nodes.map((node) => node.id).sort()).toEqual(['A', 'B', 'C', 'X', 'Y']);
|
|
42
|
+
expect(graph.groups.map((group) => group.id)).toEqual(['CORE']);
|
|
43
|
+
expect(graph.groups[0].children.sort()).toEqual(['A', 'B', 'C']);
|
|
44
|
+
// Nodes declared outside the subgraph carry no group.
|
|
45
|
+
expect(graph.nodes.find((node) => node.id === 'X')?.group).toBeUndefined();
|
|
46
|
+
expect(graph.nodes.find((node) => node.id === 'A')?.group).toBe('CORE');
|
|
47
|
+
expect(graph.edges.map((edge) => `${edge.from}->${edge.to}`)).toEqual([
|
|
48
|
+
'A->B',
|
|
49
|
+
'A->C',
|
|
50
|
+
'X->A',
|
|
51
|
+
'X->B',
|
|
52
|
+
'X->C',
|
|
53
|
+
'C->Y',
|
|
54
|
+
'Y->C',
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('compiles to one object per node plus a frame and an edge object', ({ expect }) => {
|
|
59
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
60
|
+
|
|
61
|
+
expect(objects.map((object) => object.id)).toEqual(['CORE', 'X', 'A', 'B', 'C', 'Y', 'edges']);
|
|
62
|
+
// Every node is a single labelled box; the label is the mermaid text, not the id.
|
|
63
|
+
for (const id of ['X', 'A', 'B', 'C', 'Y']) {
|
|
64
|
+
const [element] = objects.find((object) => object.id === id)!.elements;
|
|
65
|
+
expect(element).toMatchObject({ kind: 'rect', id: 'box', text: id });
|
|
66
|
+
}
|
|
67
|
+
// The blank subgraph label yields a frame with no text.
|
|
68
|
+
const frame = objects.find((object) => object.id === 'CORE')!.elements[0];
|
|
69
|
+
expect(frame).toMatchObject({ kind: 'rect', id: 'frame', stroke: 'dashed' });
|
|
70
|
+
expect(frame).not.toHaveProperty('text');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ranks nodes by longest path and breaks the C/Y cycle', ({ expect }) => {
|
|
74
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
75
|
+
const y = (id: string) => objects.find((object) => object.id === id)!.origin!.y;
|
|
76
|
+
|
|
77
|
+
// X feeds everything, so it leads; B and C sit past A because the longest path wins over the
|
|
78
|
+
// direct X edge. `Y --> C` is a back edge and must not push C below Y.
|
|
79
|
+
expect(y('X')).toBeLessThan(y('A'));
|
|
80
|
+
expect(y('A')).toBeLessThan(y('B'));
|
|
81
|
+
expect(y('A')).toBeLessThan(y('C'));
|
|
82
|
+
expect(y('B')).toBe(y('C'));
|
|
83
|
+
expect(y('C')).toBeLessThan(y('Y'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('binds arrows to node boxes, including both directions of the cycle', ({ expect }) => {
|
|
87
|
+
const edges = objectsOf(compile(FLOWCHART)).find((object) => object.id === 'edges')!;
|
|
88
|
+
const refs = edges.elements.map((element) => (element.kind === 'arrow' ? `${element.from}->${element.to}` : ''));
|
|
89
|
+
|
|
90
|
+
expect(refs).toContain('C/box->Y/box');
|
|
91
|
+
expect(refs).toContain('Y/box->C/box');
|
|
92
|
+
expect(edges.elements.every((element) => element.kind === 'arrow')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('frame encloses its members', ({ expect }) => {
|
|
96
|
+
const objects = objectsOf(compile(FLOWCHART));
|
|
97
|
+
const core = objects.find((object) => object.id === 'CORE')!;
|
|
98
|
+
const frame = core.elements[0] as Scene.Box;
|
|
99
|
+
|
|
100
|
+
for (const id of ['A', 'B', 'C']) {
|
|
101
|
+
const node = objects.find((object) => object.id === id)!;
|
|
102
|
+
expect(node.origin!.x).toBeGreaterThanOrEqual(core.origin!.x);
|
|
103
|
+
expect(node.origin!.y).toBeGreaterThanOrEqual(core.origin!.y);
|
|
104
|
+
expect(node.origin!.x).toBeLessThanOrEqual(core.origin!.x + frame.w);
|
|
105
|
+
expect(node.origin!.y).toBeLessThanOrEqual(core.origin!.y + frame.h);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('ignores comments and tolerates unknown syntax', ({ expect }) => {
|
|
110
|
+
const graph = parse(['flowchart LR', '%% a comment', 'A[A]', 'A --> B', 'click A callback'].join('\n'));
|
|
111
|
+
|
|
112
|
+
expect(graph.direction).toBe('LR');
|
|
113
|
+
expect(graph.nodes.map((node) => node.id)).toEqual(['A', 'B']);
|
|
114
|
+
expect(graph.edges).toEqual([{ from: 'A', to: 'B' }]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Mermaid flowchart dialect: parses the subset needed for block diagrams (node declarations,
|
|
7
|
+
// subgraphs, directed edges) and compiles it to scene commands with a layered layout. Mermaid
|
|
8
|
+
// carries no coordinates, so the dialect owns placement — see `dialect.ts` for the contract.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
import type * as Scene from './scene';
|
|
12
|
+
|
|
13
|
+
export type Direction = 'TB' | 'BT' | 'LR' | 'RL';
|
|
14
|
+
|
|
15
|
+
export type MermaidNode = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
/** Enclosing subgraph id, when declared inside one. */
|
|
19
|
+
group?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type MermaidGroup = {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
children: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MermaidEdge = {
|
|
29
|
+
from: string;
|
|
30
|
+
to: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type MermaidGraph = {
|
|
35
|
+
direction: Direction;
|
|
36
|
+
nodes: MermaidNode[];
|
|
37
|
+
groups: MermaidGroup[];
|
|
38
|
+
edges: MermaidEdge[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DIRECTIONS: Direction[] = ['TB', 'BT', 'LR', 'RL'];
|
|
42
|
+
|
|
43
|
+
// `A[Label]`, `A(Label)`, `A{Label}` or a bare `A`.
|
|
44
|
+
const NODE = /^([A-Za-z0-9_-]+)(?:\[(.*?)\]|\((.*?)\)|\{(.*?)\})?$/;
|
|
45
|
+
// `A --> B`, `A-->|label|B`, `A --- B`.
|
|
46
|
+
const EDGE = /^(.+?)\s*(-->|---|-\.->|==>)\s*(?:\|(.*?)\|\s*)?(.+)$/;
|
|
47
|
+
const SUBGRAPH = /^subgraph\s+([A-Za-z0-9_-]+)(?:\s*\[(.*?)\])?\s*$/;
|
|
48
|
+
|
|
49
|
+
const unquote = (value: string) => value.trim().replace(/^"(.*)"$/, '$1');
|
|
50
|
+
|
|
51
|
+
/** Parse a mermaid flowchart into a neutral graph. Unrecognised lines are ignored. */
|
|
52
|
+
export const parse = (source: string): MermaidGraph => {
|
|
53
|
+
const nodes = new Map<string, MermaidNode>();
|
|
54
|
+
const groups: MermaidGroup[] = [];
|
|
55
|
+
const edges: MermaidEdge[] = [];
|
|
56
|
+
let direction: Direction = 'TB';
|
|
57
|
+
const stack: string[] = [];
|
|
58
|
+
|
|
59
|
+
const declare = (token: string): string | undefined => {
|
|
60
|
+
const match = NODE.exec(token.trim());
|
|
61
|
+
if (!match) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const [, id, square, round, curly] = match;
|
|
65
|
+
const label = unquote(square ?? round ?? curly ?? id);
|
|
66
|
+
const group = stack[stack.length - 1];
|
|
67
|
+
const existing = nodes.get(id);
|
|
68
|
+
if (existing) {
|
|
69
|
+
// A later declaration with an explicit label wins; the first mention fixes the group.
|
|
70
|
+
if (square ?? round ?? curly) {
|
|
71
|
+
existing.label = label;
|
|
72
|
+
}
|
|
73
|
+
return id;
|
|
74
|
+
}
|
|
75
|
+
nodes.set(id, { id, label, ...(group ? { group } : {}) });
|
|
76
|
+
if (group) {
|
|
77
|
+
groups.find((entry) => entry.id === group)?.children.push(id);
|
|
78
|
+
}
|
|
79
|
+
return id;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const raw of source.split('\n')) {
|
|
83
|
+
const line = raw.trim();
|
|
84
|
+
if (!line || line.startsWith('%%')) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const header = /^(?:flowchart|graph)\s+([A-Za-z]{2})$/.exec(line);
|
|
89
|
+
if (header) {
|
|
90
|
+
const value = header[1].toUpperCase() as Direction;
|
|
91
|
+
direction = DIRECTIONS.includes(value) ? value : 'TB';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const subgraph = SUBGRAPH.exec(line);
|
|
96
|
+
if (subgraph) {
|
|
97
|
+
const [, id, label] = subgraph;
|
|
98
|
+
groups.push({ id, label: unquote(label ?? id), children: [] });
|
|
99
|
+
stack.push(id);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (line === 'end') {
|
|
104
|
+
stack.pop();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const edge = EDGE.exec(line);
|
|
109
|
+
if (edge) {
|
|
110
|
+
const [, from, , label, to] = edge;
|
|
111
|
+
const fromId = declare(from);
|
|
112
|
+
const toId = declare(to);
|
|
113
|
+
if (fromId && toId) {
|
|
114
|
+
edges.push({ from: fromId, to: toId, ...(label ? { label: unquote(label) } : {}) });
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
declare(line);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { direction, nodes: [...nodes.values()], groups, edges };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Layout constants in scene units. */
|
|
126
|
+
const NODE_W = 100;
|
|
127
|
+
const NODE_H = 50;
|
|
128
|
+
const GAP_MAIN = 60;
|
|
129
|
+
const GAP_CROSS = 40;
|
|
130
|
+
const GROUP_PAD = 24;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Edges that close a cycle, found by DFS: an edge into a node still on the stack points backwards.
|
|
134
|
+
* Ranking ignores them, so `C --> Y --> C` ranks C by its forward predecessors alone instead of
|
|
135
|
+
* chasing the loop.
|
|
136
|
+
*/
|
|
137
|
+
const backEdges = (graph: MermaidGraph): Set<MermaidEdge> => {
|
|
138
|
+
const outgoing = new Map<string, MermaidEdge[]>();
|
|
139
|
+
for (const edge of graph.edges) {
|
|
140
|
+
outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const back = new Set<MermaidEdge>();
|
|
144
|
+
const done = new Set<string>();
|
|
145
|
+
const onStack = new Set<string>();
|
|
146
|
+
|
|
147
|
+
const visit = (id: string) => {
|
|
148
|
+
onStack.add(id);
|
|
149
|
+
for (const edge of outgoing.get(id) ?? []) {
|
|
150
|
+
if (onStack.has(edge.to)) {
|
|
151
|
+
back.add(edge);
|
|
152
|
+
} else if (!done.has(edge.to)) {
|
|
153
|
+
visit(edge.to);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
onStack.delete(id);
|
|
157
|
+
done.add(id);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Roots first so the DFS classifies edges along the natural reading order of the diagram.
|
|
161
|
+
const targets = new Set(graph.edges.map((edge) => edge.to));
|
|
162
|
+
for (const node of graph.nodes) {
|
|
163
|
+
if (!targets.has(node.id) && !done.has(node.id)) {
|
|
164
|
+
visit(node.id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const node of graph.nodes) {
|
|
168
|
+
if (!done.has(node.id)) {
|
|
169
|
+
visit(node.id);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return back;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Assign each node a rank one past its deepest forward predecessor. */
|
|
176
|
+
const rank = (graph: MermaidGraph): Map<string, number> => {
|
|
177
|
+
const back = backEdges(graph);
|
|
178
|
+
const forward = graph.edges.filter((edge) => !back.has(edge));
|
|
179
|
+
const ranks = new Map(graph.nodes.map((node) => [node.id, 0]));
|
|
180
|
+
for (let pass = 0; pass < graph.nodes.length; pass++) {
|
|
181
|
+
let changed = false;
|
|
182
|
+
for (const edge of forward) {
|
|
183
|
+
const next = (ranks.get(edge.from) ?? 0) + 1;
|
|
184
|
+
if (next > (ranks.get(edge.to) ?? 0)) {
|
|
185
|
+
ranks.set(edge.to, next);
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!changed) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return ranks;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type CompileOptions = {
|
|
197
|
+
/** Canvas position of the diagram's top-left, in canvas px. */
|
|
198
|
+
origin?: Scene.Point;
|
|
199
|
+
/** Canvas px per scene unit. */
|
|
200
|
+
scale?: number;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Compile a mermaid flowchart into scene commands: one world object per node (element `box`),
|
|
205
|
+
* one per subgraph frame, and an `edges` object holding the connectors as bound arrows.
|
|
206
|
+
*/
|
|
207
|
+
export const compile = (source: string, options: CompileOptions = {}): Scene.Command[] => {
|
|
208
|
+
const graph = parse(source);
|
|
209
|
+
const { origin = { x: 0, y: 0 }, scale = 1 } = options;
|
|
210
|
+
const ranks = rank(graph);
|
|
211
|
+
const horizontal = graph.direction === 'LR' || graph.direction === 'RL';
|
|
212
|
+
|
|
213
|
+
// Group nodes by rank, preserving declaration order within each.
|
|
214
|
+
const lanes = new Map<number, MermaidNode[]>();
|
|
215
|
+
for (const node of graph.nodes) {
|
|
216
|
+
const value = ranks.get(node.id) ?? 0;
|
|
217
|
+
lanes.set(value, [...(lanes.get(value) ?? []), node]);
|
|
218
|
+
}
|
|
219
|
+
const widest = Math.max(...[...lanes.values()].map((lane) => lane.length), 1);
|
|
220
|
+
|
|
221
|
+
const positions = new Map<string, Scene.Point>();
|
|
222
|
+
for (const [lane, members] of lanes) {
|
|
223
|
+
const span = members.length * NODE_W + (members.length - 1) * GAP_CROSS;
|
|
224
|
+
const offset = (widest * NODE_W + (widest - 1) * GAP_CROSS - span) / 2;
|
|
225
|
+
members.forEach((node, index) => {
|
|
226
|
+
const cross = offset + index * (NODE_W + GAP_CROSS);
|
|
227
|
+
const main = lane * (NODE_H + GAP_MAIN);
|
|
228
|
+
positions.set(node.id, horizontal ? { x: main, y: cross } : { x: cross, y: main });
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const commands: Scene.Command[] = [];
|
|
233
|
+
|
|
234
|
+
// Frames first so nodes paint above them.
|
|
235
|
+
for (const group of graph.groups) {
|
|
236
|
+
const members = group.children.map((id) => positions.get(id)).filter((point): point is Scene.Point => !!point);
|
|
237
|
+
if (members.length === 0) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const minX = Math.min(...members.map((point) => point.x)) - GROUP_PAD;
|
|
241
|
+
const minY = Math.min(...members.map((point) => point.y)) - GROUP_PAD;
|
|
242
|
+
const maxX = Math.max(...members.map((point) => point.x)) + NODE_W + GROUP_PAD;
|
|
243
|
+
const maxY = Math.max(...members.map((point) => point.y)) + NODE_H + GROUP_PAD;
|
|
244
|
+
commands.push({
|
|
245
|
+
op: 'upsert-object',
|
|
246
|
+
object: {
|
|
247
|
+
id: group.id,
|
|
248
|
+
origin: { x: origin.x + minX * scale, y: origin.y + minY * scale },
|
|
249
|
+
scale,
|
|
250
|
+
elements: [
|
|
251
|
+
{
|
|
252
|
+
kind: 'rect',
|
|
253
|
+
id: 'frame',
|
|
254
|
+
x: 0,
|
|
255
|
+
y: 0,
|
|
256
|
+
w: maxX - minX,
|
|
257
|
+
h: maxY - minY,
|
|
258
|
+
stroke: 'dashed',
|
|
259
|
+
color: 'grey',
|
|
260
|
+
...(group.label.trim() ? { text: group.label } : {}),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const node of graph.nodes) {
|
|
268
|
+
const point = positions.get(node.id)!;
|
|
269
|
+
commands.push({
|
|
270
|
+
op: 'upsert-object',
|
|
271
|
+
object: {
|
|
272
|
+
id: node.id,
|
|
273
|
+
origin: { x: origin.x + point.x * scale, y: origin.y + point.y * scale },
|
|
274
|
+
scale,
|
|
275
|
+
elements: [{ kind: 'rect', id: 'box', x: 0, y: 0, w: NODE_W, h: NODE_H, text: node.label }],
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (graph.edges.length > 0) {
|
|
281
|
+
commands.push({
|
|
282
|
+
op: 'upsert-object',
|
|
283
|
+
object: {
|
|
284
|
+
id: 'edges',
|
|
285
|
+
origin,
|
|
286
|
+
scale,
|
|
287
|
+
elements: graph.edges.map((edge) => ({
|
|
288
|
+
kind: 'arrow' as const,
|
|
289
|
+
id: `${edge.from}-${edge.to}`,
|
|
290
|
+
from: `${edge.from}/box`,
|
|
291
|
+
to: `${edge.to}/box`,
|
|
292
|
+
...(edge.label ? { text: edge.label } : {}),
|
|
293
|
+
})),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return commands;
|
|
299
|
+
};
|