@aetherwing/fcp-drawio 0.2.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/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/adapter.d.ts +38 -0
- package/dist/adapter.js +259 -0
- package/dist/adapter.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/elk-layout.d.ts +49 -0
- package/dist/layout/elk-layout.js +144 -0
- package/dist/layout/elk-layout.js.map +1 -0
- package/dist/lib/drawio-cli.d.ts +22 -0
- package/dist/lib/drawio-cli.js +88 -0
- package/dist/lib/drawio-cli.js.map +1 -0
- package/dist/lib/node-types.d.ts +22 -0
- package/dist/lib/node-types.js +174 -0
- package/dist/lib/node-types.js.map +1 -0
- package/dist/lib/stencils/aws.d.ts +2 -0
- package/dist/lib/stencils/aws.js +69 -0
- package/dist/lib/stencils/aws.js.map +1 -0
- package/dist/lib/stencils/azure.d.ts +2 -0
- package/dist/lib/stencils/azure.js +54 -0
- package/dist/lib/stencils/azure.js.map +1 -0
- package/dist/lib/stencils/cisco.d.ts +2 -0
- package/dist/lib/stencils/cisco.js +30 -0
- package/dist/lib/stencils/cisco.js.map +1 -0
- package/dist/lib/stencils/gcp.d.ts +2 -0
- package/dist/lib/stencils/gcp.js +38 -0
- package/dist/lib/stencils/gcp.js.map +1 -0
- package/dist/lib/stencils/ibm.d.ts +2 -0
- package/dist/lib/stencils/ibm.js +32 -0
- package/dist/lib/stencils/ibm.js.map +1 -0
- package/dist/lib/stencils/index.d.ts +10 -0
- package/dist/lib/stencils/index.js +33 -0
- package/dist/lib/stencils/index.js.map +1 -0
- package/dist/lib/stencils/k8s.d.ts +2 -0
- package/dist/lib/stencils/k8s.js +32 -0
- package/dist/lib/stencils/k8s.js.map +1 -0
- package/dist/lib/stencils/types.d.ts +14 -0
- package/dist/lib/stencils/types.js +2 -0
- package/dist/lib/stencils/types.js.map +1 -0
- package/dist/lib/themes.d.ts +8 -0
- package/dist/lib/themes.js +32 -0
- package/dist/lib/themes.js.map +1 -0
- package/dist/model/defaults.d.ts +3 -0
- package/dist/model/defaults.js +26 -0
- package/dist/model/defaults.js.map +1 -0
- package/dist/model/diagram-model.d.ts +110 -0
- package/dist/model/diagram-model.js +938 -0
- package/dist/model/diagram-model.js.map +1 -0
- package/dist/model/event-log.d.ts +30 -0
- package/dist/model/event-log.js +112 -0
- package/dist/model/event-log.js.map +1 -0
- package/dist/model/id.d.ts +9 -0
- package/dist/model/id.js +35 -0
- package/dist/model/id.js.map +1 -0
- package/dist/model/reference-registry.d.ts +33 -0
- package/dist/model/reference-registry.js +143 -0
- package/dist/model/reference-registry.js.map +1 -0
- package/dist/model/spatial.d.ts +20 -0
- package/dist/model/spatial.js +59 -0
- package/dist/model/spatial.js.map +1 -0
- package/dist/parser/parse-op.d.ts +18 -0
- package/dist/parser/parse-op.js +430 -0
- package/dist/parser/parse-op.js.map +1 -0
- package/dist/parser/resolve-ref.d.ts +27 -0
- package/dist/parser/resolve-ref.js +232 -0
- package/dist/parser/resolve-ref.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +6 -0
- package/dist/parser/tokenizer.js +7 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/serialization/connector-intelligence.d.ts +35 -0
- package/dist/serialization/connector-intelligence.js +336 -0
- package/dist/serialization/connector-intelligence.js.map +1 -0
- package/dist/serialization/deserialize.d.ts +6 -0
- package/dist/serialization/deserialize.js +511 -0
- package/dist/serialization/deserialize.js.map +1 -0
- package/dist/serialization/serialize.d.ts +15 -0
- package/dist/serialization/serialize.js +332 -0
- package/dist/serialization/serialize.js.map +1 -0
- package/dist/server/intent-layer.d.ts +48 -0
- package/dist/server/intent-layer.js +1322 -0
- package/dist/server/intent-layer.js.map +1 -0
- package/dist/server/mcp-server.d.ts +7 -0
- package/dist/server/mcp-server.js +26 -0
- package/dist/server/mcp-server.js.map +1 -0
- package/dist/server/model-map.d.ts +8 -0
- package/dist/server/model-map.js +240 -0
- package/dist/server/model-map.js.map +1 -0
- package/dist/server/query-handler.d.ts +19 -0
- package/dist/server/query-handler.js +148 -0
- package/dist/server/query-handler.js.map +1 -0
- package/dist/server/response-formatter.d.ts +56 -0
- package/dist/server/response-formatter.js +351 -0
- package/dist/server/response-formatter.js.map +1 -0
- package/dist/server/session-handler.d.ts +6 -0
- package/dist/server/session-handler.js +127 -0
- package/dist/server/session-handler.js.map +1 -0
- package/dist/types/index.d.ts +238 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/verb-specs.d.ts +6 -0
- package/dist/verb-specs.js +144 -0
- package/dist/verb-specs.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1322 @@
|
|
|
1
|
+
import { DiagramModel } from "../model/diagram-model.js";
|
|
2
|
+
import { parseOp, isParseError } from "../parser/parse-op.js";
|
|
3
|
+
import { resolveRef } from "../parser/resolve-ref.js";
|
|
4
|
+
import { isShapeType, inferTypeFromLabel, computeDefaultSize } from "../lib/node-types.js";
|
|
5
|
+
import { isThemeName, resolveColor, THEMES } from "../lib/themes.js";
|
|
6
|
+
import { getModelMap } from "./model-map.js";
|
|
7
|
+
import { formatShapeCreated, formatEdgeCreated, formatShapeModified, formatShapeDeleted, formatGroupCreated, } from "./response-formatter.js";
|
|
8
|
+
import { runElkLayout } from "../layout/elk-layout.js";
|
|
9
|
+
import { QueryHandler } from "./query-handler.js";
|
|
10
|
+
import { SessionHandler } from "./session-handler.js";
|
|
11
|
+
import { getStencilPack, listStencilPacks } from "../lib/stencils/index.js";
|
|
12
|
+
export class IntentLayer {
|
|
13
|
+
model;
|
|
14
|
+
queryHandler;
|
|
15
|
+
sessionHandler;
|
|
16
|
+
/** O(1) lookup for loaded stencil entries by their short ID (e.g., "lambda", "s3"). */
|
|
17
|
+
loadedStencilEntries = new Map();
|
|
18
|
+
drawioCliPath;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.model = new DiagramModel();
|
|
21
|
+
this.drawioCliPath = options?.drawioCliPath ?? null;
|
|
22
|
+
this.queryHandler = new QueryHandler(this.model, this.drawioCliPath);
|
|
23
|
+
this.sessionHandler = new SessionHandler(this.model);
|
|
24
|
+
}
|
|
25
|
+
/** Rebuild the stencil entry lookup from loaded packs (e.g., after deserialization). */
|
|
26
|
+
restoreStencilPacks() {
|
|
27
|
+
this.loadedStencilEntries.clear();
|
|
28
|
+
for (const packId of this.model.diagram.loadedStencilPacks) {
|
|
29
|
+
const pack = getStencilPack(packId);
|
|
30
|
+
if (!pack)
|
|
31
|
+
continue;
|
|
32
|
+
for (const entry of pack.entries) {
|
|
33
|
+
if (!this.loadedStencilEntries.has(entry.id)) {
|
|
34
|
+
this.loadedStencilEntries.set(entry.id, entry);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ── Suggestion helpers ──────────────────────────────────
|
|
40
|
+
/** Build a suggestion by replacing a bad ref with the suggested label in the raw op string. */
|
|
41
|
+
buildTypoSuggestion(raw, badRef, suggestedLabel) {
|
|
42
|
+
return raw.replace(badRef, suggestedLabel);
|
|
43
|
+
}
|
|
44
|
+
/** Build a type-qualified suggestion for ambiguous references. */
|
|
45
|
+
buildAmbiguousSuggestion(raw, badRef, shapes) {
|
|
46
|
+
const first = shapes[0];
|
|
47
|
+
const qualified = `${first.type}:${badRef}`;
|
|
48
|
+
return raw.replace(badRef, qualified);
|
|
49
|
+
}
|
|
50
|
+
// ── Main entry points ──────────────────────────────────
|
|
51
|
+
async executeOps(ops) {
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const op of ops) {
|
|
54
|
+
results.push(await this.executeSingleOp(op));
|
|
55
|
+
}
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
executeQuery(query) {
|
|
59
|
+
try {
|
|
60
|
+
return this.queryHandler.dispatch(query.trim());
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
executeSession(action) {
|
|
67
|
+
try {
|
|
68
|
+
const result = this.sessionHandler.dispatch(action.trim());
|
|
69
|
+
// After open/new, restore stencil pack entries from diagram metadata
|
|
70
|
+
this.restoreStencilPacks();
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
getHelp() {
|
|
78
|
+
return getModelMap(this.model.diagram.customTypes, this.model.diagram.customThemes, this.model.diagram.loadedStencilPacks, this.drawioCliPath !== null);
|
|
79
|
+
}
|
|
80
|
+
// ── Single op execution ────────────────────────────────
|
|
81
|
+
async executeSingleOp(opStr) {
|
|
82
|
+
const parsed = parseOp(opStr);
|
|
83
|
+
if (isParseError(parsed)) {
|
|
84
|
+
return { success: false, message: parsed.error };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await this.dispatchOp(parsed);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
message: `Error executing "${parsed.verb}": ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async dispatchOp(op) {
|
|
97
|
+
switch (op.verb) {
|
|
98
|
+
case "add": return this.handleAdd(op);
|
|
99
|
+
case "connect": return this.handleConnect(op);
|
|
100
|
+
case "disconnect": return this.handleDisconnect(op);
|
|
101
|
+
case "style": return this.handleStyle(op);
|
|
102
|
+
case "remove": return this.handleRemove(op);
|
|
103
|
+
case "label": return this.handleLabel(op);
|
|
104
|
+
case "badge": return this.handleBadge(op);
|
|
105
|
+
case "move": return this.handleMove(op);
|
|
106
|
+
case "resize": return this.handleResize(op);
|
|
107
|
+
case "swap": return this.handleSwap(op);
|
|
108
|
+
case "group": return this.handleGroup(op);
|
|
109
|
+
case "ungroup": return this.handleUngroup(op);
|
|
110
|
+
case "define": return this.handleDefine(op);
|
|
111
|
+
case "checkpoint": return this.handleCheckpoint(op);
|
|
112
|
+
case "title": return this.handleTitle(op);
|
|
113
|
+
case "page": return this.handlePage(op);
|
|
114
|
+
case "layer": return this.handleLayer(op);
|
|
115
|
+
case "layout": return this.handleLayout(op);
|
|
116
|
+
case "orient": return this.handleOrient(op);
|
|
117
|
+
case "load": return this.handleLoad(op);
|
|
118
|
+
default:
|
|
119
|
+
return { success: false, message: `Unhandled verb: ${op.verb}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ── Add ────────────────────────────────────────────────
|
|
123
|
+
handleAdd(op) {
|
|
124
|
+
const count = op.params.get("count") ? parseInt(op.params.get("count"), 10) : 1;
|
|
125
|
+
if (isNaN(count) || count < 1) {
|
|
126
|
+
return { success: false, message: "Invalid count" };
|
|
127
|
+
}
|
|
128
|
+
// Resolve type
|
|
129
|
+
let resolvedType;
|
|
130
|
+
const customTypes = this.model.diagram.customTypes;
|
|
131
|
+
let theme = op.params.get("theme");
|
|
132
|
+
let badgeText;
|
|
133
|
+
let baseStyleOverride;
|
|
134
|
+
let skipDefaultTheme = false;
|
|
135
|
+
let stencilSize;
|
|
136
|
+
if (op.type) {
|
|
137
|
+
// 1. Check custom types first
|
|
138
|
+
const ct = customTypes.get(op.type);
|
|
139
|
+
if (ct) {
|
|
140
|
+
resolvedType = ct.base;
|
|
141
|
+
if (!theme && ct.theme)
|
|
142
|
+
theme = ct.theme;
|
|
143
|
+
if (ct.badge)
|
|
144
|
+
badgeText = ct.badge;
|
|
145
|
+
}
|
|
146
|
+
else if (isShapeType(op.type)) {
|
|
147
|
+
// 2. Built-in types
|
|
148
|
+
resolvedType = op.type;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// 3. Check loaded stencil types
|
|
152
|
+
const stencilEntry = this.loadedStencilEntries.get(op.type);
|
|
153
|
+
if (stencilEntry) {
|
|
154
|
+
resolvedType = "svc"; // fallback base type for the model
|
|
155
|
+
baseStyleOverride = stencilEntry.baseStyle;
|
|
156
|
+
stencilSize = { width: stencilEntry.defaultWidth, height: stencilEntry.defaultHeight };
|
|
157
|
+
skipDefaultTheme = !theme; // only skip if user didn't explicitly set theme
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// 4. Unknown type — treat as label, shift: type becomes part of the label
|
|
161
|
+
const inferred = inferTypeFromLabel(op.type);
|
|
162
|
+
if (inferred) {
|
|
163
|
+
resolvedType = inferred;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
resolvedType = "svc";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// No type specified — infer from label
|
|
173
|
+
const target = op.target ?? "Untitled";
|
|
174
|
+
const inferred = inferTypeFromLabel(target);
|
|
175
|
+
resolvedType = inferred ?? "svc";
|
|
176
|
+
}
|
|
177
|
+
// Validate theme (check custom themes too)
|
|
178
|
+
let customThemeColors;
|
|
179
|
+
if (theme) {
|
|
180
|
+
const ct = this.model.diagram.customThemes.get(theme);
|
|
181
|
+
if (ct) {
|
|
182
|
+
customThemeColors = ct;
|
|
183
|
+
}
|
|
184
|
+
else if (!isThemeName(theme)) {
|
|
185
|
+
return { success: false, message: `Unknown theme "${theme}"` };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Resolve near reference
|
|
189
|
+
let nearId;
|
|
190
|
+
const nearRef = op.params.get("near");
|
|
191
|
+
if (nearRef) {
|
|
192
|
+
const resolved = resolveRef(nearRef, this.model.registry, this.model);
|
|
193
|
+
if (resolved.kind === "single") {
|
|
194
|
+
nearId = resolved.shape.id;
|
|
195
|
+
}
|
|
196
|
+
else if (resolved.kind === "none") {
|
|
197
|
+
const suggestion = resolved.suggestedLabel
|
|
198
|
+
? this.buildTypoSuggestion(op.raw, nearRef, resolved.suggestedLabel)
|
|
199
|
+
: undefined;
|
|
200
|
+
return { success: false, message: resolved.message, suggestion };
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, nearRef, resolved.shapes);
|
|
204
|
+
return { success: false, message: resolved.message, suggestion };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Parse size:WxH (before at: since region resolution needs size)
|
|
208
|
+
let size;
|
|
209
|
+
const sizeParam = op.params.get("size");
|
|
210
|
+
if (sizeParam) {
|
|
211
|
+
const parts = sizeParam.toLowerCase().split("x");
|
|
212
|
+
if (parts.length === 2) {
|
|
213
|
+
size = { width: parseFloat(parts[0]), height: parseFloat(parts[1]) };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Parse at:X,Y or at:region-name
|
|
217
|
+
let at;
|
|
218
|
+
const atParam = op.params.get("at");
|
|
219
|
+
if (atParam) {
|
|
220
|
+
// Try region name first
|
|
221
|
+
const computedSize = size ?? computeDefaultSize(resolvedType, op.target ?? "Untitled");
|
|
222
|
+
const regionPos = this.model.resolveCanvasRegion(atParam, computedSize);
|
|
223
|
+
if (regionPos) {
|
|
224
|
+
at = regionPos;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const parts = atParam.split(",");
|
|
228
|
+
if (parts.length === 2) {
|
|
229
|
+
at = { x: parseFloat(parts[0]), y: parseFloat(parts[1]) };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Resolve in: group
|
|
234
|
+
let inGroup;
|
|
235
|
+
const inRef = op.params.get("in");
|
|
236
|
+
if (inRef) {
|
|
237
|
+
const group = this.model.getGroupByName(inRef);
|
|
238
|
+
if (group) {
|
|
239
|
+
inGroup = group.id;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const dir = op.params.get("dir");
|
|
243
|
+
const results = [];
|
|
244
|
+
const warnings = [];
|
|
245
|
+
const labelOverride = op.params.get("label");
|
|
246
|
+
for (let i = 0; i < count; i++) {
|
|
247
|
+
const label = labelOverride
|
|
248
|
+
? (count > 1 ? `${labelOverride}${i + 1}` : labelOverride)
|
|
249
|
+
: (count > 1 ? `${op.target}${i + 1}` : (op.target ?? "Untitled"));
|
|
250
|
+
const shape = this.model.addShape(label, resolvedType, {
|
|
251
|
+
theme: customThemeColors ? undefined : theme,
|
|
252
|
+
near: nearId,
|
|
253
|
+
dir: dir ?? undefined,
|
|
254
|
+
at,
|
|
255
|
+
inGroup,
|
|
256
|
+
size: size ?? stencilSize,
|
|
257
|
+
baseStyleOverride,
|
|
258
|
+
skipDefaultTheme,
|
|
259
|
+
});
|
|
260
|
+
// Apply custom theme colors if using a custom theme
|
|
261
|
+
if (customThemeColors) {
|
|
262
|
+
const style = { ...shape.style };
|
|
263
|
+
style.fillColor = customThemeColors.fill;
|
|
264
|
+
style.strokeColor = customThemeColors.stroke;
|
|
265
|
+
if (customThemeColors.fontColor)
|
|
266
|
+
style.fontColor = customThemeColors.fontColor;
|
|
267
|
+
this.model.modifyShape(shape.id, { style });
|
|
268
|
+
}
|
|
269
|
+
// Apply badge from custom type
|
|
270
|
+
if (badgeText) {
|
|
271
|
+
const badges = [{ text: badgeText, position: "top-right" }];
|
|
272
|
+
this.model.modifyShape(shape.id, { metadata: { ...shape.metadata, badges } });
|
|
273
|
+
}
|
|
274
|
+
results.push(formatShapeCreated(shape));
|
|
275
|
+
// For batch, subsequent shapes position near the previous one
|
|
276
|
+
if (count > 1) {
|
|
277
|
+
nearId = shape.id;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
success: true,
|
|
282
|
+
message: results.join("\n"),
|
|
283
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
// ── Connect ────────────────────────────────────────────
|
|
287
|
+
handleConnect(op) {
|
|
288
|
+
const targets = op.targets;
|
|
289
|
+
const arrows = op.arrows;
|
|
290
|
+
if (!targets || !arrows || targets.length < 2 || arrows.length < 1) {
|
|
291
|
+
return { success: false, message: "connect requires at least REF ARROW REF" };
|
|
292
|
+
}
|
|
293
|
+
// Parse optional params
|
|
294
|
+
const label = op.params.get("label");
|
|
295
|
+
const styleParam = op.params.get("style");
|
|
296
|
+
const sourceArrowParam = op.params.get("source-arrow");
|
|
297
|
+
const targetArrowParam = op.params.get("target-arrow");
|
|
298
|
+
const results = [];
|
|
299
|
+
const warnings = [];
|
|
300
|
+
// Connect consecutive pairs: A -> B -> C creates A->B and B->C
|
|
301
|
+
for (let i = 0; i < arrows.length; i++) {
|
|
302
|
+
const srcRef = targets[i];
|
|
303
|
+
const tgtRef = targets[i + 1];
|
|
304
|
+
if (!srcRef || !tgtRef)
|
|
305
|
+
break;
|
|
306
|
+
const arrow = arrows[i];
|
|
307
|
+
// Resolve source
|
|
308
|
+
const srcResult = resolveRef(srcRef, this.model.registry, this.model);
|
|
309
|
+
if (srcResult.kind === "none") {
|
|
310
|
+
const suggestion = srcResult.suggestedLabel
|
|
311
|
+
? this.buildTypoSuggestion(op.raw, srcRef, srcResult.suggestedLabel)
|
|
312
|
+
: undefined;
|
|
313
|
+
return { success: false, message: srcResult.message, suggestion };
|
|
314
|
+
}
|
|
315
|
+
if (srcResult.kind === "multiple") {
|
|
316
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, srcRef, srcResult.shapes);
|
|
317
|
+
return { success: false, message: srcResult.message, suggestion };
|
|
318
|
+
}
|
|
319
|
+
// Resolve target
|
|
320
|
+
const tgtResult = resolveRef(tgtRef, this.model.registry, this.model);
|
|
321
|
+
if (tgtResult.kind === "none") {
|
|
322
|
+
const suggestion = tgtResult.suggestedLabel
|
|
323
|
+
? this.buildTypoSuggestion(op.raw, tgtRef, tgtResult.suggestedLabel)
|
|
324
|
+
: undefined;
|
|
325
|
+
return { success: false, message: tgtResult.message, suggestion };
|
|
326
|
+
}
|
|
327
|
+
if (tgtResult.kind === "multiple") {
|
|
328
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, tgtRef, tgtResult.shapes);
|
|
329
|
+
return { success: false, message: tgtResult.message, suggestion };
|
|
330
|
+
}
|
|
331
|
+
// Determine arrow types based on operator
|
|
332
|
+
let sourceArrow = "none";
|
|
333
|
+
let targetArrow = "arrow";
|
|
334
|
+
if (arrow === "<->") {
|
|
335
|
+
sourceArrow = "arrow";
|
|
336
|
+
targetArrow = "arrow";
|
|
337
|
+
}
|
|
338
|
+
else if (arrow === "--") {
|
|
339
|
+
sourceArrow = "none";
|
|
340
|
+
targetArrow = "none";
|
|
341
|
+
}
|
|
342
|
+
// Override with explicit params
|
|
343
|
+
if (sourceArrowParam)
|
|
344
|
+
sourceArrow = sourceArrowParam;
|
|
345
|
+
if (targetArrowParam)
|
|
346
|
+
targetArrow = targetArrowParam;
|
|
347
|
+
// Build edge style
|
|
348
|
+
const edgeStyleOverrides = {};
|
|
349
|
+
if (styleParam) {
|
|
350
|
+
switch (styleParam) {
|
|
351
|
+
case "dashed":
|
|
352
|
+
edgeStyleOverrides.dashed = true;
|
|
353
|
+
break;
|
|
354
|
+
case "dotted":
|
|
355
|
+
edgeStyleOverrides.dashed = true;
|
|
356
|
+
edgeStyleOverrides.dotted = true;
|
|
357
|
+
break;
|
|
358
|
+
case "animated":
|
|
359
|
+
edgeStyleOverrides.flowAnimation = true;
|
|
360
|
+
break;
|
|
361
|
+
case "curved":
|
|
362
|
+
edgeStyleOverrides.curved = true;
|
|
363
|
+
break;
|
|
364
|
+
case "thick": break; // handled at render time
|
|
365
|
+
case "orthogonal":
|
|
366
|
+
edgeStyleOverrides.edgeStyle = "orthogonalEdgeStyle";
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Port hints: exit:top/bottom/left/right, entry:top/bottom/left/right
|
|
371
|
+
const exitHint = op.params.get("exit");
|
|
372
|
+
const entryHint = op.params.get("entry");
|
|
373
|
+
const portCoords = {
|
|
374
|
+
top: [0.5, 0],
|
|
375
|
+
bottom: [0.5, 1],
|
|
376
|
+
left: [0, 0.5],
|
|
377
|
+
right: [1, 0.5],
|
|
378
|
+
};
|
|
379
|
+
if (exitHint && portCoords[exitHint]) {
|
|
380
|
+
const [x, y] = portCoords[exitHint];
|
|
381
|
+
edgeStyleOverrides["exitX"] = x;
|
|
382
|
+
edgeStyleOverrides["exitY"] = y;
|
|
383
|
+
}
|
|
384
|
+
if (entryHint && portCoords[entryHint]) {
|
|
385
|
+
const [x, y] = portCoords[entryHint];
|
|
386
|
+
edgeStyleOverrides["entryX"] = x;
|
|
387
|
+
edgeStyleOverrides["entryY"] = y;
|
|
388
|
+
}
|
|
389
|
+
const edge = this.model.addEdge(srcResult.shape.id, tgtResult.shape.id, {
|
|
390
|
+
label: label ?? undefined,
|
|
391
|
+
style: edgeStyleOverrides,
|
|
392
|
+
sourceArrow,
|
|
393
|
+
targetArrow,
|
|
394
|
+
});
|
|
395
|
+
if (!edge) {
|
|
396
|
+
return { success: false, message: `Failed to create edge ${srcRef}->${tgtRef}` };
|
|
397
|
+
}
|
|
398
|
+
results.push(formatEdgeCreated(edge, srcResult.shape.label, tgtResult.shape.label));
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
success: true,
|
|
402
|
+
message: results.join("\n"),
|
|
403
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
// ── Disconnect ─────────────────────────────────────────
|
|
407
|
+
handleDisconnect(op) {
|
|
408
|
+
const targets = op.targets;
|
|
409
|
+
if (!targets || targets.length < 2) {
|
|
410
|
+
return { success: false, message: "disconnect requires REF ARROW REF" };
|
|
411
|
+
}
|
|
412
|
+
const srcResult = resolveRef(targets[0], this.model.registry, this.model);
|
|
413
|
+
if (srcResult.kind !== "single") {
|
|
414
|
+
if (srcResult.kind === "none") {
|
|
415
|
+
const suggestion = srcResult.suggestedLabel
|
|
416
|
+
? this.buildTypoSuggestion(op.raw, targets[0], srcResult.suggestedLabel)
|
|
417
|
+
: undefined;
|
|
418
|
+
return { success: false, message: srcResult.message, suggestion };
|
|
419
|
+
}
|
|
420
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[0], srcResult.shapes);
|
|
421
|
+
return { success: false, message: srcResult.message, suggestion };
|
|
422
|
+
}
|
|
423
|
+
const tgtResult = resolveRef(targets[1], this.model.registry, this.model);
|
|
424
|
+
if (tgtResult.kind !== "single") {
|
|
425
|
+
if (tgtResult.kind === "none") {
|
|
426
|
+
const suggestion = tgtResult.suggestedLabel
|
|
427
|
+
? this.buildTypoSuggestion(op.raw, targets[1], tgtResult.suggestedLabel)
|
|
428
|
+
: undefined;
|
|
429
|
+
return { success: false, message: tgtResult.message, suggestion };
|
|
430
|
+
}
|
|
431
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[1], tgtResult.shapes);
|
|
432
|
+
return { success: false, message: tgtResult.message, suggestion };
|
|
433
|
+
}
|
|
434
|
+
const edge = this.model.findEdge(srcResult.shape.id, tgtResult.shape.id);
|
|
435
|
+
if (!edge) {
|
|
436
|
+
return { success: false, message: `No edge from ${targets[0]} to ${targets[1]}` };
|
|
437
|
+
}
|
|
438
|
+
this.model.removeEdge(edge.id);
|
|
439
|
+
return { success: true, message: `-edge ${srcResult.shape.label}->${tgtResult.shape.label}` };
|
|
440
|
+
}
|
|
441
|
+
// ── Style ──────────────────────────────────────────────
|
|
442
|
+
handleStyle(op) {
|
|
443
|
+
if (!op.target) {
|
|
444
|
+
return { success: false, message: "style requires a target" };
|
|
445
|
+
}
|
|
446
|
+
// Handle @group:NAME — style the group container, not members
|
|
447
|
+
if (op.target.startsWith("@group:")) {
|
|
448
|
+
const groupName = op.target.slice(7);
|
|
449
|
+
const group = this.model.getGroupByName(groupName);
|
|
450
|
+
if (!group) {
|
|
451
|
+
return { success: false, message: `Unknown group "${groupName}"` };
|
|
452
|
+
}
|
|
453
|
+
// Apply style params to group.style
|
|
454
|
+
let groupFontEnable = 0;
|
|
455
|
+
let groupFontDisable = 0;
|
|
456
|
+
for (const [key, value] of op.params) {
|
|
457
|
+
switch (key) {
|
|
458
|
+
case "fill": {
|
|
459
|
+
const color = resolveColor(value);
|
|
460
|
+
if (color)
|
|
461
|
+
group.style.fillColor = color;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
case "stroke": {
|
|
465
|
+
const color = resolveColor(value);
|
|
466
|
+
if (color)
|
|
467
|
+
group.style.strokeColor = color;
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case "font":
|
|
471
|
+
case "font-color": {
|
|
472
|
+
const color = resolveColor(value);
|
|
473
|
+
if (color)
|
|
474
|
+
group.style.fontColor = color;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case "font-size":
|
|
478
|
+
case "fontSize":
|
|
479
|
+
group.style.fontSize = parseInt(value, 10);
|
|
480
|
+
break;
|
|
481
|
+
case "opacity":
|
|
482
|
+
group.style.opacity = parseInt(value, 10);
|
|
483
|
+
break;
|
|
484
|
+
case "rounded":
|
|
485
|
+
group.style.rounded = value === "true" || value === "1";
|
|
486
|
+
break;
|
|
487
|
+
case "dashed":
|
|
488
|
+
group.style.dashed = value === "true" || value === "1";
|
|
489
|
+
break;
|
|
490
|
+
case "shadow":
|
|
491
|
+
group.style.shadow = value === "true" || value === "1";
|
|
492
|
+
break;
|
|
493
|
+
case "bold":
|
|
494
|
+
groupFontEnable |= 1;
|
|
495
|
+
break;
|
|
496
|
+
case "no-bold":
|
|
497
|
+
groupFontDisable |= 1;
|
|
498
|
+
break;
|
|
499
|
+
case "italic":
|
|
500
|
+
groupFontEnable |= 2;
|
|
501
|
+
break;
|
|
502
|
+
case "no-italic":
|
|
503
|
+
groupFontDisable |= 2;
|
|
504
|
+
break;
|
|
505
|
+
case "underline":
|
|
506
|
+
groupFontEnable |= 4;
|
|
507
|
+
break;
|
|
508
|
+
case "no-underline":
|
|
509
|
+
groupFontDisable |= 4;
|
|
510
|
+
break;
|
|
511
|
+
case "font-family":
|
|
512
|
+
group.style.fontFamily = value;
|
|
513
|
+
break;
|
|
514
|
+
case "align":
|
|
515
|
+
group.style.align = value;
|
|
516
|
+
break;
|
|
517
|
+
case "valign":
|
|
518
|
+
group.style.verticalAlign = value;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (groupFontEnable !== 0 || groupFontDisable !== 0) {
|
|
523
|
+
const base = group.style.fontStyle ?? 0;
|
|
524
|
+
group.style.fontStyle = (base | groupFontEnable) & ~groupFontDisable;
|
|
525
|
+
}
|
|
526
|
+
const propList = [...op.params.entries()]
|
|
527
|
+
.filter(([k]) => k !== "theme")
|
|
528
|
+
.map(([k, v]) => v === "true" ? k : `${k}:${v}`)
|
|
529
|
+
.join(" ");
|
|
530
|
+
return {
|
|
531
|
+
success: true,
|
|
532
|
+
message: `*styled group ${groupName} ${propList}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
536
|
+
if (resolved.kind === "none") {
|
|
537
|
+
const suggestion = resolved.suggestedLabel
|
|
538
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
539
|
+
: undefined;
|
|
540
|
+
return { success: false, message: resolved.message, suggestion };
|
|
541
|
+
}
|
|
542
|
+
// Ambiguous non-selector reference is an error
|
|
543
|
+
const isSelector = op.target.startsWith("@");
|
|
544
|
+
if (resolved.kind === "multiple" && !isSelector) {
|
|
545
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
|
|
546
|
+
return { success: false, message: `error: ${resolved.message}`, suggestion };
|
|
547
|
+
}
|
|
548
|
+
const shapes = resolved.kind === "single" ? [resolved.shape] : resolved.shapes;
|
|
549
|
+
// Map style params
|
|
550
|
+
const styleChanges = {};
|
|
551
|
+
// Handle theme param (built-in and custom)
|
|
552
|
+
const themeParam = op.params.get("theme");
|
|
553
|
+
if (themeParam) {
|
|
554
|
+
const customTheme = this.model.diagram.customThemes.get(themeParam);
|
|
555
|
+
if (customTheme) {
|
|
556
|
+
styleChanges.fillColor = customTheme.fill;
|
|
557
|
+
styleChanges.strokeColor = customTheme.stroke;
|
|
558
|
+
if (customTheme.fontColor)
|
|
559
|
+
styleChanges.fontColor = customTheme.fontColor;
|
|
560
|
+
}
|
|
561
|
+
else if (isThemeName(themeParam)) {
|
|
562
|
+
const colors = resolveColor(themeParam);
|
|
563
|
+
// resolveColor returns fill for theme name — use resolveTheme for full colors
|
|
564
|
+
const themeColors = THEMES[themeParam];
|
|
565
|
+
if (themeColors) {
|
|
566
|
+
styleChanges.fillColor = themeColors.fill;
|
|
567
|
+
styleChanges.strokeColor = themeColors.stroke;
|
|
568
|
+
if (themeColors.fontColor)
|
|
569
|
+
styleChanges.fontColor = themeColors.fontColor;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Track fontStyle bitmask operations separately (applied per-shape)
|
|
574
|
+
let fontStyleEnable = 0;
|
|
575
|
+
let fontStyleDisable = 0;
|
|
576
|
+
for (const [key, value] of op.params) {
|
|
577
|
+
switch (key) {
|
|
578
|
+
case "fill": {
|
|
579
|
+
const color = resolveColor(value);
|
|
580
|
+
if (color)
|
|
581
|
+
styleChanges.fillColor = color;
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case "stroke": {
|
|
585
|
+
const color = resolveColor(value);
|
|
586
|
+
if (color)
|
|
587
|
+
styleChanges.strokeColor = color;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
case "font":
|
|
591
|
+
case "font-color": {
|
|
592
|
+
const color = resolveColor(value);
|
|
593
|
+
if (color)
|
|
594
|
+
styleChanges.fontColor = color;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case "font-size":
|
|
598
|
+
case "fontSize":
|
|
599
|
+
styleChanges.fontSize = parseInt(value, 10);
|
|
600
|
+
break;
|
|
601
|
+
case "opacity":
|
|
602
|
+
styleChanges.opacity = parseInt(value, 10);
|
|
603
|
+
break;
|
|
604
|
+
case "rounded":
|
|
605
|
+
styleChanges.rounded = value === "true" || value === "1";
|
|
606
|
+
break;
|
|
607
|
+
case "dashed":
|
|
608
|
+
styleChanges.dashed = value === "true" || value === "1";
|
|
609
|
+
break;
|
|
610
|
+
case "shadow":
|
|
611
|
+
styleChanges.shadow = value === "true" || value === "1";
|
|
612
|
+
break;
|
|
613
|
+
// Text styling — bare boolean flags
|
|
614
|
+
case "bold":
|
|
615
|
+
fontStyleEnable |= 1;
|
|
616
|
+
break;
|
|
617
|
+
case "no-bold":
|
|
618
|
+
fontStyleDisable |= 1;
|
|
619
|
+
break;
|
|
620
|
+
case "italic":
|
|
621
|
+
fontStyleEnable |= 2;
|
|
622
|
+
break;
|
|
623
|
+
case "no-italic":
|
|
624
|
+
fontStyleDisable |= 2;
|
|
625
|
+
break;
|
|
626
|
+
case "underline":
|
|
627
|
+
fontStyleEnable |= 4;
|
|
628
|
+
break;
|
|
629
|
+
case "no-underline":
|
|
630
|
+
fontStyleDisable |= 4;
|
|
631
|
+
break;
|
|
632
|
+
// Font family and alignment — key:value params
|
|
633
|
+
case "font-family":
|
|
634
|
+
styleChanges.fontFamily = value;
|
|
635
|
+
break;
|
|
636
|
+
case "align":
|
|
637
|
+
styleChanges.align = value;
|
|
638
|
+
break;
|
|
639
|
+
case "valign":
|
|
640
|
+
styleChanges.verticalAlign = value;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const hasFontStyleOps = fontStyleEnable !== 0 || fontStyleDisable !== 0;
|
|
645
|
+
let modifiedCount = 0;
|
|
646
|
+
for (const shape of shapes) {
|
|
647
|
+
const newStyle = { ...shape.style, ...styleChanges };
|
|
648
|
+
// Apply fontStyle bitmask operations relative to each shape's existing value
|
|
649
|
+
if (hasFontStyleOps) {
|
|
650
|
+
const base = shape.style.fontStyle ?? 0;
|
|
651
|
+
newStyle.fontStyle = (base | fontStyleEnable) & ~fontStyleDisable;
|
|
652
|
+
}
|
|
653
|
+
const result = this.model.modifyShape(shape.id, { style: newStyle });
|
|
654
|
+
if (result)
|
|
655
|
+
modifiedCount++;
|
|
656
|
+
}
|
|
657
|
+
const propList = [...op.params.entries()]
|
|
658
|
+
.filter(([k]) => k !== "theme")
|
|
659
|
+
.map(([k, v]) => v === "true" ? k : `${k}:${v}`)
|
|
660
|
+
.join(" ");
|
|
661
|
+
return {
|
|
662
|
+
success: true,
|
|
663
|
+
message: `*styled ${op.target} ${propList} (${modifiedCount} shape${modifiedCount !== 1 ? "s" : ""})`,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
// ── Remove ─────────────────────────────────────────────
|
|
667
|
+
handleRemove(op) {
|
|
668
|
+
if (!op.target) {
|
|
669
|
+
return { success: false, message: "remove requires a target" };
|
|
670
|
+
}
|
|
671
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
672
|
+
if (resolved.kind === "none") {
|
|
673
|
+
const suggestion = resolved.suggestedLabel
|
|
674
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
675
|
+
: undefined;
|
|
676
|
+
return { success: false, message: resolved.message, suggestion };
|
|
677
|
+
}
|
|
678
|
+
const shapes = resolved.kind === "single" ? [resolved.shape] : resolved.shapes;
|
|
679
|
+
const results = [];
|
|
680
|
+
for (const shape of shapes) {
|
|
681
|
+
const removed = this.model.removeShape(shape.id);
|
|
682
|
+
if (removed)
|
|
683
|
+
results.push(formatShapeDeleted(removed));
|
|
684
|
+
}
|
|
685
|
+
return { success: true, message: results.join("\n") };
|
|
686
|
+
}
|
|
687
|
+
// ── Label ──────────────────────────────────────────────
|
|
688
|
+
handleLabel(op) {
|
|
689
|
+
if (!op.target) {
|
|
690
|
+
return { success: false, message: "label requires a target" };
|
|
691
|
+
}
|
|
692
|
+
const newText = op.params.get("text");
|
|
693
|
+
if (!newText) {
|
|
694
|
+
return { success: false, message: "label requires new text" };
|
|
695
|
+
}
|
|
696
|
+
// Edge form: label A -> B "text"
|
|
697
|
+
if (op.targets && op.targets.length >= 2 && op.arrows && op.arrows.length > 0) {
|
|
698
|
+
const srcResolved = resolveRef(op.targets[0], this.model.registry, this.model);
|
|
699
|
+
if (srcResolved.kind !== "single") {
|
|
700
|
+
return { success: false, message: srcResolved.message };
|
|
701
|
+
}
|
|
702
|
+
const tgtResolved = resolveRef(op.targets[1], this.model.registry, this.model);
|
|
703
|
+
if (tgtResolved.kind !== "single") {
|
|
704
|
+
return { success: false, message: tgtResolved.message };
|
|
705
|
+
}
|
|
706
|
+
const edge = this.model.findEdge(srcResolved.shape.id, tgtResolved.shape.id);
|
|
707
|
+
if (!edge) {
|
|
708
|
+
return { success: false, message: `No edge from ${op.targets[0]} to ${op.targets[1]}` };
|
|
709
|
+
}
|
|
710
|
+
const result = this.model.modifyEdge(edge.id, { label: newText });
|
|
711
|
+
if (!result) {
|
|
712
|
+
return { success: false, message: `Failed to relabel edge` };
|
|
713
|
+
}
|
|
714
|
+
return { success: true, message: `~${op.targets[0]}->${op.targets[1]} labeled "${newText}"` };
|
|
715
|
+
}
|
|
716
|
+
// Shape form: label REF "text"
|
|
717
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
718
|
+
if (resolved.kind !== "single") {
|
|
719
|
+
if (resolved.kind === "none") {
|
|
720
|
+
const suggestion = resolved.suggestedLabel
|
|
721
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
722
|
+
: undefined;
|
|
723
|
+
return { success: false, message: resolved.message, suggestion };
|
|
724
|
+
}
|
|
725
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
|
|
726
|
+
return { success: false, message: resolved.message, suggestion };
|
|
727
|
+
}
|
|
728
|
+
const result = this.model.modifyShape(resolved.shape.id, { label: newText });
|
|
729
|
+
if (!result) {
|
|
730
|
+
return { success: false, message: `Failed to relabel ${op.target}` };
|
|
731
|
+
}
|
|
732
|
+
return { success: true, message: formatShapeModified(result, `labeled "${newText}"`) };
|
|
733
|
+
}
|
|
734
|
+
// ── Badge ──────────────────────────────────────────────
|
|
735
|
+
handleBadge(op) {
|
|
736
|
+
if (!op.target) {
|
|
737
|
+
return { success: false, message: "badge requires a target" };
|
|
738
|
+
}
|
|
739
|
+
const text = op.params.get("text");
|
|
740
|
+
if (!text) {
|
|
741
|
+
return { success: false, message: "badge requires text" };
|
|
742
|
+
}
|
|
743
|
+
const position = (op.params.get("pos") ?? "top-right");
|
|
744
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
745
|
+
if (resolved.kind !== "single") {
|
|
746
|
+
if (resolved.kind === "none") {
|
|
747
|
+
const suggestion = resolved.suggestedLabel
|
|
748
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
749
|
+
: undefined;
|
|
750
|
+
return { success: false, message: resolved.message, suggestion };
|
|
751
|
+
}
|
|
752
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
|
|
753
|
+
return { success: false, message: resolved.message, suggestion };
|
|
754
|
+
}
|
|
755
|
+
const shape = resolved.shape;
|
|
756
|
+
const existingBadges = shape.metadata.badges ?? [];
|
|
757
|
+
const newBadge = { text, position };
|
|
758
|
+
const badges = [...existingBadges, newBadge];
|
|
759
|
+
const result = this.model.modifyShape(shape.id, {
|
|
760
|
+
metadata: { ...shape.metadata, badges },
|
|
761
|
+
});
|
|
762
|
+
if (!result) {
|
|
763
|
+
return { success: false, message: `Failed to badge ${op.target}` };
|
|
764
|
+
}
|
|
765
|
+
return { success: true, message: formatShapeModified(result, `badge "${text}"`) };
|
|
766
|
+
}
|
|
767
|
+
// ── Move ───────────────────────────────────────────────
|
|
768
|
+
handleMove(op) {
|
|
769
|
+
if (!op.target) {
|
|
770
|
+
return { success: false, message: "move requires a target" };
|
|
771
|
+
}
|
|
772
|
+
const strict = op.params.get("strict") === "true";
|
|
773
|
+
// Handle @group:Name — move entire group
|
|
774
|
+
if (op.target.startsWith("@group:")) {
|
|
775
|
+
return this.handleMoveGroup(op, strict);
|
|
776
|
+
}
|
|
777
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
778
|
+
if (resolved.kind !== "single") {
|
|
779
|
+
if (resolved.kind === "none") {
|
|
780
|
+
const suggestion = resolved.suggestedLabel
|
|
781
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
782
|
+
: undefined;
|
|
783
|
+
return { success: false, message: resolved.message, suggestion };
|
|
784
|
+
}
|
|
785
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
|
|
786
|
+
return { success: false, message: resolved.message, suggestion };
|
|
787
|
+
}
|
|
788
|
+
const shape = resolved.shape;
|
|
789
|
+
let newX = shape.bounds.x;
|
|
790
|
+
let newY = shape.bounds.y;
|
|
791
|
+
// to:X,Y or to:region-name
|
|
792
|
+
const toParam = op.params.get("to");
|
|
793
|
+
if (toParam) {
|
|
794
|
+
const regionPos = this.model.resolveCanvasRegion(toParam, {
|
|
795
|
+
width: shape.bounds.width,
|
|
796
|
+
height: shape.bounds.height,
|
|
797
|
+
});
|
|
798
|
+
if (regionPos) {
|
|
799
|
+
newX = regionPos.x;
|
|
800
|
+
newY = regionPos.y;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
const parts = toParam.split(",");
|
|
804
|
+
if (parts.length === 2) {
|
|
805
|
+
newX = parseFloat(parts[0]);
|
|
806
|
+
newY = parseFloat(parts[1]);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// near:REF dir:DIR — delegate to model's positioning logic
|
|
811
|
+
const nearRef = op.params.get("near");
|
|
812
|
+
if (nearRef) {
|
|
813
|
+
const nearResolved = resolveRef(nearRef, this.model.registry, this.model);
|
|
814
|
+
if (nearResolved.kind === "single") {
|
|
815
|
+
const dir = op.params.get("dir") ?? "below";
|
|
816
|
+
const pos = this.model.positionRelativeTo(nearResolved.shape.bounds, { width: shape.bounds.width, height: shape.bounds.height }, dir);
|
|
817
|
+
newX = pos.x;
|
|
818
|
+
newY = pos.y;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const result = this.model.modifyShape(shape.id, {
|
|
822
|
+
bounds: { ...shape.bounds, x: newX, y: newY },
|
|
823
|
+
});
|
|
824
|
+
if (!result) {
|
|
825
|
+
return { success: false, message: `Failed to move ${op.target}` };
|
|
826
|
+
}
|
|
827
|
+
// Collision detection (unless strict mode)
|
|
828
|
+
let shifted = 0;
|
|
829
|
+
if (!strict) {
|
|
830
|
+
shifted = this.model.detectAndResolveCollisions(shape.id, false);
|
|
831
|
+
}
|
|
832
|
+
const shiftNote = shifted > 0 ? ` (shifted ${shifted} item${shifted !== 1 ? "s" : ""})` : "";
|
|
833
|
+
return { success: true, message: `@moved ${result.label} to (${newX},${newY})${shiftNote}` };
|
|
834
|
+
}
|
|
835
|
+
handleMoveGroup(op, strict) {
|
|
836
|
+
const groupName = op.target.slice(7); // strip "@group:"
|
|
837
|
+
const group = this.model.getGroupByName(groupName);
|
|
838
|
+
if (!group) {
|
|
839
|
+
return { success: false, message: `Unknown group "${groupName}"` };
|
|
840
|
+
}
|
|
841
|
+
const toParam = op.params.get("to");
|
|
842
|
+
if (!toParam) {
|
|
843
|
+
return { success: false, message: "move @group requires to:X,Y or to:region" };
|
|
844
|
+
}
|
|
845
|
+
// Resolve target position for the group
|
|
846
|
+
let targetX;
|
|
847
|
+
let targetY;
|
|
848
|
+
const regionPos = this.model.resolveCanvasRegion(toParam, {
|
|
849
|
+
width: group.bounds.width,
|
|
850
|
+
height: group.bounds.height,
|
|
851
|
+
});
|
|
852
|
+
if (regionPos) {
|
|
853
|
+
targetX = regionPos.x;
|
|
854
|
+
targetY = regionPos.y;
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
const parts = toParam.split(",");
|
|
858
|
+
if (parts.length !== 2) {
|
|
859
|
+
return { success: false, message: `Invalid move target: ${toParam}` };
|
|
860
|
+
}
|
|
861
|
+
targetX = parseFloat(parts[0]);
|
|
862
|
+
targetY = parseFloat(parts[1]);
|
|
863
|
+
}
|
|
864
|
+
// Compute delta from current group bounds
|
|
865
|
+
const dx = targetX - group.bounds.x;
|
|
866
|
+
const dy = targetY - group.bounds.y;
|
|
867
|
+
const page = this.model.getActivePage();
|
|
868
|
+
let movedCount = 0;
|
|
869
|
+
// Move all member shapes by the delta
|
|
870
|
+
for (const memberId of group.memberIds) {
|
|
871
|
+
const shape = page.shapes.get(memberId);
|
|
872
|
+
if (shape) {
|
|
873
|
+
this.model.modifyShape(shape.id, {
|
|
874
|
+
bounds: { ...shape.bounds, x: shape.bounds.x + dx, y: shape.bounds.y + dy },
|
|
875
|
+
});
|
|
876
|
+
movedCount++;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// Recompute group bounds
|
|
880
|
+
this.model.recomputeGroupBoundsPublic(group.id);
|
|
881
|
+
// Collision detection for the group
|
|
882
|
+
let shifted = 0;
|
|
883
|
+
if (!strict) {
|
|
884
|
+
shifted = this.model.detectAndResolveCollisions(group.id, true);
|
|
885
|
+
}
|
|
886
|
+
const shiftNote = shifted > 0 ? ` (shifted ${shifted} item${shifted !== 1 ? "s" : ""})` : "";
|
|
887
|
+
return {
|
|
888
|
+
success: true,
|
|
889
|
+
message: `@moved group ${groupName} (${movedCount} shapes)${shiftNote}`,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
// ── Resize ─────────────────────────────────────────────
|
|
893
|
+
handleResize(op) {
|
|
894
|
+
if (!op.target) {
|
|
895
|
+
return { success: false, message: "resize requires a target" };
|
|
896
|
+
}
|
|
897
|
+
const resolved = resolveRef(op.target, this.model.registry, this.model);
|
|
898
|
+
if (resolved.kind !== "single") {
|
|
899
|
+
if (resolved.kind === "none") {
|
|
900
|
+
const suggestion = resolved.suggestedLabel
|
|
901
|
+
? this.buildTypoSuggestion(op.raw, op.target, resolved.suggestedLabel)
|
|
902
|
+
: undefined;
|
|
903
|
+
return { success: false, message: resolved.message, suggestion };
|
|
904
|
+
}
|
|
905
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, op.target, resolved.shapes);
|
|
906
|
+
return { success: false, message: resolved.message, suggestion };
|
|
907
|
+
}
|
|
908
|
+
const shape = resolved.shape;
|
|
909
|
+
const toParam = op.params.get("to");
|
|
910
|
+
if (!toParam) {
|
|
911
|
+
return { success: false, message: "resize requires to:WxH" };
|
|
912
|
+
}
|
|
913
|
+
const parts = toParam.toLowerCase().split("x");
|
|
914
|
+
if (parts.length !== 2) {
|
|
915
|
+
return { success: false, message: `Invalid size format: ${toParam}` };
|
|
916
|
+
}
|
|
917
|
+
const width = parseFloat(parts[0]);
|
|
918
|
+
const height = parseFloat(parts[1]);
|
|
919
|
+
const result = this.model.modifyShape(shape.id, {
|
|
920
|
+
bounds: { ...shape.bounds, width, height },
|
|
921
|
+
});
|
|
922
|
+
if (!result) {
|
|
923
|
+
return { success: false, message: `Failed to resize ${op.target}` };
|
|
924
|
+
}
|
|
925
|
+
return { success: true, message: `*resized ${result.label} to ${width}x${height}` };
|
|
926
|
+
}
|
|
927
|
+
// ── Swap ───────────────────────────────────────────────
|
|
928
|
+
handleSwap(op) {
|
|
929
|
+
const targets = op.targets;
|
|
930
|
+
if (!targets || targets.length < 2) {
|
|
931
|
+
return { success: false, message: "swap requires two targets" };
|
|
932
|
+
}
|
|
933
|
+
const r1 = resolveRef(targets[0], this.model.registry, this.model);
|
|
934
|
+
const r2 = resolveRef(targets[1], this.model.registry, this.model);
|
|
935
|
+
if (r1.kind !== "single") {
|
|
936
|
+
if (r1.kind === "none") {
|
|
937
|
+
const suggestion = r1.suggestedLabel
|
|
938
|
+
? this.buildTypoSuggestion(op.raw, targets[0], r1.suggestedLabel)
|
|
939
|
+
: undefined;
|
|
940
|
+
return { success: false, message: r1.message, suggestion };
|
|
941
|
+
}
|
|
942
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[0], r1.shapes);
|
|
943
|
+
return { success: false, message: r1.message, suggestion };
|
|
944
|
+
}
|
|
945
|
+
if (r2.kind !== "single") {
|
|
946
|
+
if (r2.kind === "none") {
|
|
947
|
+
const suggestion = r2.suggestedLabel
|
|
948
|
+
? this.buildTypoSuggestion(op.raw, targets[1], r2.suggestedLabel)
|
|
949
|
+
: undefined;
|
|
950
|
+
return { success: false, message: r2.message, suggestion };
|
|
951
|
+
}
|
|
952
|
+
const suggestion = this.buildAmbiguousSuggestion(op.raw, targets[1], r2.shapes);
|
|
953
|
+
return { success: false, message: r2.message, suggestion };
|
|
954
|
+
}
|
|
955
|
+
const bounds1 = { ...r1.shape.bounds };
|
|
956
|
+
const bounds2 = { ...r2.shape.bounds };
|
|
957
|
+
this.model.modifyShape(r1.shape.id, { bounds: bounds2 });
|
|
958
|
+
this.model.modifyShape(r2.shape.id, { bounds: bounds1 });
|
|
959
|
+
return {
|
|
960
|
+
success: true,
|
|
961
|
+
message: `@swapped ${r1.shape.label} <-> ${r2.shape.label}`,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
// ── Group ──────────────────────────────────────────────
|
|
965
|
+
handleGroup(op) {
|
|
966
|
+
const targets = op.targets;
|
|
967
|
+
if (!targets || targets.length === 0) {
|
|
968
|
+
return { success: false, message: "group requires targets" };
|
|
969
|
+
}
|
|
970
|
+
const name = op.params.get("as");
|
|
971
|
+
if (!name) {
|
|
972
|
+
return { success: false, message: "group requires as:NAME" };
|
|
973
|
+
}
|
|
974
|
+
// Resolve all targets to shape IDs
|
|
975
|
+
const memberIds = [];
|
|
976
|
+
for (const ref of targets) {
|
|
977
|
+
const resolved = resolveRef(ref, this.model.registry, this.model);
|
|
978
|
+
if (resolved.kind === "single") {
|
|
979
|
+
memberIds.push(resolved.shape.id);
|
|
980
|
+
}
|
|
981
|
+
else if (resolved.kind === "multiple") {
|
|
982
|
+
for (const s of resolved.shapes) {
|
|
983
|
+
memberIds.push(s.id);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
const suggestion = resolved.suggestedLabel
|
|
988
|
+
? this.buildTypoSuggestion(op.raw, ref, resolved.suggestedLabel)
|
|
989
|
+
: undefined;
|
|
990
|
+
return { success: false, message: resolved.message, suggestion };
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const group = this.model.createGroup(name, memberIds);
|
|
994
|
+
if (!group) {
|
|
995
|
+
return { success: false, message: "Failed to create group" };
|
|
996
|
+
}
|
|
997
|
+
// Apply label: param as display name (group.name is used as XML value)
|
|
998
|
+
const labelParam = op.params.get("label");
|
|
999
|
+
if (labelParam) {
|
|
1000
|
+
group.name = labelParam.replace(/_/g, " ");
|
|
1001
|
+
}
|
|
1002
|
+
// Apply theme: param to group style
|
|
1003
|
+
const themeParam = op.params.get("theme");
|
|
1004
|
+
if (themeParam) {
|
|
1005
|
+
const customTheme = this.model.diagram.customThemes.get(themeParam);
|
|
1006
|
+
if (customTheme) {
|
|
1007
|
+
group.style.fillColor = customTheme.fill;
|
|
1008
|
+
group.style.strokeColor = customTheme.stroke;
|
|
1009
|
+
if (customTheme.fontColor)
|
|
1010
|
+
group.style.fontColor = customTheme.fontColor;
|
|
1011
|
+
}
|
|
1012
|
+
else if (isThemeName(themeParam)) {
|
|
1013
|
+
const colors = THEMES[themeParam];
|
|
1014
|
+
group.style.fillColor = colors.fill;
|
|
1015
|
+
group.style.strokeColor = colors.stroke;
|
|
1016
|
+
if (colors.fontColor)
|
|
1017
|
+
group.style.fontColor = colors.fontColor;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return { success: true, message: formatGroupCreated(group) };
|
|
1021
|
+
}
|
|
1022
|
+
// ── Ungroup ────────────────────────────────────────────
|
|
1023
|
+
handleUngroup(op) {
|
|
1024
|
+
if (!op.target) {
|
|
1025
|
+
return { success: false, message: "ungroup requires a group name" };
|
|
1026
|
+
}
|
|
1027
|
+
const group = this.model.getGroupByName(op.target);
|
|
1028
|
+
if (!group) {
|
|
1029
|
+
return { success: false, message: `Unknown group "${op.target}"` };
|
|
1030
|
+
}
|
|
1031
|
+
const dissolved = this.model.dissolveGroup(group.id);
|
|
1032
|
+
if (!dissolved) {
|
|
1033
|
+
return { success: false, message: `Failed to ungroup "${op.target}"` };
|
|
1034
|
+
}
|
|
1035
|
+
return { success: true, message: `!ungrouped ${dissolved.name} (${dissolved.memberIds.size} shapes)` };
|
|
1036
|
+
}
|
|
1037
|
+
// ── Define ─────────────────────────────────────────────
|
|
1038
|
+
handleDefine(op) {
|
|
1039
|
+
if (!op.target) {
|
|
1040
|
+
return { success: false, message: "define requires a name" };
|
|
1041
|
+
}
|
|
1042
|
+
// Handle "define theme NAME fill:# stroke:#"
|
|
1043
|
+
if (op.target === "theme") {
|
|
1044
|
+
const themeName = op.targets && op.targets.length > 1 ? op.targets[1] : op.params.get("name");
|
|
1045
|
+
if (!themeName) {
|
|
1046
|
+
return { success: false, message: "define theme requires a name" };
|
|
1047
|
+
}
|
|
1048
|
+
const fill = op.params.get("fill");
|
|
1049
|
+
const stroke = op.params.get("stroke");
|
|
1050
|
+
if (!fill || !stroke) {
|
|
1051
|
+
return { success: false, message: "define theme requires fill:# and stroke:#" };
|
|
1052
|
+
}
|
|
1053
|
+
const fontColor = op.params.get("font-color");
|
|
1054
|
+
this.model.defineCustomTheme(themeName, fill, stroke, fontColor);
|
|
1055
|
+
return {
|
|
1056
|
+
success: true,
|
|
1057
|
+
message: `defined theme ${themeName} (${fill} / ${stroke}${fontColor ? ` font:${fontColor}` : ""})`,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
const baseName = op.params.get("base");
|
|
1061
|
+
if (!baseName || !isShapeType(baseName)) {
|
|
1062
|
+
return { success: false, message: `define requires base:TYPE (got "${baseName ?? "none"}")` };
|
|
1063
|
+
}
|
|
1064
|
+
const theme = op.params.get("theme");
|
|
1065
|
+
const badge = op.params.get("badge");
|
|
1066
|
+
let size;
|
|
1067
|
+
const sizeParam = op.params.get("size");
|
|
1068
|
+
if (sizeParam) {
|
|
1069
|
+
const parts = sizeParam.toLowerCase().split("x");
|
|
1070
|
+
if (parts.length === 2) {
|
|
1071
|
+
size = { width: parseFloat(parts[0]), height: parseFloat(parts[1]) };
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const ct = this.model.defineCustomType(op.target, baseName, { theme, badge, size });
|
|
1075
|
+
return {
|
|
1076
|
+
success: true,
|
|
1077
|
+
message: `defined ${ct.name} (base:${ct.base}${ct.theme ? ` theme:${ct.theme}` : ""}${ct.badge ? ` badge:${ct.badge}` : ""})`,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
// ── Checkpoint ─────────────────────────────────────────
|
|
1081
|
+
handleCheckpoint(op) {
|
|
1082
|
+
if (!op.target) {
|
|
1083
|
+
return { success: false, message: "checkpoint requires a name" };
|
|
1084
|
+
}
|
|
1085
|
+
this.model.checkpoint(op.target);
|
|
1086
|
+
return { success: true, message: `checkpoint "${op.target}" created` };
|
|
1087
|
+
}
|
|
1088
|
+
// ── Title ──────────────────────────────────────────────
|
|
1089
|
+
handleTitle(op) {
|
|
1090
|
+
if (!op.target) {
|
|
1091
|
+
return { success: false, message: "title requires a name" };
|
|
1092
|
+
}
|
|
1093
|
+
this.model.setTitle(op.target);
|
|
1094
|
+
return { success: true, message: `title set to "${op.target}"` };
|
|
1095
|
+
}
|
|
1096
|
+
// ── Page ───────────────────────────────────────────────
|
|
1097
|
+
handlePage(op) {
|
|
1098
|
+
const sub = op.subcommand;
|
|
1099
|
+
if (!sub) {
|
|
1100
|
+
return { success: false, message: "page requires a subcommand" };
|
|
1101
|
+
}
|
|
1102
|
+
switch (sub) {
|
|
1103
|
+
case "add": {
|
|
1104
|
+
const name = op.target;
|
|
1105
|
+
if (!name)
|
|
1106
|
+
return { success: false, message: "page add requires a name" };
|
|
1107
|
+
const page = this.model.addPage(name);
|
|
1108
|
+
return { success: true, message: `+page ${page.name}` };
|
|
1109
|
+
}
|
|
1110
|
+
case "switch": {
|
|
1111
|
+
const name = op.target;
|
|
1112
|
+
if (!name)
|
|
1113
|
+
return { success: false, message: "page switch requires a name" };
|
|
1114
|
+
const page = this.model.switchPage(name);
|
|
1115
|
+
if (!page)
|
|
1116
|
+
return { success: false, message: `Unknown page "${name}"` };
|
|
1117
|
+
return { success: true, message: `switched to page ${page.name}` };
|
|
1118
|
+
}
|
|
1119
|
+
case "remove": {
|
|
1120
|
+
const name = op.target;
|
|
1121
|
+
if (!name)
|
|
1122
|
+
return { success: false, message: "page remove requires a name" };
|
|
1123
|
+
const ok = this.model.removePage(name);
|
|
1124
|
+
if (!ok)
|
|
1125
|
+
return { success: false, message: `Cannot remove page "${name}"` };
|
|
1126
|
+
return { success: true, message: `-page ${name}` };
|
|
1127
|
+
}
|
|
1128
|
+
case "list": {
|
|
1129
|
+
const activePage = this.model.getActivePage();
|
|
1130
|
+
const lines = this.model.diagram.pages.map((p) => {
|
|
1131
|
+
const markers = [];
|
|
1132
|
+
if (p.id === activePage.id)
|
|
1133
|
+
markers.push("active");
|
|
1134
|
+
markers.push(`${p.shapes.size} shapes`);
|
|
1135
|
+
markers.push(`${p.edges.size} edges`);
|
|
1136
|
+
const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : "";
|
|
1137
|
+
return ` ${p.name}${suffix}`;
|
|
1138
|
+
});
|
|
1139
|
+
return { success: true, message: `pages:\n${lines.join("\n")}` };
|
|
1140
|
+
}
|
|
1141
|
+
case "duplicate": {
|
|
1142
|
+
// Stub — not implemented in model yet
|
|
1143
|
+
return { success: false, message: "page duplicate not yet implemented" };
|
|
1144
|
+
}
|
|
1145
|
+
default:
|
|
1146
|
+
return { success: false, message: `Unknown page subcommand "${sub}"` };
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// ── Layer ──────────────────────────────────────────────
|
|
1150
|
+
handleLayer(op) {
|
|
1151
|
+
const sub = op.subcommand;
|
|
1152
|
+
if (!sub) {
|
|
1153
|
+
return { success: false, message: "layer requires a subcommand" };
|
|
1154
|
+
}
|
|
1155
|
+
const page = this.model.getActivePage();
|
|
1156
|
+
switch (sub) {
|
|
1157
|
+
case "create": {
|
|
1158
|
+
const name = op.target;
|
|
1159
|
+
if (!name)
|
|
1160
|
+
return { success: false, message: "layer create requires a name" };
|
|
1161
|
+
this.model.addLayer(name);
|
|
1162
|
+
return { success: true, message: `+layer ${name}` };
|
|
1163
|
+
}
|
|
1164
|
+
case "show": {
|
|
1165
|
+
const name = op.target;
|
|
1166
|
+
if (!name)
|
|
1167
|
+
return { success: false, message: "layer show requires a name" };
|
|
1168
|
+
const layer = page.layers.find((l) => l.name === name);
|
|
1169
|
+
if (!layer)
|
|
1170
|
+
return { success: false, message: `Unknown layer "${name}"` };
|
|
1171
|
+
this.model.modifyLayer(layer.id, { visible: true });
|
|
1172
|
+
return { success: true, message: `layer ${name} visible` };
|
|
1173
|
+
}
|
|
1174
|
+
case "hide": {
|
|
1175
|
+
const name = op.target;
|
|
1176
|
+
if (!name)
|
|
1177
|
+
return { success: false, message: "layer hide requires a name" };
|
|
1178
|
+
const layer = page.layers.find((l) => l.name === name);
|
|
1179
|
+
if (!layer)
|
|
1180
|
+
return { success: false, message: `Unknown layer "${name}"` };
|
|
1181
|
+
this.model.modifyLayer(layer.id, { visible: false });
|
|
1182
|
+
return { success: true, message: `layer ${name} hidden` };
|
|
1183
|
+
}
|
|
1184
|
+
case "switch": {
|
|
1185
|
+
const name = op.target;
|
|
1186
|
+
if (!name)
|
|
1187
|
+
return { success: false, message: "layer switch requires a name" };
|
|
1188
|
+
const layer = page.layers.find((l) => l.name === name);
|
|
1189
|
+
if (!layer)
|
|
1190
|
+
return { success: false, message: `Unknown layer "${name}"` };
|
|
1191
|
+
page.defaultLayer = layer.id;
|
|
1192
|
+
return { success: true, message: `switched to layer ${name}` };
|
|
1193
|
+
}
|
|
1194
|
+
case "list": {
|
|
1195
|
+
const lines = page.layers.map((l) => {
|
|
1196
|
+
const markers = [];
|
|
1197
|
+
if (l.id === page.defaultLayer)
|
|
1198
|
+
markers.push("active");
|
|
1199
|
+
if (!l.visible)
|
|
1200
|
+
markers.push("hidden");
|
|
1201
|
+
if (l.locked)
|
|
1202
|
+
markers.push("locked");
|
|
1203
|
+
const suffix = markers.length > 0 ? ` (${markers.join(", ")})` : "";
|
|
1204
|
+
return ` ${l.name}${suffix}`;
|
|
1205
|
+
});
|
|
1206
|
+
return { success: true, message: `layers:\n${lines.join("\n")}` };
|
|
1207
|
+
}
|
|
1208
|
+
case "move": {
|
|
1209
|
+
// Stub for moving shapes between layers
|
|
1210
|
+
return { success: false, message: "layer move not yet implemented" };
|
|
1211
|
+
}
|
|
1212
|
+
default:
|
|
1213
|
+
return { success: false, message: `Unknown layer subcommand "${sub}"` };
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// ── Layout ─────────────────────────────────────────────
|
|
1217
|
+
async handleLayout(op) {
|
|
1218
|
+
// Parse algorithm (default: layered)
|
|
1219
|
+
const algoParam = op.params.get("algo") ?? "layered";
|
|
1220
|
+
const validAlgos = new Set(["layered", "force", "tree"]);
|
|
1221
|
+
if (!validAlgos.has(algoParam)) {
|
|
1222
|
+
return { success: false, message: `Unknown algorithm "${algoParam}". Use: layered, force, tree` };
|
|
1223
|
+
}
|
|
1224
|
+
// Parse direction (default: TB)
|
|
1225
|
+
const dirParam = (op.params.get("dir") ?? "TB").toUpperCase();
|
|
1226
|
+
const validDirs = new Set(["TB", "LR", "BT", "RL"]);
|
|
1227
|
+
if (!validDirs.has(dirParam)) {
|
|
1228
|
+
return { success: false, message: `Unknown direction "${dirParam}". Use: TB, LR, BT, RL` };
|
|
1229
|
+
}
|
|
1230
|
+
// Parse spacing
|
|
1231
|
+
const spacing = op.params.has("spacing") ? parseInt(op.params.get("spacing"), 10) : undefined;
|
|
1232
|
+
const options = {
|
|
1233
|
+
algorithm: algoParam,
|
|
1234
|
+
direction: dirParam,
|
|
1235
|
+
spacing,
|
|
1236
|
+
};
|
|
1237
|
+
try {
|
|
1238
|
+
const page = this.model.getActivePage();
|
|
1239
|
+
const result = await runElkLayout(page, options);
|
|
1240
|
+
const count = this.model.applyLayout(result);
|
|
1241
|
+
// Auto-set flow direction to match layout
|
|
1242
|
+
this.model.setFlowDirection(dirParam);
|
|
1243
|
+
return {
|
|
1244
|
+
success: true,
|
|
1245
|
+
message: `@layout ${algoParam} ${dirParam} — repositioned ${count} shapes`,
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
return {
|
|
1250
|
+
success: false,
|
|
1251
|
+
message: `Layout failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
// ── Orient ─────────────────────────────────────────────
|
|
1256
|
+
handleOrient(op) {
|
|
1257
|
+
if (!op.target) {
|
|
1258
|
+
return { success: false, message: "orient requires a direction: TB, LR, BT, RL" };
|
|
1259
|
+
}
|
|
1260
|
+
const dir = op.target.toUpperCase();
|
|
1261
|
+
const validDirs = new Set(["TB", "LR", "BT", "RL"]);
|
|
1262
|
+
if (!validDirs.has(dir)) {
|
|
1263
|
+
return { success: false, message: `Unknown direction "${op.target}". Use: TB, LR, BT, RL` };
|
|
1264
|
+
}
|
|
1265
|
+
this.model.setFlowDirection(dir);
|
|
1266
|
+
return { success: true, message: `@orient ${dir}` };
|
|
1267
|
+
}
|
|
1268
|
+
// ── Load (stencil packs) ────────────────────────────────
|
|
1269
|
+
handleLoad(op) {
|
|
1270
|
+
const target = op.target?.toLowerCase();
|
|
1271
|
+
if (!target) {
|
|
1272
|
+
return { success: false, message: "load requires a target: use 'load list' or 'load PACK'" };
|
|
1273
|
+
}
|
|
1274
|
+
// load list — show available packs
|
|
1275
|
+
if (target === "list") {
|
|
1276
|
+
const packs = listStencilPacks();
|
|
1277
|
+
const lines = packs.map(p => {
|
|
1278
|
+
const loaded = this.model.diagram.loadedStencilPacks.has(p.id) ? " (loaded)" : "";
|
|
1279
|
+
return ` ${p.id.padEnd(8)} ${p.name} (${p.entryCount} types)${loaded}`;
|
|
1280
|
+
});
|
|
1281
|
+
return {
|
|
1282
|
+
success: true,
|
|
1283
|
+
message: "Available stencil packs:\n" + lines.join("\n"),
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
// load PACK — activate a stencil pack
|
|
1287
|
+
const pack = getStencilPack(target);
|
|
1288
|
+
if (!pack) {
|
|
1289
|
+
const available = listStencilPacks().map(p => p.id).join(", ");
|
|
1290
|
+
return {
|
|
1291
|
+
success: false,
|
|
1292
|
+
message: `Unknown stencil pack "${target}". Available: ${available}`,
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
// Check if already loaded
|
|
1296
|
+
if (this.model.diagram.loadedStencilPacks.has(target)) {
|
|
1297
|
+
return { success: true, message: `Stencil pack "${pack.name}" is already loaded` };
|
|
1298
|
+
}
|
|
1299
|
+
// Register entries (first-loaded wins on conflicts)
|
|
1300
|
+
let newEntries = 0;
|
|
1301
|
+
for (const entry of pack.entries) {
|
|
1302
|
+
if (!this.loadedStencilEntries.has(entry.id)) {
|
|
1303
|
+
this.loadedStencilEntries.set(entry.id, entry);
|
|
1304
|
+
newEntries++;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
this.model.diagram.loadedStencilPacks.add(target);
|
|
1308
|
+
// Build category summary
|
|
1309
|
+
const categories = new Map();
|
|
1310
|
+
for (const entry of pack.entries) {
|
|
1311
|
+
const cat = categories.get(entry.category) ?? [];
|
|
1312
|
+
cat.push(entry.id);
|
|
1313
|
+
categories.set(entry.category, cat);
|
|
1314
|
+
}
|
|
1315
|
+
const catLines = [...categories.entries()].map(([cat, ids]) => ` ${cat}: ${ids.join(", ")}`);
|
|
1316
|
+
return {
|
|
1317
|
+
success: true,
|
|
1318
|
+
message: `Loaded "${pack.name}" (${newEntries} types)\n${catLines.join("\n")}`,
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
//# sourceMappingURL=intent-layer.js.map
|