agentic-canvas 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -3
- package/dist/{chunk-JQCUVXXD.js → chunk-5II2WZIL.js} +2 -2
- package/dist/{chunk-NLTN7J22.js → chunk-A4YK2SWM.js} +3 -5
- package/dist/chunk-EDQNA2SU.js +290 -0
- package/dist/{chunk-ZSPZMGHG.js → chunk-MVWUIBGV.js} +50 -320
- package/dist/{chunk-M74ENUWR.js → chunk-Z6KLGAFP.js} +6 -6
- package/dist/cli.js +4 -4
- package/dist/embed.d.ts +29 -4
- package/dist/embed.js +114 -35
- package/dist/index.js +11 -11
- package/dist/live-worker.js +4 -4
- package/dist/live.js +6 -5
- package/dist/ui.css +17 -9
- package/dist/worker.js +4 -4
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/dist/chunk-JOBTIAFJ.js +0 -19
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
A shared whiteboard for diagrams, drawn by people and agents.
|
|
9
9
|
|
|
10
|
-
> Status: 0.
|
|
10
|
+
> Status: 0.12.0. The board model, the agent operations, the exports, the CLI, the
|
|
11
11
|
> MCP server, a small Worker API, and live co-editing work. The CLI, the MCP server,
|
|
12
12
|
> and the SDK can join a live room. A remote client calls the Worker API. A small web UI, served by the Worker, is new in 0.4.0.
|
|
13
13
|
> The package exports `agentic-canvas/ui`, a board view you can mount in your own app, new in 0.5.0.
|
|
@@ -111,6 +111,24 @@ There is one method per route: `me`, `listBoards`, `createBoard`, `readBoard`, `
|
|
|
111
111
|
and status 0. The client never retries. A 503 means "try again", and the caller decides, because a
|
|
112
112
|
repeated write can change a board twice. Pass `fetch` to use your own.
|
|
113
113
|
|
|
114
|
+
## Run locally
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
git clone https://github.com/pooriaarab/agentic-canvas
|
|
118
|
+
cd agentic-canvas
|
|
119
|
+
npm install && npm run dev
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
You can also use `bun install && bun run dev`. The `dev` script calls npm, so npm must be installed. The scripts need macOS, Linux, or WSL. The `dev` script builds the package, then starts `wrangler dev` on <http://localhost:8793>. Wrangler keeps a local D1 database. The `dev` script turns off Wrangler telemetry. Set `WRANGLER_SEND_METRICS=false` when you run other wrangler commands.
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
curl -i http://localhost:8793/health # 200
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `dev` script sets `CANVAS_REGION=unknown`, which local Wrangler needs. The API answers 401 until you sign in. For local work, see the dev fallback in "Auth".
|
|
129
|
+
|
|
130
|
+
`npm run dev:smoke` runs the same check by itself. It starts the dev server. It waits for `/health` to answer 200. It stops the server. CI runs it as the "dev smoke" step. To use another port, change `8793` in the `dev` and `dev:smoke` scripts.
|
|
131
|
+
|
|
114
132
|
## Live demo
|
|
115
133
|
|
|
116
134
|
A demo Worker runs on workers.dev: <https://agentic-canvas-demo.hello-fd1.workers.dev>. `GET /health` answers 200
|
|
@@ -240,7 +258,7 @@ What it does:
|
|
|
240
258
|
|
|
241
259
|
- Lists boards, makes a board, and opens one. Empty, loading, and error states have a retry.
|
|
242
260
|
- Draws shapes, connectors, text, and sticky notes. Select, move, resize, connect, and delete an
|
|
243
|
-
item. Pan by dragging the background, zoom with the wheel or the
|
|
261
|
+
item. Pan by dragging the background, zoom with the wheel or the zoom controls on the canvas.
|
|
244
262
|
- Marks an item an agent wrote with an "Agent" tag and in its accessible name. The side panel shows
|
|
245
263
|
who made the item and who last edited it.
|
|
246
264
|
- Shows who is in the board, from the live room (`agentic-realtime`). Edits go over the HTTP API.
|
|
@@ -283,6 +301,39 @@ server still checks every write against the token. The host page owns the theme.
|
|
|
283
301
|
For a React app, `BoardEditor` is the same component without a root. Wrap it in the providers of
|
|
284
302
|
`agentic-design/react`.
|
|
285
303
|
|
|
304
|
+
### Use your own Yjs document
|
|
305
|
+
|
|
306
|
+
An app that keeps the board in its own Yjs room gives it as `host`. The UI then reads and writes that document as a
|
|
307
|
+
board (its `elements` and `meta` maps, the shape this package stores) and redraws as soon as the document changes.
|
|
308
|
+
It makes no HTTP call, does not poll, and opens no live room. `client` is not needed. The default is still client
|
|
309
|
+
mode: without `host`, the UI works as described above.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const board = mount(element, {
|
|
313
|
+
boardId: "b1",
|
|
314
|
+
principal: { kind: "human", name: "Ada", id: "ada", role: "editor" }, // id is stamped on what Ada writes; the default is name
|
|
315
|
+
host: {
|
|
316
|
+
doc, // a Y.Doc
|
|
317
|
+
presence: sync, // optional: a SyncClient of agentic-realtime, or { peers, onPeers }
|
|
318
|
+
status: () => status, // optional: "connecting" | "connected" | "disconnected"
|
|
319
|
+
onStatus: (listener) => stop, // optional: tell the UI when the status changes
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- Use the same `yjs` copy as the UI. A document from another copy is refused with a `TypeError`.
|
|
325
|
+
- Each element the UI creates or edits is stamped with the principal's `id` and `kind`. A delete has no stamp. The `id` follows the identity rule of the
|
|
326
|
+
SDK (letters, digits, `.`, `_`, `-`). An agent kind marks its elements as machine-made. A refused write shows a message
|
|
327
|
+
and leaves the document alone. The server does not check these writes: your app and its room decide who may write.
|
|
328
|
+
- In host mode `boardId` only keys the view. The UI shows the one board that the document holds (its `elements` and `meta` maps), so keep one board in each document.
|
|
329
|
+
- A document pushed past the element cap (10,000) can no longer be read. If that happens after the first draw, the UI keeps the last board it drew. At mount it shows the error page with Retry. Your app should stop a peer from writing that far.
|
|
330
|
+
- `client` or `room` together with `host` throws a `TypeError`, so a board never half-writes to a server. (`BoardEditor` shows an alert for these, as it does for a bad principal.) Give presence in `host.presence`.
|
|
331
|
+
- The principal's `id` (default: `name`) must pass the identity rule, so a display name with a space needs an explicit `id`. A bad one throws a `TypeError` at mount.
|
|
332
|
+
- The UI redraws only for changes in the document's `elements` and `meta` maps. Other data in the same document is left alone.
|
|
333
|
+
- `status` and `onStatus` are read when the UI opens `doc`. A new function on `update()` does not open it again. `onStatus` alone starts as not live, until it reports "connected". With neither, the board shows as live. Give `onStatus` at mount to follow later changes. It is not picked up by a later `update()` for the same `doc`.
|
|
334
|
+
- With `presence`, the UI shows the peers. It never sends a cursor, and never closes your room or your document.
|
|
335
|
+
- On `unmount` the UI removes its observers from your document and leaves it as it is.
|
|
336
|
+
|
|
286
337
|
Develop and test the UI:
|
|
287
338
|
|
|
288
339
|
```sh
|
|
@@ -291,7 +342,7 @@ npx playwright-core install chromium
|
|
|
291
342
|
npm run test:e2e # Playwright against `wrangler dev`, with axe (WCAG 2.2 AA)
|
|
292
343
|
```
|
|
293
344
|
|
|
294
|
-
The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
|
|
345
|
+
`E2E_PORT` sets the port of the E2E server (default 18900). The E2E run creates a board, adds a shape and a note, moves them, reloads, and checks each view
|
|
295
346
|
with axe in the light and the dark theme. It saves screenshots to `test-results/screens/`. The committed set in `docs/ui/` shows the UI at this release. Run `UPDATE_SCREENSHOTS=1 npm run test:e2e` to refresh it.
|
|
296
347
|
|
|
297
348
|
A browser cannot set a header on a WebSocket, so the UI sends its token to the live room as a WebSocket sub-protocol (`Sec-WebSocket-Protocol`). A proxy that logs request headers can record it. The API itself takes the token only in an `Authorization` header, and the sub-protocol only on a WebSocket upgrade.
|
|
@@ -408,6 +459,7 @@ old author stays on its elements. Only new writes follow the strict rule.
|
|
|
408
459
|
| `mount(element, options)` | Render the board UI into an element. It returns `update(options)` and `unmount()`. See Mount the board in your app. |
|
|
409
460
|
| `BoardEditor(options)` | The same board UI as a React component, without a root. |
|
|
410
461
|
| `BoardEditorOptions` (type), `BoardPrincipal` (type), `MountedBoard` (type) | Types for the options, the principal, and the object that `mount` returns. |
|
|
462
|
+
| `HostBoard` (type) | The `host` option: `doc`, and optionally `presence`, `status`, and `onStatus`. |
|
|
411
463
|
| `CanvasClient` (type), `RoomOptions` (type) | The client type, as in `agentic-canvas/client`, and the live room's base URL and token for the `room` option. |
|
|
412
464
|
|
|
413
465
|
The stylesheet is a separate export, `agentic-canvas/ui.css`. Load `agentic-design/styles.css` too, for the tokens.
|
|
@@ -3,10 +3,10 @@ import {
|
|
|
3
3
|
MAX_LIVE_BYTES,
|
|
4
4
|
isBoardId,
|
|
5
5
|
isStoreId
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-Z6KLGAFP.js";
|
|
7
7
|
import {
|
|
8
8
|
CanvasError
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-MVWUIBGV.js";
|
|
10
10
|
|
|
11
11
|
// src/worker.ts
|
|
12
12
|
import { createStoreAuth, hasScope as hasScope2, parsePrincipal, parseRegion } from "agentic-identity";
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CanvasError,
|
|
3
|
+
PRINCIPAL_KINDS,
|
|
3
4
|
SHAPE_TYPES
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import {
|
|
6
|
-
PRINCIPAL_KINDS
|
|
7
|
-
} from "./chunk-JOBTIAFJ.js";
|
|
5
|
+
} from "./chunk-MVWUIBGV.js";
|
|
8
6
|
|
|
9
7
|
// src/version.ts
|
|
10
8
|
import { createRequire } from "module";
|
|
11
|
-
var VERSION = true ? "0.
|
|
9
|
+
var VERSION = true ? "0.12.0" : readPackageVersion();
|
|
12
10
|
|
|
13
11
|
// src/brand.ts
|
|
14
12
|
var BRAND = {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COLOR_PATTERN,
|
|
3
|
+
CanvasError,
|
|
4
|
+
IMAGE_PATTERN,
|
|
5
|
+
MAX_ELEMENTS,
|
|
6
|
+
SHAPE_TYPES
|
|
7
|
+
} from "./chunk-MVWUIBGV.js";
|
|
8
|
+
|
|
9
|
+
// src/core/flow.ts
|
|
10
|
+
import dagre from "@dagrejs/dagre";
|
|
11
|
+
import * as Y from "yjs";
|
|
12
|
+
var MAX_FLOW_NODES = 500;
|
|
13
|
+
var MAX_FLOW_EDGES = 2e3;
|
|
14
|
+
var MAX_FLOW_CHARS = 2e5;
|
|
15
|
+
var NODE = String.raw`([A-Za-z0-9_-]{1,48})(?:\[([^\]]*)\]|\(([^)]*)\)|\{([^}]*)\})?`;
|
|
16
|
+
var EDGE = new RegExp(String.raw`^${NODE}\s*->\s*${NODE}\s*(?::(.*))?$`);
|
|
17
|
+
var ALONE = new RegExp(String.raw`^${NODE}$`);
|
|
18
|
+
var SHAPES = ["rect", "ellipse", "diamond"];
|
|
19
|
+
function parseFlow(source) {
|
|
20
|
+
if (source.length > MAX_FLOW_CHARS) throw new CanvasError("LIMIT", `flow text is over ${MAX_FLOW_CHARS} characters`);
|
|
21
|
+
const graph = { direction: "TB", nodes: [], edges: [] };
|
|
22
|
+
const byId = /* @__PURE__ */ new Map();
|
|
23
|
+
const fail = (line, message) => {
|
|
24
|
+
throw new CanvasError("INVALID", `line ${line}: ${message}`);
|
|
25
|
+
};
|
|
26
|
+
const node = (line, m, at) => {
|
|
27
|
+
const id = m[at];
|
|
28
|
+
if (/^edge_\d+$/.test(id)) fail(line, `"${id}" is reserved for connectors. Pick another node id.`);
|
|
29
|
+
const brackets = m.slice(at + 1, at + 4);
|
|
30
|
+
const open = brackets.findIndex((b) => b !== void 0);
|
|
31
|
+
const shape = SHAPES[Math.max(open, 0)];
|
|
32
|
+
const text = (open < 0 ? "" : brackets[open].trim()) || id;
|
|
33
|
+
const known = byId.get(id);
|
|
34
|
+
if (!known) {
|
|
35
|
+
const created = { id, text, shape, explicit: open >= 0 };
|
|
36
|
+
byId.set(id, created);
|
|
37
|
+
graph.nodes.push(created);
|
|
38
|
+
} else if (open >= 0) {
|
|
39
|
+
if (known.explicit && (known.text !== text || known.shape !== shape)) fail(line, `"${id}" is defined twice with different text`);
|
|
40
|
+
Object.assign(known, { text, shape, explicit: true });
|
|
41
|
+
}
|
|
42
|
+
return id;
|
|
43
|
+
};
|
|
44
|
+
source.replace(/^/, "").split(/\r?\n/).forEach((raw, index) => {
|
|
45
|
+
const line = index + 1;
|
|
46
|
+
const text = raw.trim();
|
|
47
|
+
if (text === "" || text.startsWith("#") || text.startsWith("//")) return;
|
|
48
|
+
if (/[\u0000-\u0008\u000a-\u001f\u007f]/.test(text)) fail(line, "control characters are not allowed");
|
|
49
|
+
const direction = /^direction\s+(\S+)$/.exec(text);
|
|
50
|
+
if (direction) {
|
|
51
|
+
if (direction[1] !== "TB" && direction[1] !== "LR") fail(line, `direction must be TB or LR, got "${direction[1]}"`);
|
|
52
|
+
graph.direction = direction[1];
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const edge = EDGE.exec(text);
|
|
56
|
+
if (edge) {
|
|
57
|
+
const from = node(line, edge, 1);
|
|
58
|
+
const to = node(line, edge, 5);
|
|
59
|
+
graph.edges.push({ from, to, text: (edge[9] ?? "").trim() });
|
|
60
|
+
} else {
|
|
61
|
+
const alone = ALONE.exec(text);
|
|
62
|
+
if (!alone) fail(line, `cannot read "${text.slice(0, 40)}". Use "A -> B: label".`);
|
|
63
|
+
else node(line, alone, 1);
|
|
64
|
+
}
|
|
65
|
+
if (graph.nodes.length > MAX_FLOW_NODES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_NODES} nodes`);
|
|
66
|
+
if (graph.edges.length > MAX_FLOW_EDGES) throw new CanvasError("LIMIT", `a flow holds at most ${MAX_FLOW_EDGES} edges`);
|
|
67
|
+
});
|
|
68
|
+
if (graph.nodes.length === 0) throw new CanvasError("INVALID", "the flow has no nodes");
|
|
69
|
+
return { ...graph, nodes: graph.nodes.map(({ id, text, shape }) => ({ id, text, shape })) };
|
|
70
|
+
}
|
|
71
|
+
function sizeOf(n) {
|
|
72
|
+
const w = Math.min(360, Math.max(120, 24 + 8 * n.text.length));
|
|
73
|
+
return n.shape === "diamond" ? { w: Math.min(360, Math.round(w * 1.5)), h: 90 } : { w, h: 60 };
|
|
74
|
+
}
|
|
75
|
+
function layoutFlow(graph) {
|
|
76
|
+
const g = new dagre.graphlib.Graph({ multigraph: true });
|
|
77
|
+
g.setGraph({ rankdir: graph.direction, nodesep: 40, ranksep: 70, marginx: 0, marginy: 0 });
|
|
78
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
79
|
+
const index = new Map(graph.nodes.map((n, i) => [n.id, `n${i}`]));
|
|
80
|
+
const sizes = graph.nodes.map(sizeOf);
|
|
81
|
+
sizes.forEach(({ w, h }, i) => g.setNode(`n${i}`, { width: w, height: h }));
|
|
82
|
+
graph.edges.forEach((e, i) => g.setEdge(index.get(e.from), index.get(e.to), {}, String(i)));
|
|
83
|
+
dagre.layout(g);
|
|
84
|
+
const boxes = graph.nodes.map((n, i) => {
|
|
85
|
+
const { w, h } = sizes[i];
|
|
86
|
+
const c = g.node(`n${i}`);
|
|
87
|
+
return { id: n.id, x: Math.round(c.x - w / 2), y: Math.round(c.y - h / 2), w, h };
|
|
88
|
+
});
|
|
89
|
+
const minX = Math.min(...boxes.map((b) => b.x));
|
|
90
|
+
const minY = Math.min(...boxes.map((b) => b.y));
|
|
91
|
+
return boxes.map((b) => ({ ...b, x: b.x - minX, y: b.y - minY }));
|
|
92
|
+
}
|
|
93
|
+
function drawFlow(board, source, actor, options = {}) {
|
|
94
|
+
const prefix = options.idPrefix ?? `${crypto.randomUUID().slice(0, 8)}-`;
|
|
95
|
+
if (!/^[A-Za-z0-9_-]{0,16}$/.test(prefix)) throw new CanvasError("INVALID", "idPrefix must be up to 16 of A-Z, a-z, 0-9, _ and -");
|
|
96
|
+
const graph = parseFlow(source);
|
|
97
|
+
const placed = layoutFlow(graph);
|
|
98
|
+
const shapes = board.list().flatMap((e) => e.type === "connector" || e.type === "group" ? [] : [e]);
|
|
99
|
+
const origin = options.origin ?? {
|
|
100
|
+
x: shapes.length ? Math.min(...shapes.map((s) => s.x)) : 0,
|
|
101
|
+
y: shapes.length ? Math.max(...shapes.map((s) => s.y + s.h)) + 80 : 0
|
|
102
|
+
};
|
|
103
|
+
const work = board.clone();
|
|
104
|
+
const text = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
105
|
+
for (const p of placed) {
|
|
106
|
+
const n = text.get(p.id);
|
|
107
|
+
work.addShape({ id: prefix + p.id, type: n.shape, text: n.text, x: p.x + origin.x, y: p.y + origin.y, w: p.w, h: p.h }, actor);
|
|
108
|
+
}
|
|
109
|
+
const edges = graph.edges.map((e, i) => {
|
|
110
|
+
const id = `${prefix}edge_${i + 1}`;
|
|
111
|
+
work.addConnector({ id, from: { id: prefix + e.from }, to: { id: prefix + e.to }, text: e.text }, actor);
|
|
112
|
+
return id;
|
|
113
|
+
});
|
|
114
|
+
Y.applyUpdate(board.doc, Y.encodeStateAsUpdate(work.doc, Y.encodeStateVector(board.doc)));
|
|
115
|
+
return { nodes: placed.map((p) => prefix + p.id), edges };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/core/svg.ts
|
|
119
|
+
var LINE = 17;
|
|
120
|
+
var num = (n) => String(Math.round(n * 100) / 100);
|
|
121
|
+
var esc = (value) => (typeof value === "string" ? value : "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
122
|
+
var paint = (value, fallback) => typeof value === "string" && COLOR_PATTERN.test(value) ? value : fallback;
|
|
123
|
+
var str = (value) => typeof value === "string" ? value : "";
|
|
124
|
+
var within = (n, low, high) => typeof n === "number" && Number.isFinite(n) && n >= low && n <= high;
|
|
125
|
+
var LIMIT_COORD = 1e7;
|
|
126
|
+
var TYPES = /* @__PURE__ */ new Set([...SHAPE_TYPES, "connector", "group"]);
|
|
127
|
+
var sane = (s) => within(s.x, -LIMIT_COORD, LIMIT_COORD) && within(s.y, -LIMIT_COORD, LIMIT_COORD) && within(s.w, 1e-6, LIMIT_COORD) && within(s.h, 1e-6, LIMIT_COORD);
|
|
128
|
+
var isShape = (v) => "w" in v;
|
|
129
|
+
var center = (s) => ({ x: s.x + s.w / 2, y: s.y + s.h / 2 });
|
|
130
|
+
function edgePoint(s, toward) {
|
|
131
|
+
const c = center(s);
|
|
132
|
+
const dx = toward.x - c.x;
|
|
133
|
+
const dy = toward.y - c.y;
|
|
134
|
+
if (dx === 0 && dy === 0) return c;
|
|
135
|
+
const a = s.w / 2;
|
|
136
|
+
const b = s.h / 2;
|
|
137
|
+
const k = s.type === "ellipse" ? 1 / Math.hypot(dx / a, dy / b) : s.type === "diamond" ? 1 / (Math.abs(dx) / a + Math.abs(dy) / b) : Math.min(a / Math.abs(dx), b / Math.abs(dy));
|
|
138
|
+
return { x: c.x + dx * k, y: c.y + dy * k };
|
|
139
|
+
}
|
|
140
|
+
function wrap(text, width) {
|
|
141
|
+
const max = Math.max(4, Math.floor((width - 16) / 7.5));
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const paragraph of text.split(/\r?\n/)) {
|
|
144
|
+
let line = "";
|
|
145
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
146
|
+
const chars = [...word];
|
|
147
|
+
while (chars.length > max) {
|
|
148
|
+
if (line) out.push(line);
|
|
149
|
+
line = "";
|
|
150
|
+
out.push(chars.splice(0, max).join(""));
|
|
151
|
+
}
|
|
152
|
+
const rest = chars.join("");
|
|
153
|
+
if (line && [...line].length + 1 + chars.length > max) {
|
|
154
|
+
out.push(line);
|
|
155
|
+
line = rest;
|
|
156
|
+
} else line = line ? `${line} ${rest}` : rest;
|
|
157
|
+
}
|
|
158
|
+
out.push(line);
|
|
159
|
+
}
|
|
160
|
+
return out.slice(0, 40);
|
|
161
|
+
}
|
|
162
|
+
function label(text, cx, cy, width, extra = "") {
|
|
163
|
+
if (text === "") return "";
|
|
164
|
+
const lines = wrap(text, width);
|
|
165
|
+
const top = cy - (lines.length - 1) * LINE / 2;
|
|
166
|
+
const spans = lines.map((l, i) => `<tspan x="${num(cx)}" y="${num(top + i * LINE)}">${esc(l)}</tspan>`);
|
|
167
|
+
return `<text text-anchor="middle" dominant-baseline="central"${extra}>${spans.join("")}</text>`;
|
|
168
|
+
}
|
|
169
|
+
function shapeBody(s) {
|
|
170
|
+
const { x, y, w, h } = s;
|
|
171
|
+
const stroke = paint(s.stroke, "#1f2937");
|
|
172
|
+
const look = (fill, more = "") => `fill="${paint(s.fill, fill)}" stroke="${stroke}" stroke-width="2"${more}`;
|
|
173
|
+
const text = label(str(s.text), x + w / 2, y + h / 2, w);
|
|
174
|
+
switch (s.type) {
|
|
175
|
+
case "ellipse":
|
|
176
|
+
return `<ellipse cx="${num(x + w / 2)}" cy="${num(y + h / 2)}" rx="${num(w / 2)}" ry="${num(h / 2)}" ${look("#ffffff")}/>${text}`;
|
|
177
|
+
case "diamond": {
|
|
178
|
+
const pts = [[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]];
|
|
179
|
+
return `<polygon points="${pts.map(([px, py]) => `${num(px)},${num(py)}`).join(" ")}" ${look("#ffffff")}/>${text}`;
|
|
180
|
+
}
|
|
181
|
+
case "text":
|
|
182
|
+
return text;
|
|
183
|
+
case "frame":
|
|
184
|
+
return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("none", ' stroke-dasharray="8 6"')}/>${label(str(s.text), x + w / 2, y - 12, w)}`;
|
|
185
|
+
case "image":
|
|
186
|
+
if (typeof s.src === "string" && s.src.startsWith("data:") && IMAGE_PATTERN.test(s.src)) {
|
|
187
|
+
return `<image href="${s.src}" x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" preserveAspectRatio="xMidYMid meet"/>`;
|
|
188
|
+
}
|
|
189
|
+
return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" ${look("#f3f4f6", ' stroke-dasharray="4 4"')}/>${label("image", x + w / 2, y + h / 2, w)}`;
|
|
190
|
+
default:
|
|
191
|
+
return `<rect x="${num(x)}" y="${num(y)}" width="${num(w)}" height="${num(h)}" rx="${s.type === "sticky" ? 2 : 6}" ${look(s.type === "sticky" ? "#fde68a" : "#ffffff")}/>${text}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
var endKey = (e) => "id" in e ? e.id : `${e.x},${e.y}`;
|
|
195
|
+
var wellFormed = (e) => typeof e === "object" && e !== null && (typeof e.id === "string" || within(e.x, -LIMIT_COORD, LIMIT_COORD) && within(e.y, -LIMIT_COORD, LIMIT_COORD));
|
|
196
|
+
function connectorBody(c, shapes, lane) {
|
|
197
|
+
const resolve = (e) => "id" in e ? shapes.get(e.id) : e;
|
|
198
|
+
const a = resolve(c.from);
|
|
199
|
+
const b = resolve(c.to);
|
|
200
|
+
if (!a || !b) return "";
|
|
201
|
+
let d;
|
|
202
|
+
let at;
|
|
203
|
+
if (isShape(a) && a === b) {
|
|
204
|
+
const x0 = a.x + a.w * 0.35;
|
|
205
|
+
const x1 = a.x + a.w * 0.65;
|
|
206
|
+
const lift = 45 + 32 * lane.index;
|
|
207
|
+
d = `M${num(x0)} ${num(a.y)}C${num(x0)} ${num(a.y - lift)} ${num(x1)} ${num(a.y - lift)} ${num(x1)} ${num(a.y)}`;
|
|
208
|
+
at = { x: a.x + a.w / 2, y: a.y - lift * 0.75 - 6 };
|
|
209
|
+
} else {
|
|
210
|
+
const ca = isShape(a) ? center(a) : a;
|
|
211
|
+
const cb = isShape(b) ? center(b) : b;
|
|
212
|
+
const flip = endKey(c.from) > endKey(c.to);
|
|
213
|
+
const dx = (flip ? ca.x - cb.x : cb.x - ca.x) || 0;
|
|
214
|
+
const dy = (flip ? ca.y - cb.y : cb.y - ca.y) || 0;
|
|
215
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
216
|
+
const offset = (lane.index - (lane.count - 1) / 2) * 44;
|
|
217
|
+
const mid = { x: (ca.x + cb.x) / 2, y: (ca.y + cb.y) / 2 };
|
|
218
|
+
const ctrl = { x: mid.x - dy / len * 2 * offset, y: mid.y + dx / len * 2 * offset };
|
|
219
|
+
const p = isShape(a) ? edgePoint(a, ctrl) : a;
|
|
220
|
+
const q = isShape(b) ? edgePoint(b, ctrl) : b;
|
|
221
|
+
d = offset === 0 ? `M${num(p.x)} ${num(p.y)}L${num(q.x)} ${num(q.y)}` : `M${num(p.x)} ${num(p.y)}Q${num(ctrl.x)} ${num(ctrl.y)} ${num(q.x)} ${num(q.y)}`;
|
|
222
|
+
at = offset === 0 ? { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 } : { x: (p.x + q.x) / 4 + ctrl.x / 2, y: (p.y + q.y) / 4 + ctrl.y / 2 };
|
|
223
|
+
}
|
|
224
|
+
const marks = `${c.startArrow ? ' marker-start="url(#arrow)"' : ""}${c.endArrow ? ' marker-end="url(#arrow)"' : ""}`;
|
|
225
|
+
const halo = ' stroke="#ffffff" stroke-width="4" paint-order="stroke"';
|
|
226
|
+
return `<path d="${d}" fill="none" stroke="${paint(c.stroke, "#1f2937")}" stroke-width="2"${marks}/>${label(str(c.text), at.x, at.y, 160, halo)}`;
|
|
227
|
+
}
|
|
228
|
+
var drawable = (el) => TYPES.has(el.type) && (el.type === "connector" ? wellFormed(el.from) && wellFormed(el.to) : el.type === "group" || sane(el));
|
|
229
|
+
function toSvg(board) {
|
|
230
|
+
const listed = board.list();
|
|
231
|
+
if (listed.length > MAX_ELEMENTS) throw new CanvasError("LIMIT", `a board holds at most ${MAX_ELEMENTS} elements`);
|
|
232
|
+
const all = listed.filter((el) => drawable(el));
|
|
233
|
+
const shapes = /* @__PURE__ */ new Map();
|
|
234
|
+
const box = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
|
235
|
+
const grow = (x, y) => {
|
|
236
|
+
box.minX = Math.min(box.minX, x);
|
|
237
|
+
box.minY = Math.min(box.minY, y);
|
|
238
|
+
box.maxX = Math.max(box.maxX, x);
|
|
239
|
+
box.maxY = Math.max(box.maxY, y);
|
|
240
|
+
};
|
|
241
|
+
for (const el of all) {
|
|
242
|
+
if (el.type === "connector") {
|
|
243
|
+
for (const e of [el.from, el.to]) if (!("id" in e)) grow(e.x, e.y);
|
|
244
|
+
} else if (el.type !== "group") {
|
|
245
|
+
shapes.set(el.id, el);
|
|
246
|
+
grow(el.x, el.y);
|
|
247
|
+
grow(el.x + el.w, el.y + el.h);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const pad = 80;
|
|
251
|
+
const any = box.minX !== Infinity;
|
|
252
|
+
const minX = any ? box.minX - pad : 0;
|
|
253
|
+
const minY = any ? box.minY - pad : 0;
|
|
254
|
+
const width = any ? Math.ceil(box.maxX + pad - minX) : 240;
|
|
255
|
+
const height = any ? Math.ceil(box.maxY + pad - minY) : 120;
|
|
256
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
257
|
+
const groups = /* @__PURE__ */ new Map();
|
|
258
|
+
for (const el of all) {
|
|
259
|
+
if (el.type !== "connector") continue;
|
|
260
|
+
const key = [endKey(el.from), endKey(el.to)].sort().join("|");
|
|
261
|
+
groups.set(key, [...groups.get(key) ?? [], el.id]);
|
|
262
|
+
}
|
|
263
|
+
for (const ids of groups.values()) ids.forEach((id, index) => lanes.set(id, { index, count: ids.length }));
|
|
264
|
+
const body = all.map((el) => {
|
|
265
|
+
if (el.type === "group") return "";
|
|
266
|
+
const inner = el.type === "connector" ? connectorBody(el, shapes, lanes.get(el.id)) : shapeBody(el);
|
|
267
|
+
if (inner === "") return "";
|
|
268
|
+
const kind = el.author.kind;
|
|
269
|
+
const mark = `data-id="${esc(el.id)}" data-type="${el.type}" data-author-kind="${kind}" data-machine-made="${kind !== "human"}"`;
|
|
270
|
+
return `<g ${mark}>${inner}</g>`;
|
|
271
|
+
});
|
|
272
|
+
const title = esc(board.title) || "Whiteboard";
|
|
273
|
+
return [
|
|
274
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${num(minX)} ${num(minY)} ${width} ${height}" font-family="sans-serif" font-size="14" fill="#111827" role="img" aria-label="${title}">`,
|
|
275
|
+
`<title>${title}</title>`,
|
|
276
|
+
'<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#1f2937"/></marker></defs>',
|
|
277
|
+
`<rect x="${num(minX)}" y="${num(minY)}" width="${width}" height="${height}" fill="#ffffff"/>`,
|
|
278
|
+
...body.filter(Boolean),
|
|
279
|
+
"</svg>"
|
|
280
|
+
].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export {
|
|
284
|
+
MAX_FLOW_NODES,
|
|
285
|
+
MAX_FLOW_EDGES,
|
|
286
|
+
parseFlow,
|
|
287
|
+
layoutFlow,
|
|
288
|
+
drawFlow,
|
|
289
|
+
toSvg
|
|
290
|
+
};
|