mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes, the declarative layer allowed six. Opening it, with the failure modes from design review handled: - shapes.ts: a ~20-entry curated catalog (full style fragment incl. the matching perimeter — required or edges connect to the bounding box; a text-scale factor verified in the real editor — the same sentence overflows a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style figures). Any other token passes through verbatim: draw.io degrades unknown shapes to rectangles safely (verified). Injection-capable tokens (;/=) are rejected outright. - Pass-through emits a WARNING with a nearest-catalog hint (bounded edit distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently rectangular node forever. set_shape/set_role/set_group operations make the fix possible without remove+re-add (which would drop links). - mergeStyle(): style fragments merge per-key (later wins, bare shape classes displace each other) instead of string concatenation. This is what makes shape and theme composable by rule — shape owns geometry keys, theme owns colour/type keys, and an overlap resolves by order instead of emitting contradictory duplicates. - dai_shape marker carries the declared token through the round trip: appearance-based reverse mapping cannot distinguish aliases (diamond vs decision) or a rotated queue from a cylinder. - dai_auto marker separates engine-measured size from user-fixed size: parsed boxes no longer freeze the first layout's numbers, so changing a label re-measures. Pinned nodes keep everything, as before. - draw_graph's closed shape enum opened to match (first instance of the schema-drift problem the review predicted). 14 new tests: merge ownership, injection rejection, near-match hints, alias-preserving round trip, re-measure on label change, mxgraph.* tokens staying boxes with role/group intact. 556 unit tests green; acceptance diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in the real editor.
This commit is contained in:
@@ -86,17 +86,10 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
.optional()
|
||||
.describe("Border colour; pair it with fill"),
|
||||
shape: z
|
||||
.enum([
|
||||
"box",
|
||||
"decision",
|
||||
"terminator",
|
||||
"round",
|
||||
"data",
|
||||
"document",
|
||||
])
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
|
||||
"What the node IS, drawn as its conventional outline. Catalog: decision/diamond, terminator (start/end), round, data (input/output), document, cylinder (database), queue, person (actor/user), cloud (external system), hexagon (service), ellipse (concept), callout (note), step (pipeline stage), note, card, process, tape, cube. Any other draw.io shape token also works verbatim. Omit for a plain rectangle",
|
||||
),
|
||||
grow: z
|
||||
.number()
|
||||
@@ -261,6 +254,36 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_shape"),
|
||||
id: z.string(),
|
||||
shape: z
|
||||
.string()
|
||||
.describe("New shape token; 'box' resets to a plain rectangle"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_role"),
|
||||
id: z.string(),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.describe("New information role; 'body' resets to the default"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_group"),
|
||||
id: z.string(),
|
||||
group: z
|
||||
.string()
|
||||
.describe("New semantic zone; empty string removes the zone"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_dir"),
|
||||
id: z.string().describe("Container to re-orient"),
|
||||
@@ -579,6 +602,50 @@ export function applyOperations(
|
||||
break
|
||||
}
|
||||
|
||||
case "set_shape": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || node.kind !== "box") {
|
||||
errors.push(`set_shape: "${op.id}" is not a box`)
|
||||
break
|
||||
}
|
||||
// The verbatim style is last render's composition with the OLD shape
|
||||
// baked in; keeping it would override the new declaration entirely.
|
||||
if (op.shape === "box") delete node.shape
|
||||
else node.shape = op.shape
|
||||
delete node.style
|
||||
delete node.w
|
||||
delete node.h
|
||||
break
|
||||
}
|
||||
|
||||
case "set_role": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || (node.kind !== "box" && node.kind !== "group")) {
|
||||
errors.push(
|
||||
`set_role: "${op.id}" is not a box or container`,
|
||||
)
|
||||
break
|
||||
}
|
||||
if (op.role === "body") delete node.role
|
||||
else node.role = op.role
|
||||
delete node.style
|
||||
break
|
||||
}
|
||||
|
||||
case "set_group": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || (node.kind !== "box" && node.kind !== "group")) {
|
||||
errors.push(
|
||||
`set_group: "${op.id}" is not a box or container`,
|
||||
)
|
||||
break
|
||||
}
|
||||
if (op.group === "") delete node.group
|
||||
else node.group = op.group
|
||||
delete node.style
|
||||
break
|
||||
}
|
||||
|
||||
case "set_dir": {
|
||||
const node = findNode(tree, op.id)
|
||||
if (!node || !isContainer(node)) {
|
||||
|
||||
Reference in New Issue
Block a user