@1agh/maude 1.4.6 → 1.5.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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -1,178 +1,107 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// annotate.mjs — the AI annotation WRITE verb (
|
|
2
|
+
// annotate.mjs — the AI annotation WRITE verb (DDR-242 AD9).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// An agent creates, updates, moves, re-parents, re-orders, groups and deletes
|
|
5
|
+
// whiteboard elements through a typed ops vocabulary — or lays out a whole
|
|
6
|
+
// flow diagram / board template. Every request becomes the SAME element ops
|
|
7
|
+
// the canvas sends (`put | patch | delete`, annotations/ops.ts), built by the
|
|
8
|
+
// registry-driven engine in annotations/ai-write.ts: field names, text slots,
|
|
9
|
+
// bindability and geometry all come from the element registry, so a new
|
|
10
|
+
// element type needs no change here.
|
|
11
11
|
//
|
|
12
|
-
//
|
|
12
|
+
// Write path: with a live dev-server (`<designRoot>/_server.json`, loopback
|
|
13
|
+
// only) the ops go through `POST /_api/annotations/ops` — merged under the one
|
|
14
|
+
// merge rule and broadcast to every open canvas. Without one, the resulting
|
|
15
|
+
// board is written directly (canonical, atomic tmp + rename). A board file that
|
|
16
|
+
// exists but can't be read is never written over.
|
|
13
17
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// sanitized). Either way the result is LWW over the whole SVG — the same
|
|
19
|
-
// trade-off the canvas itself has (documented; agents should read-then-write).
|
|
20
|
-
//
|
|
21
|
-
// Every created stroke is stamped `data-author="ai"` (provenance) and the
|
|
22
|
-
// verb prints a ref → id map so a follow-up call can target what it made.
|
|
23
|
-
// AI writes never enter any user's local undo stack (they arrive over the
|
|
24
|
-
// sync channel, not through the canvas's commitStrokes).
|
|
18
|
+
// Created elements are stamped `author: {kind: 'ai'}`; AI writes never enter a
|
|
19
|
+
// user's undo stack (they arrive over the sync channel, DDR-100 §3). Updates
|
|
20
|
+
// are field patches that carry the values the agent read, so they merge with a
|
|
21
|
+
// concurrent human edit instead of replacing the board.
|
|
25
22
|
//
|
|
26
23
|
// Reached via `maude design annotate` (DDR-062), never a raw bin path.
|
|
27
|
-
//
|
|
28
|
-
// Usage:
|
|
29
|
-
// maude design annotate <rel-path> [--ops <file|->] [--flow <file|->]
|
|
30
|
-
// [--near <artboardId>] [--canvas-state <path>]
|
|
31
|
-
// [--root <repo>] [--dry-run]
|
|
32
|
-
//
|
|
33
|
-
// Ops JSON (--ops / stdin):
|
|
34
|
-
// { "ops": [
|
|
35
|
-
// { "op": "create", "type": "sticky", "ref": "@a", "text": "…",
|
|
36
|
-
// "x"?, "y"?, "w"?, "h"?, "color"? },
|
|
37
|
-
// { "op": "create", "type": "text", "text": "…", "x"?, "y"?, "fontSize"? },
|
|
38
|
-
// { "op": "create", "type": "shape", "shape": "rect|rounded|ellipse|diamond|triangle|triangle-down",
|
|
39
|
-
// "ref"?, "label"?, "x"?, "y"?, "w"?, "h"?, "color"?, "fill"? },
|
|
40
|
-
// { "op": "create", "type": "arrow", "x1", "y1", "x2", "y2" },
|
|
41
|
-
// { "op": "connect", "from": "<id|@ref>", "to": "<id|@ref>", "label"? },
|
|
42
|
-
// { "op": "group", "ids": ["<id|@ref>", …] },
|
|
43
|
-
// { "op": "delete", "id": "<id>" }
|
|
44
|
-
// ] }
|
|
45
|
-
//
|
|
46
|
-
// Flow JSON (--flow): { "nodes": [{ "id", "label", "shape"? }],
|
|
47
|
-
// "edges": [{ "from", "to", "label"? }] }
|
|
48
|
-
// Nodes are auto-laid-out left→right by dependency layer and connected with
|
|
49
|
-
// BOUND arrows, so `read-annotations --graph` reads the diagram back as the
|
|
50
|
-
// same graph.
|
|
51
24
|
|
|
52
|
-
import { existsSync, readFileSync
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
53
26
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
54
27
|
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
DEFAULT_SECTION_COLOR,
|
|
60
|
-
DEFAULT_STICKY_COLOR,
|
|
61
|
-
gid,
|
|
62
|
-
rid,
|
|
63
|
-
SECTION_DEFAULT_H,
|
|
64
|
-
SECTION_DEFAULT_W,
|
|
65
|
-
STICKY_CORNER_RADIUS,
|
|
66
|
-
STICKY_DEFAULT_H,
|
|
67
|
-
STICKY_DEFAULT_W,
|
|
68
|
-
sanitizeAnnotationSvg,
|
|
69
|
-
strokeToSvgEl,
|
|
70
|
-
svgToStrokes,
|
|
71
|
-
} from '../annotations-model.ts';
|
|
28
|
+
import { AiBatch, AiOpError, CREATE_SIZE, describeTypes } from '../annotations/ai-write.ts';
|
|
29
|
+
import { readBoardFile, writeBoardFileAtomic } from '../annotations/board-io.ts';
|
|
30
|
+
import { MAX_BOARD_BYTES } from '../annotations/constants.ts';
|
|
31
|
+
import { serializeBoard } from '../annotations/schema.ts';
|
|
72
32
|
import {
|
|
73
33
|
fileSlug,
|
|
74
34
|
findElementById,
|
|
75
35
|
loadArtboards,
|
|
76
36
|
loadElements,
|
|
77
|
-
parseAnnotations,
|
|
78
37
|
resolveDesignRoot,
|
|
79
38
|
} from './read-annotations.mjs';
|
|
80
39
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
const MAX_ANNOTATIONS_BYTES = 1024 * 1024;
|
|
84
|
-
|
|
85
|
-
const SVG_HEADER = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
|
|
86
|
-
|
|
87
|
-
// Flow-node visual defaults — the blue ink + paired tint read on light AND
|
|
88
|
-
// dark canvases (the verb cannot know the viewer's theme).
|
|
40
|
+
// Flow-node look — the blue ink + paired tint read on light AND dark canvases
|
|
41
|
+
// (the verb cannot know the viewer's theme).
|
|
89
42
|
const NODE_INK = '#3b82f6';
|
|
90
43
|
const NODE_FILL = '#e0ebfd';
|
|
91
|
-
const TEXT_INK = '#1a1a1a';
|
|
92
44
|
const NODE_W = 180;
|
|
93
45
|
const NODE_H = 80;
|
|
94
46
|
const FLOW_GAP_X = 100;
|
|
95
47
|
const FLOW_GAP_Y = 60;
|
|
96
48
|
|
|
97
|
-
const HELP = `annotate
|
|
49
|
+
const HELP = `annotate — the AI whiteboard write verb (DDR-242, via \`maude design annotate\`)
|
|
98
50
|
|
|
99
51
|
Usage:
|
|
100
|
-
maude design annotate <rel-path> [--ops <file|->
|
|
101
|
-
[--board <file|->]
|
|
52
|
+
maude design annotate <rel-path> [--ops <file|-> | --flow <file|-> | --board <file|->]
|
|
102
53
|
[--near <artboardId>] [--in <artboardId>]
|
|
103
54
|
[--pin <cdId|selector>] [--no-pointer]
|
|
104
55
|
[--canvas-state <path>] [--rects <path>]
|
|
105
56
|
[--root <repo>] [--dry-run]
|
|
106
57
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
--
|
|
144
|
-
--
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
--
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
Ops vocabulary: create (sticky | text | shape | arrow) · connect (bound arrow
|
|
156
|
-
between hosts, by id or @ref) · group · delete · move · set-text · set-color.
|
|
157
|
-
Created strokes carry data-author="ai" and fresh ids; the verb prints
|
|
158
|
-
{ ok, via, file, refs }.
|
|
159
|
-
|
|
160
|
-
move/set-text/set-color (id-preserving, feature-whiteboard-ai-toolkit):
|
|
161
|
-
{ "op": "move", "id": "<id|@ref>", "x": N, "y": N }
|
|
162
|
-
{ "op": "set-text", "id": "<id|@ref>", "text": "…" } (or a section's label)
|
|
163
|
-
{ "op": "set-color", "id": "<id|@ref>", "color": "#…" }
|
|
164
|
-
Every other attribute on the stroke (fontSize, bold/italic/dashed, rotation,
|
|
165
|
-
groupIds, cornerRadius, …) is preserved byte-for-byte — the target is parsed
|
|
166
|
-
through the CANONICAL parser and re-serialized through the CANONICAL
|
|
167
|
-
serializer, not reconstructed from defaults. Works on a stroke created earlier
|
|
168
|
-
in the SAME batch (by @ref) or an existing one from the file. Not every tool
|
|
169
|
-
supports every op (arrows/pen have no single position; anchored text has no
|
|
170
|
-
independent position; shapes have no single color/text field) — unsupported
|
|
171
|
-
combinations fail loud (exit 2) rather than silently no-op or mis-write.
|
|
172
|
-
DDR-100 deliberately omitted "update" for LWW honesty — these stay id-
|
|
173
|
-
preserving but are still whole-file last-write-wins like every other op.
|
|
174
|
-
|
|
175
|
-
The write is last-write-wins over the whole SVG — read before you write.`;
|
|
58
|
+
Ops JSON ({ "ops": [ … ] } or a bare array; "-" or omitted = stdin). Coordinates
|
|
59
|
+
are WORLD coordinates — the same ones read-annotations prints. Targets are ids
|
|
60
|
+
or "@refs" minted earlier in the batch.
|
|
61
|
+
|
|
62
|
+
{ "op": "create", "type": <type>, "ref"?: "@a", "x"?, "y"?, "w"?, "h"?,
|
|
63
|
+
"parent"?: <section|@ref|null>, …fields }
|
|
64
|
+
Without x/y the element is auto-placed (see placement below). Without
|
|
65
|
+
"parent" it joins the section its centre lands in; null = top level.
|
|
66
|
+
"text" always means the type's text (sticky/text body, shape label,
|
|
67
|
+
section title). Shapes: "shape": rounded|rect|ellipse|diamond|triangle|
|
|
68
|
+
triangle-down (default rounded, blue node look). Arrows: "from"/"to"
|
|
69
|
+
(bound, follow their hosts) or x1/y1/x2/y2 (free).
|
|
70
|
+
{ "op": "connect", "from": <id>, "to": <id>, "label"? } bound arrow
|
|
71
|
+
{ "op": "update", "id": <id>, …fields } patch fields; null resets
|
|
72
|
+
{ "op": "move", "id": <id>, "x", "y" } | { …, "dx", "dy" } then joins the section
|
|
73
|
+
it lands in ("keepParent": true to stay); a bound arrow end follows its host
|
|
74
|
+
{ "op": "reparent", "id": <id>, "parent": <section|null> } keeps its world position
|
|
75
|
+
{ "op": "reorder", "id": <id>, "to": front|back|forward|backward }
|
|
76
|
+
| { …, "before"|"after": <sibling id> }
|
|
77
|
+
{ "op": "group", "ids": [<id>, …] }
|
|
78
|
+
{ "op": "delete", "id": <id> } a section's children move up; bound arrows keep their end
|
|
79
|
+
{ "op": "set-text", "id", "text" } · { "op": "set-color", "id", "color" } (= update)
|
|
80
|
+
|
|
81
|
+
Types and their fields (from the element registry):
|
|
82
|
+
${describeTypes()}
|
|
83
|
+
|
|
84
|
+
--flow <file|-> { nodes: [{ id, label, shape? }], edges: [{ from, to, label? }] } —
|
|
85
|
+
auto-laid-out left→right, wired by BOUND arrows.
|
|
86
|
+
--board <file|-> { title?, layout?, groups?, nodes?, edges?, connections? } — a whole
|
|
87
|
+
template: layout "columns" (default; "grid"/"lanes" alias it) = one
|
|
88
|
+
titled section per group with its cards (string or {text,color?})
|
|
89
|
+
inside; "radial" = a centre topic with cards ringed around it;
|
|
90
|
+
"flow" = nodes/edges as in --flow. connections: [{from,to,label?}]
|
|
91
|
+
between minted refs (@sec<i>, @sec<i>card<j>, @center, @idea<i>).
|
|
92
|
+
|
|
93
|
+
Placement (creates without x/y, and whole --flow/--board layouts):
|
|
94
|
+
--near <artboard> beside the artboard (right of it)
|
|
95
|
+
--in <artboard> inside the artboard (top-left + 40px). Unknown id = error.
|
|
96
|
+
--pin <cdId|sel> beside a DOM element from a --rects manifest; a created
|
|
97
|
+
sticky/text also gets a pointer arrow to it (--no-pointer or
|
|
98
|
+
"pointer": false to skip). Unknown element = error.
|
|
99
|
+
Any create op may carry its own "in"/"near"/"pin" to override for that op.
|
|
100
|
+
--canvas-state <p> artboard rects; --rects <p> a \`maude design canvas-rects\` manifest.
|
|
101
|
+
|
|
102
|
+
Output: { ok, via: "server"|"file", file, created, updated, deleted, refs }.
|
|
103
|
+
--dry-run prints { dryRun: true, ops } (the element ops) and writes nothing.
|
|
104
|
+
Invalid requests fail loud (exit 2) and write nothing.`;
|
|
176
105
|
|
|
177
106
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
178
107
|
// Argv
|
|
@@ -208,22 +137,16 @@ function parseArgv(argv) {
|
|
|
208
137
|
const a = argv[i];
|
|
209
138
|
const eq = a.indexOf('=');
|
|
210
139
|
const flagKey = eq > 0 ? a.slice(0, eq) : a;
|
|
211
|
-
if (a === '--help' || a === '-h')
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
} else if (flagKey in VALUE_FLAGS) {
|
|
218
|
-
if (eq > 0) {
|
|
219
|
-
out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
|
|
220
|
-
} else {
|
|
140
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
141
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
142
|
+
else if (a === '--no-pointer') out.pointer = false;
|
|
143
|
+
else if (flagKey in VALUE_FLAGS) {
|
|
144
|
+
if (eq > 0) out[VALUE_FLAGS[flagKey]] = a.slice(eq + 1);
|
|
145
|
+
else {
|
|
221
146
|
i += 1;
|
|
222
147
|
out[VALUE_FLAGS[flagKey]] = argv[i];
|
|
223
148
|
}
|
|
224
|
-
} else
|
|
225
|
-
out.positional.push(a);
|
|
226
|
-
}
|
|
149
|
+
} else out.positional.push(a);
|
|
227
150
|
}
|
|
228
151
|
return out;
|
|
229
152
|
}
|
|
@@ -246,14 +169,19 @@ function readInput(spec) {
|
|
|
246
169
|
return readFileSync(p, 'utf8');
|
|
247
170
|
}
|
|
248
171
|
|
|
249
|
-
function
|
|
250
|
-
|
|
172
|
+
function parseJsonInput(raw, what) {
|
|
173
|
+
if (!raw?.trim()) fail(`${what}: empty input`, 2);
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(raw);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
fail(`${what}: invalid JSON — ${err?.message ?? err}`, 2);
|
|
178
|
+
}
|
|
251
179
|
}
|
|
252
180
|
|
|
253
181
|
/**
|
|
254
182
|
* Wave H F2 — the annotate egress allowlist. The dev-server is loopback-only
|
|
255
|
-
* (DDR-054); a
|
|
256
|
-
* a poisoned `_server.json.url` can't exfiltrate the
|
|
183
|
+
* (DDR-054); a POST target that is not a loopback http(s) origin is refused so
|
|
184
|
+
* a poisoned `_server.json.url` can't exfiltrate the board off-box.
|
|
257
185
|
*/
|
|
258
186
|
function isLoopbackHttpUrl(base) {
|
|
259
187
|
let u;
|
|
@@ -268,56 +196,35 @@ function isLoopbackHttpUrl(base) {
|
|
|
268
196
|
}
|
|
269
197
|
|
|
270
198
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
/** Fabricate a bbox-shaped rect Stroke for an EXISTING annotation so the
|
|
274
|
-
* binding helpers (which only read strokeBBox) work against parsed data. */
|
|
275
|
-
function fakeHost(ann) {
|
|
276
|
-
return {
|
|
277
|
-
id: ann.id,
|
|
278
|
-
tool: 'rect',
|
|
279
|
-
color: '#000',
|
|
280
|
-
width: 1,
|
|
281
|
-
x: ann.x ?? 0,
|
|
282
|
-
y: ann.y ?? 0,
|
|
283
|
-
w: ann.w ?? 0,
|
|
284
|
-
h: ann.h ?? 0,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const BINDABLE_PARSED = new Set(['rect', 'ellipse', 'polygon', 'sticky', 'image']);
|
|
289
|
-
|
|
290
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
-
// Op application
|
|
199
|
+
// Placement — where a create without x/y lands.
|
|
292
200
|
|
|
293
|
-
function
|
|
294
|
-
//
|
|
295
|
-
// artboard) >
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// (pre-existing behavior: an unmatched id silently falls through).
|
|
201
|
+
function buildPlacement(batch, artboards, elements, placement) {
|
|
202
|
+
// Priority: --pin (beside the element) > --in (inside the artboard) > --near
|
|
203
|
+
// (beside the artboard) > right of the existing board > a sane top-left.
|
|
204
|
+
// Unknown --in/--pin targets are a hard error (never a silent mis-place);
|
|
205
|
+
// --near stays lenient (an unmatched id falls through).
|
|
299
206
|
let origin = { x: 100, y: 100 };
|
|
300
207
|
let pinnedEl = null;
|
|
301
|
-
if (placement
|
|
208
|
+
if (placement.in) {
|
|
302
209
|
const board = artboards.find((r) => r.id === placement.in);
|
|
303
210
|
if (!board) fail(`--in: unknown artboard "${placement.in}"`, 2);
|
|
304
211
|
origin = { x: board.x + 40, y: board.y + 40 };
|
|
305
|
-
} else if (placement
|
|
212
|
+
} else if (placement.pin) {
|
|
306
213
|
const el = findElementById(elements, placement.pin);
|
|
307
214
|
if (!el) fail(`--pin: element "${placement.pin}" not found in the --rects manifest`, 2);
|
|
308
215
|
pinnedEl = el;
|
|
309
216
|
origin = { x: el.x + el.w + 40, y: el.y };
|
|
310
217
|
} else {
|
|
311
|
-
const nearBoard = placement
|
|
312
|
-
if (nearBoard) {
|
|
313
|
-
|
|
314
|
-
} else if (existing.length) {
|
|
218
|
+
const nearBoard = placement.near ? artboards.find((r) => r.id === placement.near) : null;
|
|
219
|
+
if (nearBoard) origin = { x: nearBoard.x + nearBoard.w + 80, y: nearBoard.y };
|
|
220
|
+
else {
|
|
315
221
|
let maxX = Number.NEGATIVE_INFINITY;
|
|
316
222
|
let minY = Number.POSITIVE_INFINITY;
|
|
317
|
-
for (const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
223
|
+
for (const el of batch.scene.childrenOf(null)) {
|
|
224
|
+
const b = batch.worldBox(el.id);
|
|
225
|
+
if (!b) continue;
|
|
226
|
+
maxX = Math.max(maxX, b.x + b.w);
|
|
227
|
+
minY = Math.min(minY, b.y);
|
|
321
228
|
}
|
|
322
229
|
if (Number.isFinite(maxX)) origin = { x: maxX + 80, y: Math.max(0, minY) };
|
|
323
230
|
}
|
|
@@ -325,491 +232,112 @@ function buildContext(existing, artboards, elements, placement) {
|
|
|
325
232
|
return {
|
|
326
233
|
origin,
|
|
327
234
|
cursor: { ...origin },
|
|
328
|
-
pinnedEl,
|
|
329
|
-
pointer: placement
|
|
330
|
-
artboards,
|
|
331
|
-
elements,
|
|
332
|
-
refs: new Map(), // '@ref' → minted id
|
|
333
|
-
newById: new Map(), // id → new Stroke
|
|
334
|
-
created: [], // Stroke[] in creation order
|
|
335
|
-
groupExisting: [], // { id, groupId } injections into existing elements
|
|
336
|
-
deletes: [], // existing ids to remove
|
|
337
|
-
existingById: new Map(existing.map((a) => [a.id, a])),
|
|
338
|
-
replaces: new Map(), // id -> patched full Stroke (move/set-text/set-color)
|
|
339
|
-
rawSvg: '', // set by main() before applyOps — the pre-batch SVG, for ensureFullStrokes
|
|
340
|
-
fullStrokes: null, // lazy id -> full Stroke cache, populated on first move/set-text/set-color
|
|
235
|
+
pinnedEl,
|
|
236
|
+
pointer: placement.pointer !== false,
|
|
237
|
+
artboards,
|
|
238
|
+
elements,
|
|
341
239
|
};
|
|
342
240
|
}
|
|
343
241
|
|
|
344
|
-
/**
|
|
345
|
-
|
|
346
|
-
* the CLI flags (buildContext, above), scoped to ONE op. Returns null when the
|
|
347
|
-
* op carries none of the three (the caller then falls back to ctx.cursor).
|
|
348
|
-
*/
|
|
349
|
-
function resolveOpPlacement(ctx, op) {
|
|
242
|
+
/** A per-op "in"/"near"/"pin" override — the same rules as the flags, for one op. */
|
|
243
|
+
function opPlacement(pl, op) {
|
|
350
244
|
if (op.pin) {
|
|
351
|
-
const el = findElementById(
|
|
245
|
+
const el = findElementById(pl.elements, op.pin);
|
|
352
246
|
if (!el) fail(`op.pin: element "${op.pin}" not found in the --rects manifest`, 2);
|
|
353
247
|
return { x: el.x + el.w + 40, y: el.y, pinnedEl: el };
|
|
354
248
|
}
|
|
355
249
|
if (op.in) {
|
|
356
|
-
const board =
|
|
250
|
+
const board = pl.artboards.find((r) => r.id === op.in);
|
|
357
251
|
if (!board) fail(`op.in: unknown artboard "${op.in}"`, 2);
|
|
358
252
|
return { x: board.x + 40, y: board.y + 40, pinnedEl: null };
|
|
359
253
|
}
|
|
360
254
|
if (op.near) {
|
|
361
|
-
const board =
|
|
255
|
+
const board = pl.artboards.find((r) => r.id === op.near);
|
|
362
256
|
if (!board) fail(`op.near: unknown artboard "${op.near}"`, 2);
|
|
363
257
|
return { x: board.x + board.w + 80, y: board.y, pinnedEl: null };
|
|
364
258
|
}
|
|
365
259
|
return null;
|
|
366
260
|
}
|
|
367
261
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
if (!id) return null;
|
|
372
|
-
const created = ctx.newById.get(id);
|
|
373
|
-
if (created) return { id, stroke: created, isNew: true };
|
|
374
|
-
const existing = ctx.existingById.get(id);
|
|
375
|
-
if (existing && BINDABLE_PARSED.has(existing.tool)) {
|
|
376
|
-
return { id, stroke: fakeHost(existing), isNew: false };
|
|
377
|
-
}
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function mint(ctx, ref) {
|
|
382
|
-
const id = rid();
|
|
383
|
-
if (typeof ref === 'string' && ref.startsWith('@')) ctx.refs.set(ref, id);
|
|
384
|
-
return id;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Resolves an op's placement, returning { x, y, pinnedEl }. An op-level
|
|
389
|
-
* in/near/pin override takes priority over the batch's ctx.cursor; explicit
|
|
390
|
-
* op.x/op.y always win over either. `pinnedEl` (an op-level pin, or the
|
|
391
|
-
* GLOBAL --pin when the op has no override of its own) is what createSticky/
|
|
392
|
-
* createText use to attach a pointer arrow.
|
|
393
|
-
*/
|
|
394
|
-
function autoPlace(ctx, w, op) {
|
|
395
|
-
const override = op.in || op.near || op.pin ? resolveOpPlacement(ctx, op) : null;
|
|
262
|
+
/** Resolve an op's x/y: explicit wins, then a per-op override, then the batch cursor. */
|
|
263
|
+
function place(pl, op, w) {
|
|
264
|
+
const override = op.in || op.near || op.pin ? opPlacement(pl, op) : null;
|
|
396
265
|
if (override) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const x = Number.isFinite(op.x) ? op.x : ctx.cursor.x;
|
|
402
|
-
const y = Number.isFinite(op.y) ? op.y : ctx.cursor.y;
|
|
403
|
-
if (!Number.isFinite(op.x)) ctx.cursor.x = x + w + 40;
|
|
404
|
-
return { x, y, pinnedEl: ctx.pinnedEl };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function pushCreated(ctx, stroke) {
|
|
408
|
-
ctx.created.push(stroke);
|
|
409
|
-
ctx.newById.set(stroke.id, stroke);
|
|
410
|
-
return stroke;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* A visual pointer from a note to a DOM element (--pin). Not a magnetic BIND
|
|
415
|
-
* (annotate.mjs:18 — binds only host on annotation strokes, DDR-100) — a DOM
|
|
416
|
-
* element isn't part of this SVG, so the arrow is a one-time snapshot
|
|
417
|
-
* computed via the SAME facing-anchor math createConnect uses, against a
|
|
418
|
-
* fabricated rect host built from the element's manifest rect.
|
|
419
|
-
*/
|
|
420
|
-
function pointerArrowTo(ctx, fromStroke, el) {
|
|
421
|
-
const elHost = { id: `_el_${el.cdId ?? 'x'}`, tool: 'rect', x: el.x, y: el.y, w: el.w, h: el.h };
|
|
422
|
-
const fromCenter = centerOf(fromStroke);
|
|
423
|
-
const toCenter = [el.x + el.w / 2, el.y + el.h / 2];
|
|
424
|
-
const sb = facingAnchor(fromStroke, toCenter[0], toCenter[1]);
|
|
425
|
-
const eb = facingAnchor(elHost, fromCenter[0], fromCenter[1]);
|
|
426
|
-
if (!sb || !eb) return null;
|
|
427
|
-
const p1 = anchorPoint(fromStroke, sb.nx, sb.ny);
|
|
428
|
-
const p2 = anchorPoint(elHost, eb.nx, eb.ny);
|
|
429
|
-
if (!p1 || !p2) return null;
|
|
430
|
-
return pushCreated(ctx, {
|
|
431
|
-
id: rid(),
|
|
432
|
-
tool: 'arrow',
|
|
433
|
-
color: DEFAULT_COLOR,
|
|
434
|
-
width: 3,
|
|
435
|
-
x1: p1[0],
|
|
436
|
-
y1: p1[1],
|
|
437
|
-
x2: p2[0],
|
|
438
|
-
y2: p2[1],
|
|
439
|
-
author: 'ai',
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function createSticky(ctx, op) {
|
|
444
|
-
const w = Number.isFinite(op.w) ? op.w : STICKY_DEFAULT_W;
|
|
445
|
-
const h = Number.isFinite(op.h) ? op.h : STICKY_DEFAULT_H;
|
|
446
|
-
const { x, y, pinnedEl } = autoPlace(ctx, w, op);
|
|
447
|
-
const stroke = pushCreated(ctx, {
|
|
448
|
-
id: mint(ctx, op.ref),
|
|
449
|
-
tool: 'sticky',
|
|
450
|
-
color: typeof op.color === 'string' ? op.color : DEFAULT_STICKY_COLOR,
|
|
451
|
-
x,
|
|
452
|
-
y,
|
|
453
|
-
w,
|
|
454
|
-
h,
|
|
455
|
-
text: typeof op.text === 'string' ? op.text : '',
|
|
456
|
-
fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
|
|
457
|
-
cornerRadius: STICKY_CORNER_RADIUS,
|
|
458
|
-
author: 'ai',
|
|
459
|
-
});
|
|
460
|
-
if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
|
|
461
|
-
return stroke;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function createSection(ctx, op) {
|
|
465
|
-
const w = Number.isFinite(op.w) ? op.w : SECTION_DEFAULT_W;
|
|
466
|
-
const h = Number.isFinite(op.h) ? op.h : SECTION_DEFAULT_H;
|
|
467
|
-
const { x, y } = autoPlace(ctx, w, op);
|
|
468
|
-
return pushCreated(ctx, {
|
|
469
|
-
id: mint(ctx, op.ref),
|
|
470
|
-
tool: 'section',
|
|
471
|
-
x,
|
|
472
|
-
y,
|
|
473
|
-
w,
|
|
474
|
-
h,
|
|
475
|
-
label: typeof op.label === 'string' && op.label ? op.label : 'Section',
|
|
476
|
-
color: typeof op.color === 'string' ? op.color : DEFAULT_SECTION_COLOR,
|
|
477
|
-
author: 'ai',
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function createText(ctx, op) {
|
|
482
|
-
const { x, y, pinnedEl } = autoPlace(ctx, 160, op);
|
|
483
|
-
const stroke = pushCreated(ctx, {
|
|
484
|
-
id: mint(ctx, op.ref),
|
|
485
|
-
tool: 'text',
|
|
486
|
-
color: typeof op.color === 'string' ? op.color : TEXT_INK,
|
|
487
|
-
fontSize: Number.isFinite(op.fontSize) ? op.fontSize : DEFAULT_FONT_SIZE,
|
|
488
|
-
text: typeof op.text === 'string' ? op.text : '',
|
|
489
|
-
x,
|
|
490
|
-
y,
|
|
491
|
-
author: 'ai',
|
|
492
|
-
});
|
|
493
|
-
if (pinnedEl && ctx.pointer && op.pointer !== false) pointerArrowTo(ctx, stroke, pinnedEl);
|
|
494
|
-
return stroke;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function createShape(ctx, op) {
|
|
498
|
-
const w = Number.isFinite(op.w) ? op.w : NODE_W;
|
|
499
|
-
const h = Number.isFinite(op.h) ? op.h : NODE_H;
|
|
500
|
-
const { x, y } = autoPlace(ctx, w, op);
|
|
501
|
-
const ink = typeof op.color === 'string' ? op.color : NODE_INK;
|
|
502
|
-
const fill = op.fill === null ? null : typeof op.fill === 'string' ? op.fill : NODE_FILL;
|
|
503
|
-
const kind = typeof op.shape === 'string' ? op.shape : 'rounded';
|
|
504
|
-
const id = mint(ctx, op.ref);
|
|
505
|
-
let shape;
|
|
506
|
-
if (kind === 'ellipse' || kind === 'circle') {
|
|
507
|
-
shape = {
|
|
508
|
-
id,
|
|
509
|
-
tool: 'ellipse',
|
|
510
|
-
color: ink,
|
|
511
|
-
width: 3,
|
|
512
|
-
cx: x + w / 2,
|
|
513
|
-
cy: y + h / 2,
|
|
514
|
-
rx: w / 2,
|
|
515
|
-
ry: h / 2,
|
|
516
|
-
fill,
|
|
517
|
-
author: 'ai',
|
|
518
|
-
};
|
|
519
|
-
} else if (kind === 'diamond' || kind === 'triangle' || kind === 'triangle-down') {
|
|
520
|
-
shape = {
|
|
521
|
-
id,
|
|
522
|
-
tool: 'polygon',
|
|
523
|
-
shape: kind,
|
|
524
|
-
color: ink,
|
|
525
|
-
width: 3,
|
|
526
|
-
x,
|
|
527
|
-
y,
|
|
528
|
-
w,
|
|
529
|
-
h,
|
|
530
|
-
fill,
|
|
531
|
-
author: 'ai',
|
|
266
|
+
return {
|
|
267
|
+
x: Number.isFinite(op.x) ? op.x : override.x,
|
|
268
|
+
y: Number.isFinite(op.y) ? op.y : override.y,
|
|
269
|
+
pinnedEl: override.pinnedEl,
|
|
532
270
|
};
|
|
533
|
-
} else {
|
|
534
|
-
shape = {
|
|
535
|
-
id,
|
|
536
|
-
tool: 'rect',
|
|
537
|
-
color: ink,
|
|
538
|
-
width: 3,
|
|
539
|
-
x,
|
|
540
|
-
y,
|
|
541
|
-
w,
|
|
542
|
-
h,
|
|
543
|
-
fill,
|
|
544
|
-
cornerRadius: kind === 'rect' || kind === 'square' ? 0 : 8,
|
|
545
|
-
author: 'ai',
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
pushCreated(ctx, shape);
|
|
549
|
-
if (typeof op.label === 'string' && op.label) {
|
|
550
|
-
// Anchored label — renders centered in the host (the canvas convention).
|
|
551
|
-
// Wave G widened anchored text to every closed shape, polygons included.
|
|
552
|
-
pushCreated(ctx, {
|
|
553
|
-
id: rid(),
|
|
554
|
-
tool: 'text',
|
|
555
|
-
color: TEXT_INK,
|
|
556
|
-
fontSize: DEFAULT_FONT_SIZE,
|
|
557
|
-
text: op.label,
|
|
558
|
-
anchorId: id,
|
|
559
|
-
author: 'ai',
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
return shape;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function createConnect(ctx, op) {
|
|
566
|
-
const from = resolveTarget(ctx, op.from);
|
|
567
|
-
const to = resolveTarget(ctx, op.to);
|
|
568
|
-
if (!from || !to) {
|
|
569
|
-
fail(
|
|
570
|
-
`connect: unknown ${!from ? `"from" (${op.from})` : `"to" (${op.to})`} — targets must be existing bindable ids or @refs created earlier in the batch`,
|
|
571
|
-
2
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
const fromCenter = centerOf(from.stroke);
|
|
575
|
-
const toCenter = centerOf(to.stroke);
|
|
576
|
-
const sb = facingAnchor(from.stroke, toCenter[0], toCenter[1]);
|
|
577
|
-
const eb = facingAnchor(to.stroke, fromCenter[0], fromCenter[1]);
|
|
578
|
-
if (!sb || !eb) fail('connect: could not derive anchors (zero-extent host?)', 2);
|
|
579
|
-
const p1 = anchorPoint(from.stroke, sb.nx, sb.ny);
|
|
580
|
-
const p2 = anchorPoint(to.stroke, eb.nx, eb.ny);
|
|
581
|
-
if (!p1 || !p2) fail('connect: could not derive endpoints', 2);
|
|
582
|
-
const arrow = pushCreated(ctx, {
|
|
583
|
-
id: mint(ctx, op.ref),
|
|
584
|
-
tool: 'arrow',
|
|
585
|
-
color: typeof op.color === 'string' ? op.color : DEFAULT_COLOR,
|
|
586
|
-
width: 3,
|
|
587
|
-
x1: p1[0],
|
|
588
|
-
y1: p1[1],
|
|
589
|
-
x2: p2[0],
|
|
590
|
-
y2: p2[1],
|
|
591
|
-
startBind: { hostId: from.id, nx: sb.nx, ny: sb.ny },
|
|
592
|
-
endBind: { hostId: to.id, nx: eb.nx, ny: eb.ny },
|
|
593
|
-
author: 'ai',
|
|
594
|
-
});
|
|
595
|
-
if (typeof op.label === 'string' && op.label) {
|
|
596
|
-
pushCreated(ctx, {
|
|
597
|
-
id: rid(),
|
|
598
|
-
tool: 'text',
|
|
599
|
-
color: TEXT_INK,
|
|
600
|
-
fontSize: 12,
|
|
601
|
-
text: op.label,
|
|
602
|
-
x: (p1[0] + p2[0]) / 2 + 6,
|
|
603
|
-
y: (p1[1] + p2[1]) / 2 - 18,
|
|
604
|
-
author: 'ai',
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
return arrow;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function centerOf(stroke) {
|
|
611
|
-
if (stroke.tool === 'ellipse') return [stroke.cx, stroke.cy];
|
|
612
|
-
return [stroke.x + (stroke.w ?? 0) / 2, stroke.y + (stroke.h ?? 0) / 2];
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function applyGroup(ctx, op) {
|
|
616
|
-
const ids = Array.isArray(op.ids) ? op.ids : [];
|
|
617
|
-
const resolved = [];
|
|
618
|
-
for (const raw of ids) {
|
|
619
|
-
const id = typeof raw === 'string' && raw.startsWith('@') ? ctx.refs.get(raw) : raw;
|
|
620
|
-
if (!id) fail(`group: unknown ref ${raw}`, 2);
|
|
621
|
-
resolved.push(id);
|
|
622
|
-
}
|
|
623
|
-
if (resolved.length < 2) fail('group: needs at least two ids', 2);
|
|
624
|
-
const groupId = gid();
|
|
625
|
-
for (const id of resolved) {
|
|
626
|
-
const created = ctx.newById.get(id);
|
|
627
|
-
if (created) {
|
|
628
|
-
created.groupIds = [...(created.groupIds ?? []), groupId];
|
|
629
|
-
} else if (ctx.existingById.has(id)) {
|
|
630
|
-
ctx.groupExisting.push({ id, groupId });
|
|
631
|
-
} else {
|
|
632
|
-
fail(`group: id not found on canvas: ${id}`, 2);
|
|
633
|
-
}
|
|
634
271
|
}
|
|
272
|
+
const x = Number.isFinite(op.x) ? op.x : pl.cursor.x;
|
|
273
|
+
const y = Number.isFinite(op.y) ? op.y : pl.cursor.y;
|
|
274
|
+
if (!Number.isFinite(op.x)) pl.cursor.x = x + w + 40;
|
|
275
|
+
return { x, y, pinnedEl: pl.pinnedEl };
|
|
635
276
|
}
|
|
636
277
|
|
|
637
278
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
// so patching `DOMParser` alone leaves the parse broken) — but register()
|
|
657
|
-
// itself records every property it overwrote, so we call `unregister()`
|
|
658
|
-
// (its documented inverse) IMMEDIATELY after the parse, before control ever
|
|
659
|
-
// returns to the batch's later loopback-gated PUT. That closes the window
|
|
660
|
-
// this feature's own security review flagged: fetch/Response/Request/URL
|
|
661
|
-
// never stay monkey-patched past this one parse.
|
|
662
|
-
async function ensureFullStrokes(ctx) {
|
|
663
|
-
if (ctx.fullStrokes) return ctx.fullStrokes;
|
|
664
|
-
let GlobalRegistrator;
|
|
665
|
-
try {
|
|
666
|
-
({ GlobalRegistrator } = await import('@happy-dom/global-registrator'));
|
|
667
|
-
} catch {
|
|
668
|
-
// Packaging note (feature-whiteboard-ai-toolkit review): apps/studio's
|
|
669
|
-
// package.json is a nested workspace manifest the npm tarball doesn't
|
|
670
|
-
// ship, and unlike every other bin script here this is the first one
|
|
671
|
-
// that needs a real third-party package at runtime — an npm-installed
|
|
672
|
-
// maude may have no node_modules for it. Fail loud with the documented
|
|
673
|
-
// escape hatch rather than an opaque "Cannot find package" stack trace.
|
|
674
|
-
fail(
|
|
675
|
-
'move/set-text/set-color need the happy-dom package, which is not installed — ' +
|
|
676
|
-
"reinstall maude, or use delete + create instead (the vocabulary's existing fallback for full replacement)",
|
|
677
|
-
1
|
|
678
|
-
);
|
|
679
|
-
}
|
|
680
|
-
GlobalRegistrator.register({ settings: { disableJavaScriptEvaluation: true } });
|
|
681
|
-
let strokes;
|
|
682
|
-
try {
|
|
683
|
-
strokes = svgToStrokes(sanitizeAnnotationSvg(ctx.rawSvg));
|
|
684
|
-
} finally {
|
|
685
|
-
await GlobalRegistrator.unregister();
|
|
279
|
+
// Ops → the engine.
|
|
280
|
+
|
|
281
|
+
function createOp(batch, pl, op) {
|
|
282
|
+
const type = op.type;
|
|
283
|
+
// Placement keys are resolved here, never passed on as element fields.
|
|
284
|
+
const { in: _in, near: _near, pin: _pin, pointer: _pointer, ...fields } = op;
|
|
285
|
+
if (type === 'shape') {
|
|
286
|
+
fields.shape ??= 'rounded';
|
|
287
|
+
if (fields.color === undefined) fields.color = NODE_INK;
|
|
288
|
+
if (fields.fill === undefined) fields.fill = NODE_FILL;
|
|
289
|
+
fields.width ??= 3;
|
|
290
|
+
}
|
|
291
|
+
if (type === 'arrow' || type === 'pen') return batch.create(type, fields);
|
|
292
|
+
const w = Number.isFinite(op.w) ? op.w : (CREATE_SIZE[type]?.w ?? 160);
|
|
293
|
+
const { x, y, pinnedEl } = place(pl, op, w);
|
|
294
|
+
const id = batch.create(type, { ...fields, x, y });
|
|
295
|
+
if (pinnedEl && pl.pointer && op.pointer !== false && (type === 'sticky' || type === 'text')) {
|
|
296
|
+
batch.pointer(id, { x: pinnedEl.x, y: pinnedEl.y, w: pinnedEl.w, h: pinnedEl.h });
|
|
686
297
|
}
|
|
687
|
-
|
|
688
|
-
return ctx.fullStrokes;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Resolves an id or "@ref" to the mutable stroke — a just-created stroke in
|
|
693
|
-
* THIS batch (mutated in place, not yet serialized), an EARLIER patch this
|
|
694
|
-
* same batch already queued (checked before the cached original — otherwise
|
|
695
|
-
* a second move/set-text/set-color on the same id would clobber the first
|
|
696
|
-
* patch instead of building on it), or an existing one from the pre-batch SVG.
|
|
697
|
-
*/
|
|
698
|
-
async function resolveMutable(ctx, idOrRef, verb) {
|
|
699
|
-
const id =
|
|
700
|
-
typeof idOrRef === 'string' && idOrRef.startsWith('@') ? ctx.refs.get(idOrRef) : idOrRef;
|
|
701
|
-
if (!id) fail(`${verb}: unknown ref "${idOrRef}"`, 2);
|
|
702
|
-
const created = ctx.newById.get(id);
|
|
703
|
-
if (created) return { id, stroke: created, isNew: true };
|
|
704
|
-
if (ctx.replaces.has(id)) return { id, stroke: ctx.replaces.get(id), isNew: false };
|
|
705
|
-
const byId = await ensureFullStrokes(ctx);
|
|
706
|
-
const stroke = byId.get(id);
|
|
707
|
-
if (!stroke) fail(`${verb}: unknown id "${id}"`, 2);
|
|
708
|
-
return { id, stroke, isNew: false };
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function commitMutation(ctx, target, patched) {
|
|
712
|
-
if (target.isNew) Object.assign(target.stroke, patched);
|
|
713
|
-
else ctx.replaces.set(target.id, { ...target.stroke, ...patched });
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
async function applyMove(ctx, op) {
|
|
717
|
-
if (typeof op.id !== 'string' || !op.id) fail('move: missing id', 2);
|
|
718
|
-
if (!Number.isFinite(op.x) || !Number.isFinite(op.y)) fail('move: x/y must be finite numbers', 2);
|
|
719
|
-
const target = await resolveMutable(ctx, op.id, 'move');
|
|
720
|
-
const { stroke } = target;
|
|
721
|
-
if (stroke.tool === 'ellipse') {
|
|
722
|
-
commitMutation(ctx, target, { cx: op.x + stroke.rx, cy: op.y + stroke.ry });
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
if (stroke.tool === 'arrow' || stroke.tool === 'pen') {
|
|
726
|
-
fail(`move: "${stroke.tool}" has no single position (multi-point) — use delete + create`, 2);
|
|
727
|
-
}
|
|
728
|
-
if (stroke.tool === 'text' && stroke.anchorId) {
|
|
729
|
-
fail('move: anchored text derives its position from its host — move the host instead', 2);
|
|
730
|
-
}
|
|
731
|
-
if (stroke.x == null || stroke.y == null) {
|
|
732
|
-
fail(`move: tool "${stroke.tool}" has no movable x/y`, 2);
|
|
733
|
-
}
|
|
734
|
-
commitMutation(ctx, target, { x: op.x, y: op.y });
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
async function applySetText(ctx, op) {
|
|
738
|
-
if (typeof op.id !== 'string' || !op.id) fail('set-text: missing id', 2);
|
|
739
|
-
if (typeof op.text !== 'string') fail('set-text: text must be a string', 2);
|
|
740
|
-
const target = await resolveMutable(ctx, op.id, 'set-text');
|
|
741
|
-
const { stroke } = target;
|
|
742
|
-
const field = 'label' in stroke ? 'label' : 'text' in stroke ? 'text' : null;
|
|
743
|
-
if (!field) fail(`set-text: tool "${stroke.tool}" has no text/label field`, 2);
|
|
744
|
-
commitMutation(ctx, target, { [field]: op.text });
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function applySetColor(ctx, op) {
|
|
748
|
-
if (typeof op.id !== 'string' || !op.id) fail('set-color: missing id', 2);
|
|
749
|
-
if (typeof op.color !== 'string' || !op.color)
|
|
750
|
-
fail('set-color: color must be a non-empty string', 2);
|
|
751
|
-
const target = await resolveMutable(ctx, op.id, 'set-color');
|
|
752
|
-
const { stroke } = target;
|
|
753
|
-
if (!('color' in stroke)) {
|
|
754
|
-
fail(`set-color: tool "${stroke.tool}" has no single color field — use delete + create`, 2);
|
|
755
|
-
}
|
|
756
|
-
commitMutation(ctx, target, { color: op.color });
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/** Splice a re-serialized stroke into the SAME position the original element
|
|
760
|
-
* occupied — preserves document order/z, unlike delete-then-append. Mirrors
|
|
761
|
-
* deleteElement's g-wrapped-vs-flat matching. */
|
|
762
|
-
function replaceElement(svg, id, replacement) {
|
|
763
|
-
const idEsc = escapeRe(id);
|
|
764
|
-
const gOrTextRe = new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`);
|
|
765
|
-
if (gOrTextRe.test(svg)) return svg.replace(gOrTextRe, replacement);
|
|
766
|
-
const flatRe = new RegExp(
|
|
767
|
-
`<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`
|
|
768
|
-
);
|
|
769
|
-
if (flatRe.test(svg)) return svg.replace(flatRe, replacement);
|
|
770
|
-
return null;
|
|
298
|
+
return id;
|
|
771
299
|
}
|
|
772
300
|
|
|
773
|
-
|
|
301
|
+
function runOps(batch, pl, ops) {
|
|
774
302
|
for (const op of ops) {
|
|
775
|
-
if (!op || typeof op !== 'object')
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
});
|
|
794
|
-
} else {
|
|
795
|
-
createConnect(ctx, op); // from/to form
|
|
303
|
+
if (!op || typeof op !== 'object') throw new AiOpError('ops: every entry must be an object');
|
|
304
|
+
switch (op.op) {
|
|
305
|
+
case 'create':
|
|
306
|
+
createOp(batch, pl, op);
|
|
307
|
+
break;
|
|
308
|
+
case 'connect':
|
|
309
|
+
batch.connect(op);
|
|
310
|
+
break;
|
|
311
|
+
case 'update':
|
|
312
|
+
batch.update(op);
|
|
313
|
+
break;
|
|
314
|
+
case 'set-text':
|
|
315
|
+
if (typeof op.text !== 'string') throw new AiOpError('set-text: text must be a string');
|
|
316
|
+
batch.update({ id: op.id, text: op.text });
|
|
317
|
+
break;
|
|
318
|
+
case 'set-color':
|
|
319
|
+
if (typeof op.color !== 'string' || !op.color) {
|
|
320
|
+
throw new AiOpError('set-color: color must be a non-empty string');
|
|
796
321
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
322
|
+
batch.update({ id: op.id, color: op.color });
|
|
323
|
+
break;
|
|
324
|
+
case 'move':
|
|
325
|
+
batch.move(op);
|
|
326
|
+
break;
|
|
327
|
+
case 'reparent':
|
|
328
|
+
batch.reparent(op);
|
|
329
|
+
break;
|
|
330
|
+
case 'reorder':
|
|
331
|
+
batch.reorder(op);
|
|
332
|
+
break;
|
|
333
|
+
case 'group':
|
|
334
|
+
batch.group(op);
|
|
335
|
+
break;
|
|
336
|
+
case 'delete':
|
|
337
|
+
batch.delete(op);
|
|
338
|
+
break;
|
|
339
|
+
default:
|
|
340
|
+
throw new AiOpError(`ops: unknown op "${op.op}"`);
|
|
813
341
|
}
|
|
814
342
|
}
|
|
815
343
|
}
|
|
@@ -817,6 +345,12 @@ async function applyOps(ctx, ops) {
|
|
|
817
345
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
818
346
|
// Flow mode — layered left→right layout of nodes + bound connector edges.
|
|
819
347
|
|
|
348
|
+
// Shared by --flow and --board layout:"flow" (which delegates to flowToOps) —
|
|
349
|
+
// checked once, in flowToOps, so neither caller can bypass it. A relationship
|
|
350
|
+
// array (edges) manufactures ops just as surely as an entity array does.
|
|
351
|
+
const FLOW_MAX_NODES = 200;
|
|
352
|
+
const FLOW_MAX_EDGES = 400;
|
|
353
|
+
|
|
820
354
|
function flowToOps(flow) {
|
|
821
355
|
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
822
356
|
const edges = Array.isArray(flow.edges) ? flow.edges : [];
|
|
@@ -866,8 +400,10 @@ function flowToOps(flow) {
|
|
|
866
400
|
type: 'shape',
|
|
867
401
|
shape: typeof node.shape === 'string' ? node.shape : 'rounded',
|
|
868
402
|
ref: `@${id}`,
|
|
869
|
-
|
|
870
|
-
|
|
403
|
+
text: typeof node.label === 'string' ? node.label : String(id),
|
|
404
|
+
w: NODE_W,
|
|
405
|
+
h: NODE_H,
|
|
406
|
+
// Relative to the placement origin (applyOriginOffset).
|
|
871
407
|
flowX: l * (NODE_W + FLOW_GAP_X),
|
|
872
408
|
flowY: i * (NODE_H + FLOW_GAP_Y),
|
|
873
409
|
});
|
|
@@ -880,11 +416,10 @@ function flowToOps(flow) {
|
|
|
880
416
|
}
|
|
881
417
|
|
|
882
418
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
883
|
-
// Board mode
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
// just the generic layout engine they share.
|
|
419
|
+
// Board mode — a typed template spec expands into ops the same way --flow
|
|
420
|
+
// does. Named presets (retro / kanban / social calendar / roadmap / brainstorm
|
|
421
|
+
// / checklist / user flow) are spec fixtures in the `whiteboard` skill, not
|
|
422
|
+
// code: this is only the generic layout engine they share.
|
|
888
423
|
|
|
889
424
|
const BOARD_COL_W = 280;
|
|
890
425
|
const BOARD_COL_GAP = 40;
|
|
@@ -899,23 +434,12 @@ const BOARD_RADIAL_CENTER_H = 100;
|
|
|
899
434
|
const BOARD_RADIAL_CARD_W = 200;
|
|
900
435
|
const BOARD_RADIAL_CARD_H = 80;
|
|
901
436
|
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
// is expanded + serialized, so an unbounded groups/cards array could burn
|
|
905
|
-
// real CPU/memory before that cap ever fires. Cap generously (well above any
|
|
906
|
-
// real retro/kanban/roadmap board) up front instead.
|
|
437
|
+
// --board is arbitrary agent-composed JSON: cap it BEFORE expansion, so an
|
|
438
|
+
// unbounded groups/cards array can't burn CPU/memory before the board byte cap.
|
|
907
439
|
const BOARD_MAX_GROUPS = 20;
|
|
908
440
|
const BOARD_MAX_CARDS_PER_GROUP = 50;
|
|
909
441
|
const BOARD_MAX_TOTAL_CARDS = 300;
|
|
910
442
|
const BOARD_MAX_CONNECTIONS = 400;
|
|
911
|
-
// Shared by --flow (top-level) and --board's layout:"flow" (which delegates
|
|
912
|
-
// straight to flowToOps) — checked once, in flowToOps itself, so neither
|
|
913
|
-
// caller can bypass it. A relationship array (edges) is just as capable of
|
|
914
|
-
// manufacturing unbounded connect ops as an entity array (nodes/cards) is —
|
|
915
|
-
// createConnect mints a fresh arrow (+ optional label text) stroke per call
|
|
916
|
-
// regardless of how few distinct nodes are involved.
|
|
917
|
-
const FLOW_MAX_NODES = 200;
|
|
918
|
-
const FLOW_MAX_EDGES = 400;
|
|
919
443
|
|
|
920
444
|
function cardText(card) {
|
|
921
445
|
return typeof card === 'string' ? card : typeof card?.text === 'string' ? card.text : '';
|
|
@@ -928,11 +452,9 @@ function cardColor(card) {
|
|
|
928
452
|
}
|
|
929
453
|
|
|
930
454
|
/**
|
|
931
|
-
* "columns"
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* so an empty column next to a seeded one (a half-filled retro board) never
|
|
935
|
-
* collides with its neighbor.
|
|
455
|
+
* "columns" — one titled section per group, its cards stacked INSIDE it (they
|
|
456
|
+
* are the section's children). Each section's height grows with its own card
|
|
457
|
+
* count, so a half-filled board never collides with its neighbour.
|
|
936
458
|
*/
|
|
937
459
|
function boardColumns(groups) {
|
|
938
460
|
const ops = [];
|
|
@@ -946,12 +468,13 @@ function boardColumns(groups) {
|
|
|
946
468
|
op: 'create',
|
|
947
469
|
type: 'section',
|
|
948
470
|
ref: `@sec${i}`,
|
|
949
|
-
|
|
471
|
+
text: typeof g.title === 'string' && g.title ? g.title : `Group ${i + 1}`,
|
|
950
472
|
color: typeof g.color === 'string' ? g.color : undefined,
|
|
951
473
|
boardX: colX,
|
|
952
474
|
boardY: 0,
|
|
953
475
|
w: BOARD_COL_W,
|
|
954
476
|
h,
|
|
477
|
+
parent: null,
|
|
955
478
|
});
|
|
956
479
|
cards.forEach((c, j) => {
|
|
957
480
|
ops.push({
|
|
@@ -964,17 +487,14 @@ function boardColumns(groups) {
|
|
|
964
487
|
boardY: BOARD_HEADER_H + j * (BOARD_CARD_H + BOARD_CARD_GAP),
|
|
965
488
|
w: BOARD_CARD_W,
|
|
966
489
|
h: BOARD_CARD_H,
|
|
490
|
+
parent: `@sec${i}`,
|
|
967
491
|
});
|
|
968
492
|
});
|
|
969
493
|
});
|
|
970
494
|
return ops;
|
|
971
495
|
}
|
|
972
496
|
|
|
973
|
-
/**
|
|
974
|
-
* "radial" — a central topic shape with idea cards arranged in a ring
|
|
975
|
-
* (brainstorm). Cards flatten across every group's cards in order — a
|
|
976
|
-
* brainstorm spec doesn't need multiple groups, but tolerates them.
|
|
977
|
-
*/
|
|
497
|
+
/** "radial" — a central topic shape with idea cards in a ring (brainstorm). */
|
|
978
498
|
function boardRadial(spec, groups) {
|
|
979
499
|
const cards = groups.flatMap((g) => (Array.isArray(g.cards) ? g.cards : []));
|
|
980
500
|
const pad = BOARD_RADIAL_RADIUS + Math.max(BOARD_RADIAL_CARD_W, BOARD_RADIAL_CARD_H);
|
|
@@ -984,7 +504,7 @@ function boardRadial(spec, groups) {
|
|
|
984
504
|
type: 'shape',
|
|
985
505
|
shape: 'ellipse',
|
|
986
506
|
ref: '@center',
|
|
987
|
-
|
|
507
|
+
text: typeof spec.title === 'string' && spec.title ? spec.title : 'Topic',
|
|
988
508
|
boardX: pad - BOARD_RADIAL_CENTER_W / 2,
|
|
989
509
|
boardY: pad - BOARD_RADIAL_CENTER_H / 2,
|
|
990
510
|
w: BOARD_RADIAL_CENTER_W,
|
|
@@ -1011,13 +531,6 @@ function boardRadial(spec, groups) {
|
|
|
1011
531
|
return ops;
|
|
1012
532
|
}
|
|
1013
533
|
|
|
1014
|
-
/**
|
|
1015
|
-
* Expand a typed board spec into ops (create + connect), positioned relative
|
|
1016
|
-
* to the placement origin via `boardX`/`boardY` — the same convention
|
|
1017
|
-
* `flowToOps` uses for `flowX`/`flowY` (applyOriginOffset, below, applies
|
|
1018
|
-
* either). `layout: "flow"` delegates straight to `flowToOps` so a user-flow
|
|
1019
|
-
* diagram shares ONE auto-layout implementation with plain `--flow`.
|
|
1020
|
-
*/
|
|
1021
534
|
function boardToOps(spec) {
|
|
1022
535
|
const layout = typeof spec.layout === 'string' ? spec.layout : 'columns';
|
|
1023
536
|
if (layout === 'flow') {
|
|
@@ -1043,10 +556,6 @@ function boardToOps(spec) {
|
|
|
1043
556
|
fail(`board: ${totalCards} total cards across groups, max ${BOARD_MAX_TOTAL_CARDS}`, 2);
|
|
1044
557
|
}
|
|
1045
558
|
const ops = layout === 'radial' ? boardRadial(spec, groups) : boardColumns(groups);
|
|
1046
|
-
|
|
1047
|
-
// Optional cross-references — "from"/"to" name a ref this expansion minted
|
|
1048
|
-
// (section refs are `@sec<i>`, 0-indexed by group order; card refs are
|
|
1049
|
-
// `@sec<i>card<j>`; the radial center is `@center`, ideas are `@idea<i>`).
|
|
1050
559
|
const connections = Array.isArray(spec.connections) ? spec.connections : [];
|
|
1051
560
|
if (connections.length > BOARD_MAX_CONNECTIONS) {
|
|
1052
561
|
fail(`board: connections[] has ${connections.length}, max ${BOARD_MAX_CONNECTIONS}`, 2);
|
|
@@ -1060,59 +569,80 @@ function boardToOps(spec) {
|
|
|
1060
569
|
return ops;
|
|
1061
570
|
}
|
|
1062
571
|
|
|
1063
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1064
|
-
// SVG assembly — deletes + group injections on the existing string, new
|
|
1065
|
-
// strokes serialized through the canonical serializer, sanitize, cap.
|
|
1066
|
-
|
|
1067
|
-
function deleteElement(svg, id) {
|
|
1068
|
-
const idEsc = escapeRe(id);
|
|
1069
|
-
let out = svg.replace(
|
|
1070
|
-
new RegExp(`<(g|text)\\b[^>]*data-id="${idEsc}"[^>]*>[\\s\\S]*?</\\1>`, 'g'),
|
|
1071
|
-
''
|
|
1072
|
-
);
|
|
1073
|
-
out = out.replace(
|
|
1074
|
-
new RegExp(`<(?:path|rect|ellipse|polygon|image)\\b[^>]*data-id="${idEsc}"[^>]*/>`, 'g'),
|
|
1075
|
-
''
|
|
1076
|
-
);
|
|
1077
|
-
// Cascade — anchored text hosted by the deleted shape goes with it (the
|
|
1078
|
-
// canvas deleteStrokes rule).
|
|
1079
|
-
out = out.replace(
|
|
1080
|
-
new RegExp(`<text\\b[^>]*data-anchor-id="${idEsc}"[^>]*>[\\s\\S]*?</text>`, 'g'),
|
|
1081
|
-
''
|
|
1082
|
-
);
|
|
1083
|
-
return out;
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
function injectGroupAttr(svg, id, groupId) {
|
|
1087
|
-
const idEsc = escapeRe(id);
|
|
1088
|
-
const re = new RegExp(`(<[a-zA-Z]+\\b[^>]*data-id="${idEsc}"[^>]*?)(/?>)`);
|
|
1089
|
-
return svg.replace(re, (_whole, head, close) => {
|
|
1090
|
-
if (/data-group-ids="/.test(head)) {
|
|
1091
|
-
return (
|
|
1092
|
-
head.replace(
|
|
1093
|
-
/data-group-ids="([^"]*)"/,
|
|
1094
|
-
(_m, cur) => `data-group-ids="${cur} ${groupId}"`
|
|
1095
|
-
) + close
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
return `${head} data-group-ids="${groupId}"${close}`;
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
572
|
/**
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
* positions the whole diagram/board as a unit.
|
|
573
|
+
* --flow and --board emit a relative offset (flowX/flowY, boardX/boardY)
|
|
574
|
+
* instead of absolute x/y, so ONE placement resolution positions the whole
|
|
575
|
+
* diagram/board as a unit.
|
|
1107
576
|
*/
|
|
1108
577
|
function applyOriginOffset(ops, origin) {
|
|
1109
578
|
return ops.map((op) => {
|
|
1110
|
-
|
|
1111
|
-
if (
|
|
1112
|
-
return
|
|
579
|
+
const { flowX, flowY, boardX, boardY, ...rest } = op;
|
|
580
|
+
if (flowX !== undefined) return { ...rest, x: origin.x + flowX, y: origin.y + flowY };
|
|
581
|
+
if (boardX !== undefined) return { ...rest, x: origin.x + boardX, y: origin.y + boardY };
|
|
582
|
+
return rest;
|
|
1113
583
|
});
|
|
1114
584
|
}
|
|
1115
585
|
|
|
586
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
587
|
+
// Write — the live server's op endpoint, else a direct canonical file write.
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* POST the ops to a live, loopback dev-server. Returns the parsed response,
|
|
591
|
+
* `null` when no server answers or it predates the op route (then the caller
|
|
592
|
+
* writes the file), and fails on any other refusal or a timeout — a file write
|
|
593
|
+
* would bypass the server and its live room.
|
|
594
|
+
*/
|
|
595
|
+
async function postOps(designRoot, file, ops) {
|
|
596
|
+
const serverJsonPath = join(designRoot, '_server.json');
|
|
597
|
+
if (!existsSync(serverJsonPath)) return null;
|
|
598
|
+
let base = null;
|
|
599
|
+
try {
|
|
600
|
+
const srv = JSON.parse(readFileSync(serverJsonPath, 'utf8'));
|
|
601
|
+
base = typeof srv.url === 'string' ? srv.url.replace(/\/+$/, '') : null;
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
// Security (Wave H F2): only ever POST to a loopback origin — a poisoned
|
|
606
|
+
// `url` can't turn the verb into an exfiltration primitive. Anything else
|
|
607
|
+
// silently falls back to the file write (the local intent still succeeds).
|
|
608
|
+
if (!base || !isLoopbackHttpUrl(base)) return null;
|
|
609
|
+
let res;
|
|
610
|
+
try {
|
|
611
|
+
res = await fetch(`${base}/_api/annotations/ops`, {
|
|
612
|
+
method: 'POST',
|
|
613
|
+
headers: { 'Content-Type': 'application/json' },
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
file,
|
|
616
|
+
actionId: `ai-annotate-${Math.random().toString(36).slice(2, 12)}`,
|
|
617
|
+
ops,
|
|
618
|
+
}),
|
|
619
|
+
signal: AbortSignal.timeout(10_000),
|
|
620
|
+
});
|
|
621
|
+
} catch (err) {
|
|
622
|
+
// No server answering (a stale _server.json) → the file write is safe.
|
|
623
|
+
// A server that IS there but too slow must not be bypassed: writing the
|
|
624
|
+
// file would reseed its live room from a stale snapshot and erase what
|
|
625
|
+
// collaborators did meanwhile (security review A2).
|
|
626
|
+
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
|
|
627
|
+
fail('the dev-server did not answer in time — nothing written; try again', 1);
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
// Only a server too old to have the op route falls back to the file.
|
|
632
|
+
if (res.status === 404 || res.status === 405) return null;
|
|
633
|
+
// Inside a cloud workspace (MAUDE_WORKSPACE_MODE=1) the studio treats a
|
|
634
|
+
// loopback request without the proxy's role header as read-only; the
|
|
635
|
+
// workspace's own file write IS the agent's channel there (the workspace
|
|
636
|
+
// agent syncs it). Anywhere else a read-only refusal stands — a local write
|
|
637
|
+
// the hub refuses to sync is a silent fork.
|
|
638
|
+
if (res.status === 403 && process.env.MAUDE_WORKSPACE_MODE === '1') return null;
|
|
639
|
+
if (!res.ok) {
|
|
640
|
+
const body = await res.json().catch(() => ({}));
|
|
641
|
+
fail(`the server refused the batch: ${body?.error ?? res.status} — nothing written`, 1);
|
|
642
|
+
}
|
|
643
|
+
return res.json().catch(() => ({ ok: true, rejected: [] }));
|
|
644
|
+
}
|
|
645
|
+
|
|
1116
646
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1117
647
|
// Main
|
|
1118
648
|
|
|
@@ -1135,37 +665,27 @@ async function main() {
|
|
|
1135
665
|
: process.cwd();
|
|
1136
666
|
const { designRel, designRoot } = resolveDesignRoot(repoRoot);
|
|
1137
667
|
const slug = fileSlug(relPath, designRel);
|
|
1138
|
-
|
|
668
|
+
// The canvas path the server keys its board by — relative to the design
|
|
669
|
+
// root, whether the caller wrote `ui/X.tsx` or `.design/ui/X.tsx` (the slug
|
|
670
|
+
// above tolerates both; the server must get the same file, or every op is
|
|
671
|
+
// refused as `gone` against a board that doesn't exist).
|
|
672
|
+
const designPrefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
673
|
+
let canvasRel = String(relPath).replace(/^\/+/, '');
|
|
674
|
+
if (canvasRel.startsWith(designPrefix)) canvasRel = canvasRel.slice(designPrefix.length);
|
|
1139
675
|
|
|
1140
|
-
|
|
1141
|
-
if (
|
|
1142
|
-
|
|
1143
|
-
svg = readFileSync(svgPath, 'utf8');
|
|
1144
|
-
} catch {
|
|
1145
|
-
svg = '';
|
|
1146
|
-
}
|
|
676
|
+
const boardFile = readBoardFile(designRoot, slug);
|
|
677
|
+
if (boardFile.tooLarge) {
|
|
678
|
+
fail(`${slug}.annotations.json exceeds ${MAX_BOARD_BYTES} bytes — refusing to read it`, 2);
|
|
1147
679
|
}
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
// (peer-written per DDR-054, or git-committed by anyone) would still be
|
|
1151
|
-
// read in full and DOM-parsed on every move/set-text/set-color — the exact
|
|
1152
|
-
// "pay the cost before the check" pattern this feature's own board-size
|
|
1153
|
-
// caps exist to avoid, just via the read path instead of the write path.
|
|
1154
|
-
if (Buffer.byteLength(svg, 'utf8') > MAX_ANNOTATIONS_BYTES) {
|
|
1155
|
-
fail(`annotations file exceeds ${MAX_ANNOTATIONS_BYTES} bytes on disk — refusing to read`, 2);
|
|
680
|
+
if (boardFile.unreadable) {
|
|
681
|
+
fail(`${slug}.annotations.json is not a valid board — refusing to write over it`, 2);
|
|
1156
682
|
}
|
|
1157
|
-
const existing = parseAnnotations(svg);
|
|
1158
683
|
|
|
1159
684
|
let artboards = args.canvasState
|
|
1160
685
|
? loadArtboards(
|
|
1161
686
|
isAbsolute(args.canvasState) ? args.canvasState : resolve(process.cwd(), args.canvasState)
|
|
1162
687
|
)
|
|
1163
688
|
: [];
|
|
1164
|
-
|
|
1165
|
-
// feature-whiteboard-ai-toolkit — --rects supplies element lookups for
|
|
1166
|
-
// --pin, and (absent a separate --canvas-state) artboard lookups for
|
|
1167
|
-
// --in/--near too: loadArtboards already understands a canvas-rects
|
|
1168
|
-
// manifest's { artboards, elements } shape.
|
|
1169
689
|
let elements = [];
|
|
1170
690
|
if (args.rects) {
|
|
1171
691
|
const rectsPath = isAbsolute(args.rects) ? args.rects : resolve(process.cwd(), args.rects);
|
|
@@ -1173,104 +693,77 @@ async function main() {
|
|
|
1173
693
|
if (!artboards.length) artboards = loadArtboards(rectsPath);
|
|
1174
694
|
}
|
|
1175
695
|
|
|
1176
|
-
const
|
|
696
|
+
const batch = new AiBatch(boardFile.elements);
|
|
697
|
+
const pl = buildPlacement(batch, artboards, elements, {
|
|
1177
698
|
near: args.near,
|
|
1178
699
|
in: args.in,
|
|
1179
700
|
pin: args.pin,
|
|
1180
701
|
pointer: args.pointer,
|
|
1181
702
|
});
|
|
1182
|
-
ctx.rawSvg = svg;
|
|
1183
703
|
|
|
1184
704
|
let ops;
|
|
1185
705
|
if (args.flow != null) {
|
|
1186
|
-
|
|
1187
|
-
ops = applyOriginOffset(flowToOps(flow), ctx.origin);
|
|
706
|
+
ops = applyOriginOffset(flowToOps(parseJsonInput(readInput(args.flow), '--flow')), pl.origin);
|
|
1188
707
|
} else if (args.board != null) {
|
|
1189
|
-
|
|
1190
|
-
|
|
708
|
+
ops = applyOriginOffset(
|
|
709
|
+
boardToOps(parseJsonInput(readInput(args.board), '--board')),
|
|
710
|
+
pl.origin
|
|
711
|
+
);
|
|
1191
712
|
} else {
|
|
1192
713
|
const payload = parseJsonInput(readInput(args.ops), '--ops');
|
|
1193
|
-
ops = Array.isArray(payload
|
|
714
|
+
ops = Array.isArray(payload?.ops) ? payload.ops : Array.isArray(payload) ? payload : null;
|
|
1194
715
|
if (!ops) fail('ops: expected { ops: [...] } (or a bare array)', 2);
|
|
1195
716
|
}
|
|
1196
717
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
let merged = svg && /<svg[\s>]/i.test(svg) ? svg : `${SVG_HEADER}</svg>`;
|
|
1203
|
-
for (const id of ctx.deletes) merged = deleteElement(merged, id);
|
|
1204
|
-
for (const inj of ctx.groupExisting) merged = injectGroupAttr(merged, inj.id, inj.groupId);
|
|
1205
|
-
for (const [id, stroke] of ctx.replaces) {
|
|
1206
|
-
const next = replaceElement(merged, id, strokeToSvgEl(stroke));
|
|
1207
|
-
if (next === null) fail(`internal: replaced stroke "${id}" vanished before assembly`, 1);
|
|
1208
|
-
merged = next;
|
|
1209
|
-
}
|
|
1210
|
-
if (ctx.created.length) {
|
|
1211
|
-
const body = ctx.created.map((s) => strokeToSvgEl(s)).join('');
|
|
1212
|
-
const close = merged.lastIndexOf('</svg>');
|
|
1213
|
-
if (close < 0) fail('existing annotation file is not a valid SVG', 1);
|
|
1214
|
-
merged = merged.slice(0, close) + body + merged.slice(close);
|
|
1215
|
-
}
|
|
1216
|
-
merged = sanitizeAnnotationSvg(merged);
|
|
1217
|
-
const bytes = Buffer.byteLength(merged, 'utf8');
|
|
1218
|
-
if (bytes > MAX_ANNOTATIONS_BYTES) {
|
|
1219
|
-
fail(`result exceeds the 1 MB annotation cap (${bytes} bytes) — nothing written`, 1);
|
|
718
|
+
try {
|
|
719
|
+
runOps(batch, pl, ops);
|
|
720
|
+
} catch (err) {
|
|
721
|
+
if (err instanceof AiOpError) fail(err.message, 2);
|
|
722
|
+
throw err;
|
|
1220
723
|
}
|
|
1221
724
|
|
|
725
|
+
const text = serializeBoard(batch.elements);
|
|
726
|
+
if (text.length > MAX_BOARD_BYTES) {
|
|
727
|
+
fail(
|
|
728
|
+
`result exceeds the ${MAX_BOARD_BYTES}-byte board cap (${text.length}) — nothing written`,
|
|
729
|
+
1
|
|
730
|
+
);
|
|
731
|
+
}
|
|
1222
732
|
if (args.dryRun) {
|
|
1223
|
-
process.stdout.write(`${
|
|
733
|
+
process.stdout.write(`${JSON.stringify({ dryRun: true, ops: batch.ops })}\n`);
|
|
1224
734
|
return;
|
|
1225
735
|
}
|
|
1226
736
|
|
|
1227
|
-
|
|
1228
|
-
// canvases update in real time); fall back to a direct file write.
|
|
737
|
+
const file = join(designRoot, `${slug}.annotations.json`);
|
|
1229
738
|
let via = 'file';
|
|
1230
|
-
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if (base && isLoopbackHttpUrl(base)) {
|
|
1242
|
-
const res = await fetch(`${base}/_api/annotations`, {
|
|
1243
|
-
method: 'PUT',
|
|
1244
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1245
|
-
// `base` — the SVG this edit was merged onto, so a peer's strokes
|
|
1246
|
-
// that landed meanwhile are merged, not replaced (DDR-241).
|
|
1247
|
-
body: JSON.stringify({ file: `${designRel}/${relPath}`, svg: merged, base: svg }),
|
|
1248
|
-
signal: AbortSignal.timeout(3000),
|
|
1249
|
-
});
|
|
1250
|
-
if (res.ok) via = 'server';
|
|
739
|
+
let rejected = [];
|
|
740
|
+
if (batch.ops.length) {
|
|
741
|
+
const res = await postOps(designRoot, `${designRel}/${canvasRel}`, batch.ops);
|
|
742
|
+
if (res) {
|
|
743
|
+
via = 'server';
|
|
744
|
+
rejected = Array.isArray(res.rejected) ? res.rejected : [];
|
|
745
|
+
} else {
|
|
746
|
+
try {
|
|
747
|
+
writeBoardFileAtomic(designRoot, slug, text);
|
|
748
|
+
} catch (err) {
|
|
749
|
+
fail(err instanceof Error ? err.message : String(err), 2);
|
|
1251
750
|
}
|
|
1252
|
-
} catch {
|
|
1253
|
-
/* stale _server.json / server down — fall through to the file write */
|
|
1254
751
|
}
|
|
1255
752
|
}
|
|
1256
|
-
if (via === 'file') {
|
|
1257
|
-
writeFileSync(svgPath, merged, 'utf8');
|
|
1258
|
-
}
|
|
1259
753
|
|
|
1260
|
-
const refs =
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
}
|
|
754
|
+
const refs = Object.fromEntries(batch.refs);
|
|
755
|
+
const out = {
|
|
756
|
+
ok: rejected.length === 0,
|
|
757
|
+
via,
|
|
758
|
+
file,
|
|
759
|
+
created: batch.created.length,
|
|
760
|
+
updated: batch.updated.size,
|
|
761
|
+
deleted: batch.deleted.length,
|
|
762
|
+
refs,
|
|
763
|
+
...(rejected.length ? { rejected } : {}),
|
|
764
|
+
};
|
|
765
|
+
process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
766
|
+
if (rejected.length) process.exitCode = 1;
|
|
1274
767
|
}
|
|
1275
768
|
|
|
1276
769
|
await main();
|