mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
feat(diagram-engine): corners, borderless fills, shadows and strikethrough
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
This commit is contained in:
@@ -8,9 +8,7 @@ import {
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai"
|
||||
import fs from "fs/promises"
|
||||
import { jsonrepair } from "jsonrepair"
|
||||
import path from "path"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
getAIModel,
|
||||
@@ -41,7 +39,11 @@ import { getUserIdFromRequest } from "@/lib/user-id"
|
||||
|
||||
export const maxDuration = 120
|
||||
|
||||
// Helper function to create cached stream response
|
||||
// Helper function to create cached stream response.
|
||||
//
|
||||
// This replays a stored XML answer straight to the canvas, so it still speaks the
|
||||
// `display_diagram` wire format even though the model can no longer call that tool: the client
|
||||
// handler for it is what puts XML on the canvas. Nothing here goes through the model.
|
||||
function createCachedStreamResponse(xml: string): Response {
|
||||
const toolCallId = `cached-${Date.now()}`
|
||||
|
||||
@@ -551,15 +553,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
}
|
||||
}
|
||||
if (toolCall.toolName === "display_diagram") {
|
||||
return {
|
||||
...toolCall,
|
||||
input: {
|
||||
xml: "",
|
||||
_error: "JSON repair failed - empty diagram",
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -599,44 +592,6 @@ IMPORTANT: The "Current diagram XML" is the SINGLE SOURCE OF TRUTH for what's on
|
||||
},
|
||||
tools: {
|
||||
// Client-side tool that will be executed on the client
|
||||
display_diagram: {
|
||||
description: `Display a diagram by writing raw draw.io XML yourself. This is the EXCEPTION, for diagrams whose exact positions are the content (UI mockups, floor plans, circuit/P&ID, seating charts, Gantt, illustrations). For flowcharts and anything nodes-and-arrows use draw_graph; for nesting-based diagrams (cloud architecture, swimlanes, sequence, mind maps) use restructure_diagram. Pass ONLY the mxCell elements - wrapper tags and root cells are added automatically.
|
||||
|
||||
VALIDATION RULES (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. All mxCell elements must be siblings - never nested
|
||||
4. Every mxCell needs a unique id (start from "2")
|
||||
5. Every mxCell needs a valid parent attribute (use "1" for top-level)
|
||||
6. Escape special chars in values: < > & "
|
||||
|
||||
Example (generate ONLY this - no wrapper tags):
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
Notes:
|
||||
- For AWS diagrams, use **AWS 2025 icons**.
|
||||
- For animated connectors, add "flowAnimation=1" to edge style.
|
||||
`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe("XML string to be displayed on draw.io"),
|
||||
}),
|
||||
},
|
||||
edit_diagram: {
|
||||
description: `Edit the current diagram by ID-based operations (update/add/delete cells).
|
||||
|
||||
@@ -679,34 +634,42 @@ Example - Delete container (children & edges auto-deleted):
|
||||
.describe("Array of operations to apply"),
|
||||
}),
|
||||
},
|
||||
append_diagram: {
|
||||
description: `Continue generating diagram XML when previous display_diagram output was truncated due to length limits.
|
||||
|
||||
WHEN TO USE: Only call this tool after display_diagram was truncated (you'll see an error message about truncation).
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
Example: If previous output ended with '<mxCell id="x" style="rounded=1', continue with ';" vertex="1">...' and complete the remaining elements.`,
|
||||
inputSchema: z.object({
|
||||
xml: z
|
||||
.string()
|
||||
.describe(
|
||||
"Continuation XML fragment to append (NO wrapper tags)",
|
||||
),
|
||||
}),
|
||||
},
|
||||
restructure_diagram: {
|
||||
description: `Build or edit a diagram by declaring STRUCTURE. The engine computes every coordinate.
|
||||
|
||||
PREFER THIS over display_diagram/edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
PREFER THIS over edit_diagram whenever the diagram's meaning is in nesting or in a fixed frame: cloud architecture, swimlane/BPMN, sequence diagrams, mind maps, org charts — AND poster-style layouts: paper summaries, cheat sheets, infographics, comparison sheets. You declare what contains what; layout, sizing, alignment and arrow routing are computed. Containers always fit their contents and siblings never overlap, so the usual layout problems cannot occur.
|
||||
|
||||
The layout model is FLEXBOX. row/col containers nest freely; a box with internal structure is an invisible col container (pad 10-14) holding smaller boxes. Three knobs: grow (columns split leftover WIDTH by weight — grow 3 / grow 2 gives a 3:2 page; for containers in a row, not for leaf boxes), align "stretch" (child fills its column's width; content keeps natural height and packs to the top — the engine leaves leftover vertical space at the bottom, never inflates boxes to fill it, so balance columns by moving content between them), pad (8-14 tight card, default 24 roomy section). Labels take inline HTML — <b>, <i>, <font color="#...">, <br> — so one box carries a bold keyword, a second paragraph, a coloured verdict line. Paragraphs set themselves flush-left automatically; short labels centre. Emoji in headings (💡 Core Idea) read instantly.
|
||||
|
||||
For a POSTER (paper summary, cheat sheet): one col container as the page; a banner box as the masthead with align stretch (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container holding 2-4 col containers with grow weights as columns; each section a heading-role box + content boxes, all align stretch. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col gap=8 pad=12 grow=1 role=bad, then a bold title box, the body text, a role=bad answer bar (all align stretch), and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with align start.
|
||||
DECLARE THE PAGE SHAPE FIRST, with set_page. aspect is width:height — 1 square, 1.4 a landscape slide, 0.75 a portrait poster, 1.6 a wide architecture diagram. This is the one thing that has to come before everything else: it gives the top level a definite width, and until there is one there is no spare room to share out, so grow weights and column fractions have no effect at all. A row that then cannot fit wraps onto a second line rather than running off to the right.
|
||||
|
||||
LAYOUT, TYPE AND SURFACE — Tailwind classes. Every add_container and add_box takes class, and it is the preferred way to say these things. Colour is the one thing a class never carries: that comes from role and group.
|
||||
proportion grow-3 / flex-3 / w-2/3 — a column's share of the row. Add min-w-0 to BOTH columns when you want the ratio exactly: without it a column will not shrink below the width of its own text, so a declared 3:1 lands wherever the text allows (this is how flexbox behaves in a browser too).
|
||||
direction flex-row, flex-col (or the dir field, which a class cannot override)
|
||||
cross axis items-stretch on the container (cards all span the same width — this is what makes a column line up), or self-start / self-center / self-end / self-stretch on one child
|
||||
main axis justify-start (default: packed, spare room at the far end) / justify-center / justify-end / justify-between / justify-around / justify-evenly. Reach for justify-between when a short column would otherwise leave a hole at the bottom.
|
||||
spacing gap-4 between children, p-6 inside. Tailwind's 4px scale, so gap-4 is 16px and p-6 is 24px. Use the scale; there is no gap-7.5.
|
||||
width cap max-w-md (448) or max-w-96, up to max-w-4xl. A capped box rewraps its text instead of stretching, which is what stops one long sentence flattening the page. A cap beats grow.
|
||||
type font-bold / font-normal, italic, underline, line-through, text-xs..text-4xl (12/14/16/18/20/24/30/36px), text-left|center|right, align-top|middle|bottom, whitespace-nowrap. An explicit alignment beats the engine's own "this looks like a paragraph" rule, so use text-center when you want a long label centred. line-through is for a superseded or cancelled step.
|
||||
border border or border-N for thickness, border-dashed / border-dotted / border-solid. A dashed frame is the conventional way to draw something planned, optional or purely logical. border-none removes the outline entirely, which is how you draw a plain colour field.
|
||||
corners rounded, rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-3xl, rounded-4xl (4/4/6/8/12/16/24/32px), rounded-full for a capsule, rounded-none for square. Real pixels, so the same class is the same corner on every box. Overrides the corner of a shape that has one, which is what you want on round and terminator.
|
||||
elevation shadow-sm / shadow-md / shadow-lg / shadow-xl, shadow-none. Use it to lift a card off a panel; one level on one group of cards, not on everything.
|
||||
NOT supported, and dropped with a note telling you which: EVERY colour class (bg-*, text-red-*, border-blue-*) and gradients — colour comes from role and group; the seven font weights between font-thin and font-black, because draw.io has one bold bit rather than a weight ladder; opacity-* (Tailwind's is any number, not a scale); truncate (draw.io cannot draw the ellipsis, so text would just be cut); PER-SIDE borders (border-l, border-t-4) — draw.io draws these with a shape called partialRectangle, which would take the place of the node's own shape, and what a node IS matters more than which of its edges show; PER-SIDE padding (pt-4, px-2) — the engine has one padding value, and draw.io's per-side keys pad the LABEL rather than making room for children; per-corner radius (rounded-tl-lg); text-shadow-*; tracking-* and uppercase/lowercase/capitalize and leading-* (draw.io has no letter-spacing, no text-transform and no per-node line height); outline-*, hover:*, responsive prefixes, and all transforms.
|
||||
|
||||
PLAN THE COLUMNS BEFORE THE FIRST OPERATION. The engine places exactly what you declare; a column that runs out of content early leaves a hole at the bottom of the page and nothing later can fill it. So: list each section with a rough character count (heading ~20, paragraph ~its length, comparison card ~the sum of its parts, add_graph ~400); a column twice as wide runs about half as tall, so a column's SHARE OF THE TOTAL CONTENT must match its grow weight — grow 3 beside grow 1 holds about three times the characters, never fewer; add the columns up and check the ratio before emitting anything (1200 vs 1100 chars is grow 1 / grow 1, and wanting grow 3 / grow 1 for 900 vs 1100 means the plan is wrong — move sections across or equalise the weights); a full-width element (masthead, footnote, wide diagram) is its own row above or below the row of columns, never inside one, because a 900-wide diagram in one column forces that column wide and strands the others. State the numbers in your preamble ("left ~N chars / right ~M, so grow X / Y") — writing them down is what catches the mismatch.
|
||||
|
||||
For a POSTER (paper summary, cheat sheet): set_page with aspect 0.75 (portrait) or 1.4 (landscape); one col container as the page with class "gap-4"; a banner box as the masthead with class "self-stretch" (do NOT also use set_title — the banner IS the title); a muted box for the byline; a row container class "gap-4" holding 2-4 col containers as columns, each class "grow-N min-w-0 items-stretch"; each section a heading-role box + content boxes. Give each section a distinct group name — sections sharing a group share a hue, so groups are how the poster gets its colour. Use roles on boxes: callout for the core idea, good/bad for verdict pairs, metric for the headline number, muted for fine print. A comparison card: add_container dir=col class="gap-2 p-3 grow-1 items-stretch" role=bad, then a bold title box, the body text, a role=bad answer bar, and a coloured "<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" verdict with class "self-start".
|
||||
{"operations":[
|
||||
{"op":"set_page","aspect":0.8},
|
||||
{"op":"add_container","id":"page","label":"","dir":"col","class":"gap-4"},
|
||||
{"op":"add_box","id":"mast","parent":"page","label":"Chain-of-Thought Prompting","role":"banner","class":"self-stretch"},
|
||||
{"op":"add_container","id":"cols","parent":"page","label":"","dir":"row","class":"gap-4"},
|
||||
{"op":"add_container","id":"left","parent":"cols","label":"","dir":"col","class":"grow-2 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_container","id":"right","parent":"cols","label":"","dir":"col","class":"grow-1 min-w-0 gap-3 items-stretch"},
|
||||
{"op":"add_box","id":"h1","parent":"left","label":"What it is","role":"heading","group":"idea"},
|
||||
{"op":"add_box","id":"p1","parent":"left","label":"Ask the model to show its steps...","group":"idea"}
|
||||
]}
|
||||
(Two thirds of the characters go in the grow-2 column, one third in the grow-1 column.)
|
||||
|
||||
Never write coordinates, mxCell XML, or style strings. Look AWS icon names up with search_stencils first — an invented name is rejected with suggestions.
|
||||
|
||||
@@ -720,13 +683,59 @@ Operations are applied in order, so you can add a container and fill it in the s
|
||||
|
||||
Editing an existing diagram: the structure is re-read from the canvas each time, INCLUDING anything the user moved or recoloured by hand. To add one service, send one operation — do not re-send the diagram.
|
||||
|
||||
CLOUD ARCHITECTURE (AWS/Azure/GCP/Kubernetes) — every zone is a container, and each one's dir is what makes the diagram readable: dir follows the traffic. Nesting is Region -> VPC -> Availability Zone -> Subnet, and managed/global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS, WAF, CloudWatch) sit OUTSIDE the VPC — a regional service inside a subnet states something false about the network. Use dir "row" wherever things are PEERS (availability zones side by side, replicas, a set of regional services) and dir "col" wherever traffic FLOWS THROUGH (the tiers inside one zone: public -> app -> data, top to bottom). Label every zone with its scope ("Availability Zone A", "Private Subnet (App)", "VPC 10.0.0.0/16") — an unlabelled frame makes the reader guess what the boundary means. Put the actor (Users / Internet) OUTSIDE the region as a plain box with shape "person" or "cloud" and link it inwards; it is not infrastructure. Two availability zones is the right default for "a sample architecture" — one reads as a single point of failure, three repeats the same information a third time. Number the request path on the links ("1. HTTPS", "2. forward", "3. route", "4. query") so the reader has an entry point, and make cross-cutting links (replication, telemetry) dashed and unnumbered. Keep each zone to 1-4 icons: one is fine when the boundary itself is the point (a subnet holding one NAT gateway), ten is a wall of icons — split it or use add_grid.
|
||||
{"operations":[
|
||||
{"op":"add_box","id":"users","label":"Users / Internet","shape":"person"},
|
||||
{"op":"add_container","id":"region","label":"Region (ap-southeast-1)","dir":"row","gname":"group_region"},
|
||||
{"op":"add_container","id":"vpc","parent":"region","label":"VPC 10.0.0.0/16","dir":"col","gname":"group_vpc"},
|
||||
{"op":"add_icon","id":"igw","parent":"vpc","name":"internet_gateway","label":"Internet Gateway"},
|
||||
{"op":"add_icon","id":"alb","parent":"vpc","name":"application_load_balancer","label":"ALB"},
|
||||
{"op":"add_container","id":"azs","parent":"vpc","dir":"row"},
|
||||
{"op":"add_container","id":"az_a","parent":"azs","label":"Availability Zone A","dir":"col","gname":"group_availability_zone"},
|
||||
{"op":"add_container","id":"pub_a","parent":"az_a","label":"Public Subnet","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"nat_a","parent":"pub_a","name":"nat_gateway","label":"NAT Gateway"},
|
||||
{"op":"add_container","id":"app_a","parent":"az_a","label":"Private Subnet (App)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"ec2_a","parent":"app_a","name":"ec2","label":"EC2 / ECS"},
|
||||
{"op":"add_container","id":"db_a","parent":"az_a","label":"Private Subnet (Data)","dir":"col","gname":"group_subnet"},
|
||||
{"op":"add_icon","id":"rds_a","parent":"db_a","name":"rds","label":"RDS (Primary)"},
|
||||
{"op":"add_container","id":"reg_svc","parent":"region","label":"Regional / Edge services","dir":"col"},
|
||||
{"op":"add_icon","id":"waf","parent":"reg_svc","name":"waf","label":"AWS WAF"},
|
||||
{"op":"link","source":"users","target":"igw","label":"1. HTTPS"},
|
||||
{"op":"link","source":"igw","target":"alb","label":"2. forward"},
|
||||
{"op":"link","source":"alb","target":"ec2_a","label":"3. route"},
|
||||
{"op":"link","source":"ec2_a","target":"rds_a","label":"4. query"},
|
||||
{"op":"link","source":"rds_a","target":"rds_b","label":"Multi-AZ replication","dashed":true}
|
||||
]}
|
||||
(az_b mirrors az_a, with RDS labelled "(Standby)".)
|
||||
|
||||
CONTAINERS — pick by what the diagram means:
|
||||
|
||||
add_container: children stacked along one axis. dir "row" side by side, "col" one above the next. An empty label makes an invisible grouping wrapper (use it to group columns without drawing another frame). gname is an AWS group stencil (group_region, group_vpc, group_availability_zone, group_subnet, group_account) — omit it for a plain titled frame.
|
||||
|
||||
add_grid: packs children into cols columns. Use it to pack 3-8 related icons into one labelled area rather than giving each its own frame.
|
||||
|
||||
add_graph: an ARROW-ORDERED zone inside a nested diagram. Takes nodes+edges like draw_graph; the edges decide layering and ordering, and the resulting block joins the outer layout like any node. Use it when one region's contents follow a flow — a pipeline zone in an architecture diagram, a small flowchart in a poster column. dir: "col" (default) flows down, "row" flows right.
|
||||
add_graph: an ARROW-ORDERED block. Give it nodes and edges, NO positions and NO nesting: the engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Loops and arrows that skip ahead are fine.
|
||||
THIS IS THE ONLY WAY TO DRAW A FLOWCHART. Use it for flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams, and any "illustrate how X works" where X is a sequence of steps or states. Never build one out of add_container/add_box by hand: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart; set parent to put a flow inside one zone of a bigger diagram (a pipeline in an architecture diagram, a small flowchart in a poster column), where the block then joins the outer layout like any node. dir: "col" (default) flows down, "row" flows right.
|
||||
Redrawing a whole-page flowchart: send clear first. One new arrow can change which row several nodes belong in, so a flowchart is rebuilt rather than patched.
|
||||
{"operations":[
|
||||
{"op":"clear"},
|
||||
{"op":"add_graph","id":"flow","nodes":[
|
||||
{"id":"start","label":"Order received","shape":"terminator"},
|
||||
{"id":"check","label":"Amount > $1000?","shape":"decision"},
|
||||
{"id":"mgr","label":"Manager approval"},
|
||||
{"id":"auto","label":"Auto-approve"},
|
||||
{"id":"ship","label":"Ship order"}
|
||||
],"edges":[
|
||||
{"source":"start","target":"check"},
|
||||
{"source":"check","target":"mgr","label":"yes"},
|
||||
{"source":"check","target":"auto","label":"no"},
|
||||
{"source":"mgr","target":"ship"},
|
||||
{"source":"auto","target":"ship"}
|
||||
]},
|
||||
{"op":"set_title","title":"Order Approval"}
|
||||
]}
|
||||
Grouping: when the nodes fall into natural zones (remote vs local, frontend vs backend, roles, phases), set the same group name on each zone's nodes and the engine colours each zone consistently. Set icon instead of shape to draw a node as a catalog icon.
|
||||
|
||||
add_pool: a SWIMLANE diagram. lanes are the roles, top to bottom. Set orientation to "vertical" for vertical swimlanes, where the lanes become columns and the flow runs downwards. Each step is an add_box with lane (which role owns it) and col (which step of the process it is); columns advance left to right and an empty cell means that role does nothing at that point. Two steps with the same col happen at the same time. phases optionally labels groups of columns.
|
||||
{"operations":[
|
||||
@@ -767,117 +776,6 @@ BOX SHAPES: add_box takes shape — "decision" for a branch (diamond), "terminat
|
||||
.describe("Structural operations, applied in order"),
|
||||
}),
|
||||
},
|
||||
draw_graph: {
|
||||
description: `Draw a FLOWCHART or other arrow-driven diagram from nodes and arrows alone. Give NO positions and NO nesting.
|
||||
|
||||
USE THIS FOR: flowcharts, decision trees, process and approval flows, CI/CD pipelines, state machines, git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams, and any "illustrate how X works" where X is a sequence of steps or states.
|
||||
|
||||
The engine reads the arrows to work out how many rows the diagram has, which nodes share a row, and who goes left of whom — chosen to keep arrows from crossing each other or running through unrelated boxes. Do NOT lay these out yourself with nested containers or XML: declaring a flowchart as nesting puts every step in one column, so each branch has to jump over the step beside it.
|
||||
|
||||
Loops are fine — an arrow back to an earlier step is drawn as a loop. So are arrows that skip ahead several steps.
|
||||
|
||||
{"nodes":[
|
||||
{"id":"start","label":"Order received","shape":"terminator"},
|
||||
{"id":"check","label":"Amount > $1000?","shape":"decision"},
|
||||
{"id":"mgr","label":"Manager approval"},
|
||||
{"id":"auto","label":"Auto-approve"},
|
||||
{"id":"ship","label":"Ship order"}
|
||||
],"edges":[
|
||||
{"source":"start","target":"check"},
|
||||
{"source":"check","target":"mgr","label":"yes"},
|
||||
{"source":"check","target":"auto","label":"no"},
|
||||
{"source":"mgr","target":"ship"},
|
||||
{"source":"auto","target":"ship"}
|
||||
],"title":"Order Approval"}
|
||||
|
||||
Replaces the whole diagram, because one new arrow can change which row several nodes belong in. To edit afterwards, use restructure_diagram with the ids from the outline this returns.
|
||||
|
||||
Shapes say what a node IS: "decision" for a branch (diamond), "terminator" for a start or end point, "data" for input or output, "document" for a report, "round" for a soft-edged step, "cylinder" for a database, "queue" for a message queue, "person" for an actor or user, "cloud" for an external system, "hexagon" for a service, "ellipse" for a concept, "box" (default) for a plain step. Any other draw.io shape token also works verbatim. Set icon instead of shape to draw a node as a catalog icon — look the name up with search_stencils first.
|
||||
|
||||
Grouping: when the nodes fall into natural zones (remote vs local, frontend vs backend, roles, phases), set the same group name on each zone's nodes. The engine colours each group consistently from its own palette. Name groups by meaning; never pick hex colours.`,
|
||||
inputSchema: z.object({
|
||||
nodes: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
shape: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"What the node IS: decision, terminator, round, data, document, cylinder (database), queue, person (actor), cloud (external), hexagon (service), ellipse. Any draw.io shape token also works",
|
||||
),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Catalog stencil name; draws this node as an icon",
|
||||
),
|
||||
group: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Semantic group name, e.g. 'remote' or 'local'. Nodes sharing a group get the same colour from the engine's palette — never pick colours yourself",
|
||||
),
|
||||
role: z
|
||||
.enum([
|
||||
"banner",
|
||||
"heading",
|
||||
"body",
|
||||
"callout",
|
||||
"good",
|
||||
"bad",
|
||||
"metric",
|
||||
"muted",
|
||||
])
|
||||
.optional()
|
||||
.describe(
|
||||
"What this node IS: heading, callout (must-not-miss), good/bad (verdict), metric (key number), muted (fine print). The theme styles it",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe("Every box in the diagram"),
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
source: z.string(),
|
||||
target: z.string(),
|
||||
label: z.string().optional(),
|
||||
dashed: z.boolean().optional(),
|
||||
bold: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Thick coloured arrow for THE key relationship; use sparingly",
|
||||
),
|
||||
head: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Arrowhead at the target: block/open/diamond/diamondThin/oval/none, ER: ERone/ERmany/ERoneToMany/ERzeroToMany. UML inheritance: head=block headFill=false",
|
||||
),
|
||||
tail: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Arrowhead at the source, same values. ER 1:N: tail=ERone head=ERoneToMany",
|
||||
),
|
||||
headFill: z.boolean().optional(),
|
||||
tailFill: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Arrows. Direction matters — it sets the order of the diagram",
|
||||
),
|
||||
title: z.string().optional(),
|
||||
flow: z
|
||||
.enum(["col", "row"])
|
||||
.optional()
|
||||
.describe(
|
||||
"col (default): top to bottom. row: left to right",
|
||||
),
|
||||
}),
|
||||
},
|
||||
search_stencils: {
|
||||
description: `Find AWS stencil names for restructure_diagram. Returns names and official colours — call this before naming an icon, and batch the whole diagram's lookups into as few calls as possible.`,
|
||||
inputSchema: z.object({
|
||||
@@ -901,69 +799,6 @@ Grouping: when the nodes fall into natural zones (remote vs local, frontend vs b
|
||||
return JSON.stringify(hits)
|
||||
},
|
||||
},
|
||||
get_shape_library: {
|
||||
description: `Get draw.io shape/icon library documentation with style syntax and shape names. Use this before writing raw XML with display_diagram (UI mockups, floor plans, and other absolute-position diagrams). Flowcharts go through draw_graph and AWS architecture through search_stencils + restructure_diagram - neither needs this.
|
||||
|
||||
Available libraries:
|
||||
- Cloud: aws4, azure2, gcp2, alibaba_cloud, openstack, salesforce
|
||||
- Networking: cisco19, network, kubernetes, vvd, rack
|
||||
- Business: bpmn, lean_mapping
|
||||
- General: flowchart, basic, arrows2, infographic, sitemap
|
||||
- UI/Mockups: android, material_design
|
||||
- Enterprise: citrix, sap, mscae, atlassian
|
||||
- Engineering: fluidpower, electrical, pid, cabinets, floorplan
|
||||
- Icons: webicons
|
||||
|
||||
Call this tool to get shape names and usage syntax for a specific library.`,
|
||||
inputSchema: z.object({
|
||||
library: z
|
||||
.string()
|
||||
.describe(
|
||||
"Library name (e.g., 'aws4', 'kubernetes', 'flowchart')",
|
||||
),
|
||||
}),
|
||||
execute: async ({ library }) => {
|
||||
// Sanitize input - prevent path traversal attacks
|
||||
const sanitizedLibrary = library
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "")
|
||||
|
||||
if (sanitizedLibrary !== library.toLowerCase()) {
|
||||
return `Invalid library name "${library}". Use only letters, numbers, underscores, and hyphens.`
|
||||
}
|
||||
|
||||
const baseDir = path.join(
|
||||
process.cwd(),
|
||||
"docs/shape-libraries",
|
||||
)
|
||||
const filePath = path.join(
|
||||
baseDir,
|
||||
`${sanitizedLibrary}.md`,
|
||||
)
|
||||
|
||||
// Verify path stays within expected directory
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
if (!resolvedPath.startsWith(path.resolve(baseDir))) {
|
||||
return `Invalid library path.`
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
return content
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
) {
|
||||
return `Library "${library}" not found. Available: aws4, azure2, gcp2, alibaba_cloud, cisco19, kubernetes, network, bpmn, flowchart, basic, arrows2, vvd, salesforce, citrix, sap, mscae, atlassian, fluidpower, electrical, pid, cabinets, floorplan, webicons, infographic, sitemap, android, material_design, lean_mapping, openstack, rack`
|
||||
}
|
||||
console.error(
|
||||
`[get_shape_library] Error loading "${library}":`,
|
||||
error,
|
||||
)
|
||||
return `Error loading library "${library}". Please try again.`
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
...(process.env.TEMPERATURE !== undefined && {
|
||||
temperature: parseFloat(process.env.TEMPERATURE),
|
||||
|
||||
@@ -337,7 +337,7 @@ export default function ChatPanel({
|
||||
// VLM validation hook using AI SDK's useObject
|
||||
const { validateWithFallback } = useValidateDiagram()
|
||||
|
||||
// Diagram tool handlers (display_diagram, edit_diagram, append_diagram)
|
||||
// Diagram tool handlers (edit_diagram, restructure_diagram, cached replay)
|
||||
const { handleToolCall } = useDiagramToolHandlers({
|
||||
partialXmlRef,
|
||||
editDiagramOriginalXmlRef,
|
||||
|
||||
@@ -4,7 +4,11 @@ import { Check, ChevronDown, ChevronUp, Copy, Cpu } from "lucide-react"
|
||||
import type { Dispatch, SetStateAction } from "react"
|
||||
import { CodeBlock } from "@/components/code-block"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import type { DiagramOperation, ToolPartLike } from "./types"
|
||||
import type {
|
||||
DiagramOperation,
|
||||
StructureOperation,
|
||||
ToolPartLike,
|
||||
} from "./types"
|
||||
|
||||
interface ToolCallCardProps {
|
||||
part: ToolPartLike
|
||||
@@ -19,31 +23,137 @@ interface ToolCallCardProps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour an operation by what it does to the diagram: removes, adds, or changes.
|
||||
*
|
||||
* Takes an unknown rather than a string because this renders DURING streaming: the tool
|
||||
* input arrives character by character, so an operation is briefly `{}` or `{"op": "add_c`
|
||||
* before it is whole. A missing name is the normal mid-stream state, not an error.
|
||||
*/
|
||||
function opColour(op: unknown): string {
|
||||
if (typeof op !== "string") return "text-muted-foreground"
|
||||
if (op === "delete" || op === "remove" || op === "unlink" || op === "clear")
|
||||
return "text-red-600"
|
||||
if (op.startsWith("add") || op === "link") return "text-green-600"
|
||||
return "text-blue-600"
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments worth showing beside an operation's name.
|
||||
*
|
||||
* A whitelist rather than "everything except op and id", because some operations carry a
|
||||
* whole nested graph (add_graph's nodes and edges) and dumping that turns one line into a
|
||||
* screenful. The excluded keys are summarised instead.
|
||||
*/
|
||||
const SHOWN_KEYS = [
|
||||
"label",
|
||||
"name",
|
||||
"parent",
|
||||
"dir",
|
||||
"class",
|
||||
"role",
|
||||
"group",
|
||||
"shape",
|
||||
"cols",
|
||||
"lanes",
|
||||
"aspect",
|
||||
"source",
|
||||
"target",
|
||||
"title",
|
||||
] as const
|
||||
|
||||
function summarise(op: StructureOperation | undefined | null): string {
|
||||
if (!op || typeof op !== "object") return ""
|
||||
const parts: string[] = []
|
||||
for (const key of SHOWN_KEYS) {
|
||||
const v = op[key]
|
||||
if (v === undefined || v === null || v === "") continue
|
||||
parts.push(
|
||||
`${key}=${Array.isArray(v) ? v.join("/") : String(v).slice(0, 60)}`,
|
||||
)
|
||||
}
|
||||
// A graph carries its own nodes and edges; report the size, not the contents.
|
||||
const nodes = op.nodes
|
||||
const edges = op.edges
|
||||
if (Array.isArray(nodes))
|
||||
parts.push(
|
||||
`${nodes.length} node${nodes.length === 1 ? "" : "s"}${
|
||||
Array.isArray(edges)
|
||||
? `, ${edges.length} edge${edges.length === 1 ? "" : "s"}`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* `restructure_diagram`'s operations: structural steps, not XML patches.
|
||||
*
|
||||
* Written to survive PARTIAL data. This renders while the tool input is still streaming, so
|
||||
* an entry may be `{}`, or `{op: "add_contai"}`, or — because a JSON array is repaired as it
|
||||
* arrives — `undefined`. Every field is therefore treated as possibly absent rather than
|
||||
* validated up front: dropping incomplete entries would make rows appear and disappear as
|
||||
* the text arrives, and asserting on them crashes the whole message.
|
||||
*/
|
||||
function StructureOperationsDisplay({
|
||||
operations,
|
||||
}: {
|
||||
operations: StructureOperation[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op?.op ?? "pending"}-${op?.id ?? index}-${index}`}
|
||||
className="flex items-baseline gap-2 px-2 py-1 rounded bg-background/50 border border-border/40"
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide shrink-0 ${opColour(op?.op)}`}
|
||||
>
|
||||
{op?.op ?? "…"}
|
||||
</span>
|
||||
{op?.id && (
|
||||
<span className="text-xs font-mono text-foreground/80 shrink-0">
|
||||
{op.id}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground font-mono break-all">
|
||||
{summarise(op)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** `edit_diagram`'s operations. Also streamed, so also written for partial entries. */
|
||||
function OperationsDisplay({ operations }: { operations: DiagramOperation[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{operations.map((op, index) => (
|
||||
<div
|
||||
key={`${op.operation}-${op.cell_id}-${index}`}
|
||||
key={`${op?.operation ?? "pending"}-${op?.cell_id ?? index}-${index}`}
|
||||
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-medium uppercase tracking-wide ${
|
||||
op.operation === "delete"
|
||||
op?.operation === "delete"
|
||||
? "text-red-600"
|
||||
: op.operation === "add"
|
||||
: op?.operation === "add"
|
||||
? "text-green-600"
|
||||
: "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{op.operation}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
{op?.operation ?? "…"}
|
||||
</span>
|
||||
{op?.cell_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
cell_id: {op.cell_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{op.new_xml && (
|
||||
{op?.new_xml && (
|
||||
<div className="px-3 py-2">
|
||||
<pre className="text-[11px] font-mono text-foreground/80 bg-muted/30 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{op.new_xml}
|
||||
@@ -81,12 +191,16 @@ export function ToolCallCard({
|
||||
|
||||
const getToolDisplayName = (name: string) => {
|
||||
switch (name) {
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
case "restructure_diagram":
|
||||
return "Build Diagram"
|
||||
case "edit_diagram":
|
||||
return "Edit Diagram"
|
||||
case "get_shape_library":
|
||||
return "Get Shape Library"
|
||||
case "search_stencils":
|
||||
return "Find Icons"
|
||||
// Only ever arrives from the server's cache-hit path now; the model cannot
|
||||
// call it. See createCachedStreamResponse in app/api/chat/route.ts.
|
||||
case "display_diagram":
|
||||
return "Generate Diagram"
|
||||
default:
|
||||
return name
|
||||
}
|
||||
@@ -105,14 +219,6 @@ export function ToolCallCard({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
output &&
|
||||
toolName === "get_shape_library" &&
|
||||
typeof output === "string"
|
||||
) {
|
||||
textToCopy = output
|
||||
}
|
||||
|
||||
if (textToCopy) {
|
||||
onCopy(callId, textToCopy, true)
|
||||
}
|
||||
@@ -162,10 +268,11 @@ export function ToolCallCard({
|
||||
)}
|
||||
{state === "output-error" &&
|
||||
(() => {
|
||||
// Check if this is a truncation (incomplete XML) vs real error
|
||||
// Truncation only applies to a tool that streams raw XML, which
|
||||
// is now just the cached-answer replay. The engine tools send
|
||||
// structured operations, so a failure there is a real error.
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
toolName === "display_diagram" &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return isTruncated ? (
|
||||
<span className="text-xs font-medium text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded-full">
|
||||
@@ -214,7 +321,23 @@ export function ToolCallCard({
|
||||
) : typeof input === "object" &&
|
||||
input.operations &&
|
||||
Array.isArray(input.operations) ? (
|
||||
<OperationsDisplay operations={input.operations} />
|
||||
// Dispatch by TOOL, not by whether an `operations` key exists: both
|
||||
// tools call their argument that, but the items have different shapes
|
||||
// (op/id versus operation/cell_id), and reading one as the other
|
||||
// printed a row of blank `cell_id:` labels.
|
||||
toolName === "restructure_diagram" ? (
|
||||
<StructureOperationsDisplay
|
||||
operations={
|
||||
input.operations as StructureOperation[]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<OperationsDisplay
|
||||
operations={
|
||||
input.operations as DiagramOperation[]
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : typeof input === "object" &&
|
||||
Object.keys(input).length > 0 ? (
|
||||
<CodeBlock
|
||||
@@ -228,8 +351,7 @@ export function ToolCallCard({
|
||||
state === "output-error" &&
|
||||
(() => {
|
||||
const isTruncated =
|
||||
(toolName === "display_diagram" ||
|
||||
toolName === "append_diagram") &&
|
||||
toolName === "display_diagram" &&
|
||||
!isMxCellXmlComplete(input?.xml)
|
||||
return (
|
||||
<div
|
||||
@@ -241,25 +363,21 @@ export function ToolCallCard({
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* Show get_shape_library output on success */}
|
||||
{output &&
|
||||
toolName === "get_shape_library" &&
|
||||
state === "output-available" &&
|
||||
isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
Library loaded (
|
||||
{typeof output === "string" ? output.length : 0}{" "}
|
||||
chars)
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 p-2 rounded-md overflow-auto max-h-32 whitespace-pre-wrap">
|
||||
{typeof output === "string"
|
||||
? output.substring(0, 800) +
|
||||
(output.length > 800 ? "\n..." : "")
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{/* What the tool actually returned. Worth showing on success, not only on
|
||||
error: restructure_diagram answers with an outline of the structure it
|
||||
built plus any notes about classes it could not honour, and that is the
|
||||
same text the model reads to name ids in its next call. */}
|
||||
{output && state === "output-available" && isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-border/40">
|
||||
<pre className="text-[11px] font-mono text-muted-foreground bg-muted/40 rounded-md p-2 overflow-auto max-h-64 whitespace-pre-wrap break-all">
|
||||
{typeof output === "string"
|
||||
? output.length > 4000
|
||||
? `${output.slice(0, 4000)}\n…`
|
||||
: output
|
||||
: String(output)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
/** An `edit_diagram` operation: a patch against one cell, addressed by its id. */
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
new_xml?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A `restructure_diagram` operation.
|
||||
*
|
||||
* Deliberately loose. The engine owns the real schema (lib/diagram-engine/operations.ts)
|
||||
* and it has two dozen variants; the card only needs to say WHAT each step did, so it
|
||||
* reads the two fields every variant shares and picks a few recognisable extras out of
|
||||
* the rest. Mirroring the full union here would mean editing this file every time the
|
||||
* engine gains an operation.
|
||||
*
|
||||
* The field names matter: `op`/`id`, where edit_diagram has `operation`/`cell_id`. Both
|
||||
* tools happen to call their argument `operations`, which is what let the card render one
|
||||
* as the other and print six blank `cell_id:` lines.
|
||||
*/
|
||||
export interface StructureOperation {
|
||||
op: string
|
||||
id?: string
|
||||
label?: string
|
||||
parent?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ToolPartLike {
|
||||
type: string
|
||||
toolCallId: string
|
||||
state?: string
|
||||
input?: {
|
||||
xml?: string
|
||||
operations?: DiagramOperation[]
|
||||
operations?: DiagramOperation[] | StructureOperation[]
|
||||
} & Record<string, unknown>
|
||||
output?: string
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ import type {
|
||||
ValidationStatus,
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import {
|
||||
drawGraph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
restructureDiagram,
|
||||
} from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, isRealDiagram, wrapWithMxFile } from "@/lib/utils"
|
||||
@@ -71,7 +66,8 @@ interface UseDiagramToolHandlersParams {
|
||||
|
||||
/**
|
||||
* Hook that creates the onToolCall handler for diagram-related tools.
|
||||
* Handles display_diagram, edit_diagram, and append_diagram tools.
|
||||
* Handles edit_diagram and restructure_diagram, plus the cached-XML replay that arrives
|
||||
* as display_diagram.
|
||||
*
|
||||
* Note: addToolOutput is passed at call time (not hook init) because
|
||||
* it comes from useChat which creates a circular dependency.
|
||||
@@ -125,263 +121,36 @@ export function useDiagramToolHandlers({
|
||||
await handleDisplayDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "edit_diagram") {
|
||||
await handleEditDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "append_diagram") {
|
||||
handleAppendDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "restructure_diagram") {
|
||||
await handleRestructureDiagram(toolCall, addToolOutput)
|
||||
} else if (toolCall.toolName === "draw_graph") {
|
||||
await handleDrawGraph(toolCall, addToolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Replays a cached XML answer onto the canvas. The model can no longer call this tool —
|
||||
// it only arrives from the server's cache-hit path (see createCachedStreamResponse), which
|
||||
// speaks the same wire format. So there is no truncation to continue and no model to send
|
||||
// errors back to: load it, or report that it did not load.
|
||||
const handleDisplayDiagram = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// DEBUG: Log raw input to diagnose false truncation detection
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] XML ending (last 100 chars):",
|
||||
xml.slice(-100),
|
||||
)
|
||||
console.log("[display_diagram] XML length:", xml.length)
|
||||
}
|
||||
|
||||
// Check if XML is truncated (incomplete mxCell indicates truncated output)
|
||||
const isTruncated = !isMxCellXmlComplete(xml)
|
||||
if (DEBUG) {
|
||||
console.log("[display_diagram] isTruncated:", isTruncated)
|
||||
}
|
||||
|
||||
if (isTruncated) {
|
||||
// Store the partial XML for continuation via append_diagram
|
||||
partialXmlRef.current = xml
|
||||
|
||||
// Tell LLM to use append_diagram to continue
|
||||
const partialEnding = partialXmlRef.current.slice(-500)
|
||||
const validationError = onDisplayChart(wrapWithMxFile(xml))
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Output was truncated due to length limits. Use the append_diagram tool to continue.
|
||||
|
||||
Your output ended with:
|
||||
\`\`\`
|
||||
${partialEnding}
|
||||
\`\`\`
|
||||
|
||||
NEXT STEP: Call append_diagram with the continuation XML.
|
||||
- Do NOT include wrapper tags or root cells (id="0", id="1")
|
||||
- Start from EXACTLY where you stopped
|
||||
- Complete all remaining mxCell elements`,
|
||||
errorText: validationError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Complete XML received - use it directly
|
||||
// (continuation is now handled via append_diagram tool)
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
// Return error to model - sendAutomaticallyWhen will trigger retry
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-error",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `${validationError}
|
||||
|
||||
Please fix the XML issues and call display_diagram again with corrected XML.
|
||||
|
||||
Your failed XML:
|
||||
\`\`\`xml
|
||||
${finalXml}
|
||||
\`\`\``,
|
||||
})
|
||||
} else {
|
||||
// Success - diagram will be rendered by chat-message-display
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Success! Checking if VLM validation is enabled...",
|
||||
)
|
||||
}
|
||||
|
||||
// VLM validation after successful display
|
||||
if (
|
||||
enableVlmValidation &&
|
||||
captureValidationPng &&
|
||||
validateDiagram
|
||||
) {
|
||||
let capturedPngData: string | null = null
|
||||
try {
|
||||
// Notify UI that we're starting capture
|
||||
updateValidationState(toolCall.toolCallId, "capturing")
|
||||
|
||||
// Small delay (100ms) to allow diagram rendering to complete before capture.
|
||||
// This is a best-effort heuristic and may need adjustment for complex diagrams or slower devices.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
capturedPngData = await captureValidationPng()
|
||||
if (capturedPngData) {
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Captured PNG for validation",
|
||||
)
|
||||
}
|
||||
|
||||
const retryCount =
|
||||
validationRetryCountRef.current.get(
|
||||
toolCall.toolCallId,
|
||||
) || 0
|
||||
|
||||
// Notify UI that we're validating (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"validating",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
const result = await validateDiagram(
|
||||
capturedPngData,
|
||||
sessionId,
|
||||
)
|
||||
|
||||
if (!result.valid) {
|
||||
if (retryCount < MAX_VALIDATION_RETRIES) {
|
||||
validationRetryCountRef.current.set(
|
||||
toolCall.toolCallId,
|
||||
retryCount + 1,
|
||||
)
|
||||
|
||||
const feedback =
|
||||
formatValidationFeedback(result)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
`[display_diagram] Validation failed (attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}):`,
|
||||
result.issues,
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of validation failure (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"failed",
|
||||
{
|
||||
attempt: retryCount + 1,
|
||||
maxAttempts: MAX_VALIDATION_RETRIES,
|
||||
result,
|
||||
imageData: capturedPngData,
|
||||
},
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `[Validation attempt ${retryCount + 1}/${MAX_VALIDATION_RETRIES}]\n${feedback}`,
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Max retries reached - accept the diagram with warning
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Max validation retries reached, accepting diagram",
|
||||
)
|
||||
}
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
|
||||
// Notify UI that we're accepting with issues (include the image)
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
"skipped",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram displayed (validation issues noted but max retries reached).",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Validation passed - clean up retry count
|
||||
validationRetryCountRef.current.delete(
|
||||
toolCall.toolCallId,
|
||||
)
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Validation passed!",
|
||||
)
|
||||
}
|
||||
|
||||
// Notify UI of success (include the image)
|
||||
// Use "success_with_warnings" if valid but has issues
|
||||
const hasWarnings = result.issues.length > 0
|
||||
updateValidationState(
|
||||
toolCall.toolCallId,
|
||||
hasWarnings
|
||||
? "success_with_warnings"
|
||||
: "success",
|
||||
{ result, imageData: capturedPngData },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// PNG capture failed - skip validation
|
||||
updateValidationState(toolCall.toolCallId, "skipped")
|
||||
}
|
||||
} catch (error) {
|
||||
// VLM validation error - log but don't block the user
|
||||
console.warn(
|
||||
"[display_diagram] VLM validation error:",
|
||||
error,
|
||||
)
|
||||
updateValidationState(toolCall.toolCallId, "error", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Validation failed",
|
||||
imageData: capturedPngData || undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Adding tool output with state: output-available",
|
||||
)
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
if (DEBUG) {
|
||||
console.log(
|
||||
"[display_diagram] Tool output added. Diagram should be visible now.",
|
||||
)
|
||||
}
|
||||
}
|
||||
addToolOutput({
|
||||
tool: "display_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Successfully displayed the diagram.",
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditDiagram = async (
|
||||
@@ -494,99 +263,13 @@ Current diagram XML:
|
||||
${currentXml || "No XML available"}
|
||||
\`\`\`
|
||||
|
||||
Please check cell IDs and retry, or use display_diagram to regenerate.`,
|
||||
Please check cell IDs and retry, or rebuild with restructure_diagram.`,
|
||||
})
|
||||
// Clean up the shared original XML ref even on error
|
||||
editDiagramOriginalXmlRef.current.delete(toolCall.toolCallId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAppendDiagram = (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { xml } = toolCall.input as { xml: string }
|
||||
|
||||
// Detect if LLM incorrectly started fresh instead of continuing
|
||||
// LLM should only output bare mxCells now, so wrapper tags indicate error
|
||||
const trimmed = xml.trim()
|
||||
const isFreshStart =
|
||||
trimmed.startsWith("<mxGraphModel") ||
|
||||
trimmed.startsWith("<root") ||
|
||||
trimmed.startsWith("<mxfile") ||
|
||||
trimmed.startsWith('<mxCell id="0"') ||
|
||||
trimmed.startsWith('<mxCell id="1"')
|
||||
|
||||
if (isFreshStart) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `ERROR: You started fresh with wrapper tags. Do NOT include wrapper tags or root cells (id="0", id="1").
|
||||
|
||||
Continue from EXACTLY where the partial ended:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Start your continuation with the NEXT character after where it stopped.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Append to accumulated XML
|
||||
partialXmlRef.current += xml
|
||||
|
||||
// Check if XML is now complete (last mxCell is complete)
|
||||
const isComplete = isMxCellXmlComplete(partialXmlRef.current)
|
||||
|
||||
if (isComplete) {
|
||||
// Wrap and display the complete diagram
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Validation error after assembly: ${validationError}
|
||||
|
||||
Assembled XML:
|
||||
\`\`\`xml
|
||||
${finalXml.substring(0, 2000)}...
|
||||
\`\`\`
|
||||
|
||||
Please use display_diagram with corrected XML.`,
|
||||
})
|
||||
} else {
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: "Diagram assembly complete and displayed successfully.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Still incomplete - signal to continue
|
||||
addToolOutput({
|
||||
tool: "append_diagram",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `XML still incomplete (mxCell not closed). Call append_diagram again to continue.
|
||||
|
||||
Current ending:
|
||||
\`\`\`
|
||||
${partialXmlRef.current.slice(-500)}
|
||||
\`\`\`
|
||||
|
||||
Continue from EXACTLY where you stopped.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural editing. The model sends operations against the tree; the engine
|
||||
* re-derives that tree from whatever is on the canvas right now — including anything
|
||||
@@ -653,60 +336,5 @@ Fix the operations and call restructure_diagram again.`,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* draw_graph: build a flowchart from nodes and arrows, with no positions given.
|
||||
*
|
||||
* The current canvas is deliberately NOT read. Which row a node belongs in depends on
|
||||
* every arrow in the graph, so one new edge can move half the diagram — there is no
|
||||
* meaningful way to merge a new graph into an existing layout. The model edits afterwards
|
||||
* through restructure_diagram, which does read the canvas.
|
||||
*/
|
||||
const handleDrawGraph = async (
|
||||
toolCall: ToolCall,
|
||||
addToolOutput: AddToolOutputFn,
|
||||
) => {
|
||||
const { nodes, edges, title, flow } = toolCall.input as {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
title?: string
|
||||
flow?: "col" | "row"
|
||||
}
|
||||
|
||||
const result = drawGraph(nodes ?? [], edges ?? [], { title, flow })
|
||||
|
||||
if (result.errors.length > 0 || !result.xml) {
|
||||
addToolOutput({
|
||||
tool: "draw_graph",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `Could not draw the graph:
|
||||
${result.errors.map((e) => `- ${e}`).join("\n")}
|
||||
|
||||
Fix the nodes or edges and call draw_graph again.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const loadError = onDisplayChart(result.xml)
|
||||
if (loadError) {
|
||||
addToolOutput({
|
||||
tool: "draw_graph",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
state: "output-error",
|
||||
errorText: `The diagram was built but draw.io rejected it: ${loadError}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const notes = result.warnings.length
|
||||
? `\n\nNotes:\n${result.warnings.map((w) => `- ${w}`).join("\n")}`
|
||||
: ""
|
||||
addToolOutput({
|
||||
tool: "draw_graph",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
output: `Diagram updated.\n\n${result.outline}${notes}`,
|
||||
})
|
||||
}
|
||||
|
||||
return { handleToolCall }
|
||||
}
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
*/
|
||||
|
||||
import { checkNames, resolveStyle } from "./catalog"
|
||||
import {
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type GraphOptions,
|
||||
graphToOperations,
|
||||
} from "./graph"
|
||||
import {
|
||||
applyOperations,
|
||||
collectNames,
|
||||
@@ -74,6 +68,7 @@ export function restructureDiagram(
|
||||
|
||||
const applied = applyOperations(tree, ops)
|
||||
const errors = [...applied.errors]
|
||||
warnings.push(...applied.warnings)
|
||||
|
||||
// Catch invented names before rendering, so the model gets a correctable error
|
||||
// instead of a diagram with blank squares in it.
|
||||
@@ -124,69 +119,6 @@ export function restructureDiagram(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a flowchart, dependency graph, ER diagram or site map from nodes and arrows alone.
|
||||
*
|
||||
* The model gives no positions and no nesting — just what the boxes are and what points at
|
||||
* what. The engine works out how many rows there are, who shares a row, and who goes left
|
||||
* of whom, then hands the result to the same layout and edge router the architecture
|
||||
* diagrams use.
|
||||
*
|
||||
* This exists because declaring a flowchart as nesting does not work: six steps declared in
|
||||
* their natural order become one column, and every branch then has to jump over the step
|
||||
* beside it. The layering has to come from the arrows, and only the engine can see all of
|
||||
* them at once.
|
||||
*
|
||||
* Replaces the whole diagram rather than adding to it: the layer assignment depends on every
|
||||
* arrow, so one new edge can move half the nodes. Editing afterwards goes through
|
||||
* `restructureDiagram` as usual.
|
||||
*/
|
||||
export function drawGraph(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
opts: RestructureOptions & GraphOptions & { title?: string } = {},
|
||||
): RestructureResult {
|
||||
if (nodes.length === 0)
|
||||
return {
|
||||
xml: null,
|
||||
outline: "",
|
||||
errors: ["draw_graph: no nodes — nothing to draw."],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const dupes = nodes
|
||||
.map((n) => n.id)
|
||||
.filter((id, i, all) => all.indexOf(id) !== i)
|
||||
if (dupes.length > 0)
|
||||
return {
|
||||
xml: null,
|
||||
outline: "",
|
||||
errors: [
|
||||
`draw_graph: duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
const graph = graphToOperations(nodes, edges, opts)
|
||||
const warnings: string[] = []
|
||||
if (graph.unknownEndpoints.length)
|
||||
warnings.push(
|
||||
`Dropped edge(s) naming nodes that were not in the node list: ${graph.unknownEndpoints.join(", ")}.`,
|
||||
)
|
||||
if (graph.backEdges.length)
|
||||
warnings.push(
|
||||
`Loop(s) drawn but not used for ordering: ${graph.backEdges
|
||||
.map((e) => `${e.source}→${e.target}`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
|
||||
const ops: Operation[] = opts.title
|
||||
? [{ op: "set_title", title: opts.title }, ...graph.operations]
|
||||
: graph.operations
|
||||
const result = restructureDiagram("", ops, opts)
|
||||
return { ...result, warnings: [...warnings, ...result.warnings] }
|
||||
}
|
||||
|
||||
/** Read the current canvas structure without changing it. */
|
||||
export function describeDiagram(
|
||||
currentXml: string,
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
import { resolveShape } from "./shapes"
|
||||
import { type Role, roleMetrics } from "./theme"
|
||||
import type {
|
||||
Align,
|
||||
ContainerNode,
|
||||
DiagramNode,
|
||||
Justify,
|
||||
PoolNode,
|
||||
RadialNode,
|
||||
Rect,
|
||||
@@ -56,10 +58,181 @@ function growOf(n: DiagramNode): number {
|
||||
return typeof g === "number" && g > 0 ? g : 0
|
||||
}
|
||||
|
||||
/** The cross-axis alignment a node declared. */
|
||||
function alignOf(n: DiagramNode): "start" | "center" | "end" | "stretch" {
|
||||
const a = (n.kind === "box" || n.kind === "group") && n.align
|
||||
return a === "start" || a === "end" || a === "stretch" ? a : "center"
|
||||
/**
|
||||
* The cross-axis alignment a node ends up with: its own `align`, else the parent's
|
||||
* `alignItems`, else centred. Same cascade as CSS, where align-self overrides the
|
||||
* container's align-items.
|
||||
*/
|
||||
function alignOf(n: DiagramNode, parent?: ContainerNode): Align {
|
||||
const own = (n.kind === "box" || n.kind === "group") && n.align
|
||||
if (
|
||||
own === "start" ||
|
||||
own === "end" ||
|
||||
own === "stretch" ||
|
||||
own === "center"
|
||||
)
|
||||
return own
|
||||
const inherited = parent?.kind === "group" ? parent.alignItems : undefined
|
||||
if (
|
||||
inherited === "start" ||
|
||||
inherited === "end" ||
|
||||
inherited === "stretch" ||
|
||||
inherited === "center"
|
||||
)
|
||||
return inherited
|
||||
return "center"
|
||||
}
|
||||
|
||||
/**
|
||||
* The main-axis distribution a container declared, or null when it declared none.
|
||||
*
|
||||
* Null matters: it selects the engine's original per-axis defaults rather than any value
|
||||
* in this vocabulary. A row centred its children and padded their gaps, a column packed to
|
||||
* the top — neither is expressible as one `Justify`, and both are what every diagram built
|
||||
* before this existed relies on. Declaring `justify` opts out of them.
|
||||
*/
|
||||
function justifyOf(n: ContainerNode): Justify | null {
|
||||
const j = n.kind === "group" ? n.justify : undefined
|
||||
return j === "start" ||
|
||||
j === "center" ||
|
||||
j === "end" ||
|
||||
j === "between" ||
|
||||
j === "around" ||
|
||||
j === "evenly"
|
||||
? j
|
||||
: null
|
||||
}
|
||||
|
||||
/** The width cap a node declared, or Infinity. */
|
||||
function maxWOf(n: DiagramNode): number {
|
||||
const m = (n.kind === "box" || n.kind === "group") && n.maxW
|
||||
return typeof m === "number" && m > 0 ? m : Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide `room` among weighted children, honouring each one's floor and ceiling.
|
||||
*
|
||||
* The naive version — give each child `room * weight / total` and never go below its
|
||||
* content width — overflows: a child whose content is wider than its share keeps the
|
||||
* wider figure, and the total then exceeds what there was to divide, so the last child
|
||||
* hangs out of the frame.
|
||||
*
|
||||
* CSS resolves this by FREEZING any item that cannot take its share and re-dividing the
|
||||
* rest among those that still can, repeating until nothing changes. That is what this
|
||||
* does. It terminates because every round either freezes at least one child or stops.
|
||||
*
|
||||
* Returns the width for each child, in order; a child with no weight keeps its size.
|
||||
*/
|
||||
function shareOut(
|
||||
sizes: number[],
|
||||
weights: number[],
|
||||
caps: number[],
|
||||
floors: number[],
|
||||
room: number,
|
||||
): number[] {
|
||||
const out = [...sizes]
|
||||
const frozen = sizes.map((_, i) => weights[i] <= 0)
|
||||
for (;;) {
|
||||
const liveTotal = weights.reduce(
|
||||
(s, w, i) => s + (frozen[i] ? 0 : w),
|
||||
0,
|
||||
)
|
||||
if (liveTotal <= 0) return out
|
||||
// What is left once everything already settled has taken its width.
|
||||
const rest = room - out.reduce((s, v, i) => s + (frozen[i] ? v : 0), 0)
|
||||
let changed = false
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (frozen[i]) continue
|
||||
const share = (rest * weights[i]) / liveTotal
|
||||
// A child cannot go below its own content, and cannot pass a declared cap.
|
||||
// Either way it settles here and the others divide what is left.
|
||||
if (share < floors[i]) {
|
||||
out[i] = floors[i]
|
||||
frozen[i] = true
|
||||
changed = true
|
||||
} else if (share > caps[i]) {
|
||||
out[i] = caps[i]
|
||||
frozen[i] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) continue
|
||||
for (let i = 0; i < out.length; i++)
|
||||
if (!frozen[i]) out[i] = (rest * weights[i]) / liveTotal
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrowest a node may be squeezed to when weights divide a row.
|
||||
*
|
||||
* Its own content width, unless it opted out with `minW0` (CSS's `min-width: 0`), in which
|
||||
* case a weight may take it below that. Matching CSS here is deliberate: `min-width`
|
||||
* defaults to `auto`, so in a browser too a `flex: 2` column stops shrinking at its text
|
||||
* and a declared 2:1 comes out closer to 1.4:1 — surprising, but it is what everyone
|
||||
* writing flexbox already works with.
|
||||
*/
|
||||
function floorOf(n: DiagramNode, contentW: number): number {
|
||||
const opted = (n.kind === "box" || n.kind === "group") && n.minW0
|
||||
return opted ? 0 : contentW
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this node need the full width of its parent to mean what it says?
|
||||
*
|
||||
* A row whose children carry `grow` weights does: the weights are shares of the row's
|
||||
* width, so if the row is only as wide as its own content there is nothing to share and
|
||||
* every declared proportion silently comes out 1:1.
|
||||
*
|
||||
* This is where CSS and this engine disagree, and the disagreement is why it has to be
|
||||
* inferred. CSS and Yoga default `align-items` to `stretch`, so a row inside a column
|
||||
* fills that column's width for free. This engine defaults to `center`, which is the
|
||||
* better default for diagrams — a lone icon in a wide frame should sit in the middle, not
|
||||
* be smeared across it — but it means a row of weighted columns gets no width unless
|
||||
* something asks. Declaring weights IS the ask.
|
||||
*/
|
||||
function needsFullWidth(n: DiagramNode): boolean {
|
||||
return (
|
||||
n.kind === "group" &&
|
||||
n.dir === "row" &&
|
||||
n.children.some((c) => growOf(c) > 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the children start, and how much goes between them — CSS's justify-content.
|
||||
*
|
||||
* `slack` is what is left after the children and their base gaps. Returning both the
|
||||
* leading offset and the per-gap addition covers all six values in one place, so the
|
||||
* row and column branches no longer need their own contradictory policies.
|
||||
*/
|
||||
function distribute(
|
||||
justify: Justify,
|
||||
slack: number,
|
||||
count: number,
|
||||
): { lead: number; extraGap: number } {
|
||||
if (slack <= 0 || count === 0) return { lead: 0, extraGap: 0 }
|
||||
switch (justify) {
|
||||
case "center":
|
||||
return { lead: slack / 2, extraGap: 0 }
|
||||
case "end":
|
||||
return { lead: slack, extraGap: 0 }
|
||||
case "between":
|
||||
return count > 1
|
||||
? { lead: 0, extraGap: slack / (count - 1) }
|
||||
: { lead: 0, extraGap: 0 }
|
||||
case "around": {
|
||||
// Half a share before the first child and after the last, a full share between.
|
||||
const share = slack / count
|
||||
return { lead: share / 2, extraGap: share }
|
||||
}
|
||||
case "evenly": {
|
||||
const share = slack / (count + 1)
|
||||
return { lead: share, extraGap: share }
|
||||
}
|
||||
default:
|
||||
return { lead: 0, extraGap: 0 }
|
||||
}
|
||||
}
|
||||
/** Height of a container's title strip. Zero when it has no label — an empty strip
|
||||
* reads as a dead band at the top of the frame. */
|
||||
@@ -180,6 +353,43 @@ export interface Placed {
|
||||
children: Placed[]
|
||||
}
|
||||
|
||||
/** One line of a wrapped row: which children sit on it, and how big it is. */
|
||||
interface WrapLine {
|
||||
items: Placed[]
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Break a row of children into lines that each fit `room`.
|
||||
*
|
||||
* Greedy, the same rule as a text line-breaker and as CSS flex-wrap: keep adding to the
|
||||
* current line while it fits, otherwise start a new one. A single child wider than the
|
||||
* whole row still gets its own line rather than being dropped.
|
||||
*
|
||||
* Shared by measure and place so both agree on where the breaks fall — computing them
|
||||
* twice from the same input is cheap, keeping two copies in sync is not.
|
||||
*/
|
||||
function wrapLines(kids: Placed[], room: number, gap: number): WrapLine[] {
|
||||
const lines: WrapLine[] = []
|
||||
let cur: WrapLine | null = null
|
||||
for (const k of kids) {
|
||||
const next = cur ? cur.width + gap + k.rect.w : k.rect.w
|
||||
if (cur && next > room && cur.items.length > 0) {
|
||||
lines.push(cur)
|
||||
cur = null
|
||||
}
|
||||
if (!cur) cur = { items: [k], width: k.rect.w, height: k.rect.h }
|
||||
else {
|
||||
cur.items.push(k)
|
||||
cur.width = next
|
||||
cur.height = Math.max(cur.height, k.rect.h)
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrows layout needs, which the node tree alone does not carry.
|
||||
*
|
||||
@@ -209,11 +419,20 @@ function visibleText(label: string): string {
|
||||
*
|
||||
* The role scales the estimate: a banner sets 20px type and a footnote 9px, and layout has
|
||||
* to reserve what render will draw or the text overflows its cell.
|
||||
*
|
||||
* `atWidth` is the width the box will ACTUALLY be drawn at, when that is already known
|
||||
* (see the reflow pass in `layoutForest`). Height is then counted for that width while
|
||||
* the reported width stays intrinsic — which is what a browser does, and what this was
|
||||
* missing: a paragraph measured at its 260px natural width needs eight lines, the same
|
||||
* paragraph stretched to 750px needs three, and reserving the eight-line height left
|
||||
* every panel with a slab of dead space under its text.
|
||||
*/
|
||||
export function autoBoxSize(
|
||||
label: string,
|
||||
role?: Role,
|
||||
shape?: string,
|
||||
atWidth?: number,
|
||||
maxW?: number,
|
||||
): { w: number; h: number } {
|
||||
const spec = shape ? resolveShape(shape)?.spec : undefined
|
||||
// A glyph shape (umlActor…) has a fixed figure with the label below it: the slot is
|
||||
@@ -226,19 +445,35 @@ export function autoBoxSize(
|
||||
}
|
||||
}
|
||||
const r = roleMetrics(role)
|
||||
const maxW = Math.round(260 * Math.max(1, r.charScale))
|
||||
// Two caps: the role's own default, and whatever the model declared. The declared one
|
||||
// is allowed to go BELOW the floor of 120 — capping a box at 90px has to mean 90px,
|
||||
// or the cap silently does nothing on short labels.
|
||||
const roleCap = Math.round(260 * Math.max(1, r.charScale))
|
||||
const declared = maxW != null && maxW > 0 ? maxW : Number.POSITIVE_INFINITY
|
||||
const explicit = visibleText(label).split("\n")
|
||||
const longest = Math.max(1, ...explicit.map((l) => l.length))
|
||||
const w = Math.min(
|
||||
maxW,
|
||||
Math.max(120, Math.round(longest * CHAR_W * r.charScale + 28)),
|
||||
const natural = Math.max(
|
||||
120,
|
||||
Math.round(longest * CHAR_W * r.charScale + 28),
|
||||
)
|
||||
const w = Math.min(declared, roleCap, natural)
|
||||
const s = spec?.textScale ?? 1
|
||||
// Count the lines the text ACTUALLY occupies: draw.io wraps at the box width, so a
|
||||
// long line becomes several. Estimating by explicit newlines alone left the box one
|
||||
// line tall while the text wrapped to six — and overflowed straight out of it.
|
||||
//
|
||||
// Wrapping happens at the DRAWN width, which for a stretched box is wider than the
|
||||
// intrinsic one. `atWidth` carries it; the shape factor is divided back out because
|
||||
// it is applied to the final height below.
|
||||
// A declared cap also bounds the reflow hint: a box capped at 200 never gets to count
|
||||
// its lines as if it had been drawn at 600, however wide its parent turned out.
|
||||
const textW = Math.min(
|
||||
declared,
|
||||
Math.max(w, atWidth != null ? atWidth / s : 0),
|
||||
)
|
||||
const charsPerLine = Math.max(
|
||||
8,
|
||||
Math.floor((w - 28) / (CHAR_W * r.charScale)),
|
||||
Math.floor((textW - 28) / (CHAR_W * r.charScale)),
|
||||
)
|
||||
const lines = explicit.reduce(
|
||||
(sum, l) => sum + Math.max(1, Math.ceil(l.length / charsPerLine)),
|
||||
@@ -250,7 +485,6 @@ export function autoBoxSize(
|
||||
// a rhombus exactly half — so the box grows by the shape's measured factor.
|
||||
// Verified in the real editor: the same sentence overflows a 1.0× rhombus and fits
|
||||
// a 1.5× one.
|
||||
const s = spec?.textScale ?? 1
|
||||
return { w: Math.round(w * s), h: Math.round(h * s) }
|
||||
}
|
||||
|
||||
@@ -443,6 +677,7 @@ function measure(
|
||||
n: DiagramNode,
|
||||
defaultGlyph: number,
|
||||
links: LayoutLinks,
|
||||
widthHints?: Map<string, number>,
|
||||
): Placed {
|
||||
if (n.kind === "icon") {
|
||||
const glyph = n.size ?? defaultGlyph
|
||||
@@ -450,7 +685,15 @@ function measure(
|
||||
return { node: n, rect: { x: 0, y: 0, ...s }, children: [] }
|
||||
}
|
||||
if (n.kind === "box") {
|
||||
const auto = autoBoxSize(n.label, n.role, n.shape)
|
||||
// The hint is the width this box was drawn at last pass; its text rewraps to
|
||||
// that width, so its height has to be counted there.
|
||||
const auto = autoBoxSize(
|
||||
n.label,
|
||||
n.role,
|
||||
n.shape,
|
||||
widthHints?.get(n.id),
|
||||
n.maxW,
|
||||
)
|
||||
return {
|
||||
node: n,
|
||||
rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h },
|
||||
@@ -461,7 +704,9 @@ function measure(
|
||||
return { node: n, rect: { x: 0, y: 0, w: 0, h: 30 }, children: [] }
|
||||
}
|
||||
|
||||
const kids = n.children.map((c) => measure(c, defaultGlyph, links))
|
||||
const kids = n.children.map((c) =>
|
||||
measure(c, defaultGlyph, links, widthHints),
|
||||
)
|
||||
const head = headerFor(n)
|
||||
const gap = n.gap
|
||||
|
||||
@@ -581,6 +826,9 @@ function measure(
|
||||
|
||||
// group: row or col
|
||||
const pad = padOf(n)
|
||||
// A declared cap wins over the measured content, so a row of six cards capped at 900
|
||||
// reports 900 and the place pass below has real negative slack to shrink into.
|
||||
const cap = maxWOf(n)
|
||||
if (n.dir === "row") {
|
||||
const tallest = Math.max(0, ...kids.map((k) => k.rect.h))
|
||||
// Only a group stretches to match its siblings. A leaf keeps its natural size,
|
||||
@@ -590,6 +838,31 @@ function measure(
|
||||
// lane bands from the nodes sitting on them.
|
||||
for (const k of kids)
|
||||
if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
|
||||
// A capped row wraps into as many lines as it takes, so the cap is a real limit
|
||||
// rather than something the content silently overflows. Sized here and positioned
|
||||
// by the same line-breaking in `place`, so measure and place cannot disagree.
|
||||
if (cap < Number.POSITIVE_INFINITY) {
|
||||
const lines = wrapLines(kids, cap - pad * 2, gap)
|
||||
const h =
|
||||
head +
|
||||
pad * 2 +
|
||||
lines.reduce((s, l) => s + l.height, 0) +
|
||||
gap * Math.max(0, lines.length - 1)
|
||||
const widestLine = Math.max(0, ...lines.map((l) => l.width))
|
||||
return {
|
||||
node: n,
|
||||
rect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: Math.max(
|
||||
Math.min(cap, pad * 2 + widestLine),
|
||||
titleFloor(n.label, pad),
|
||||
),
|
||||
h,
|
||||
},
|
||||
children: kids,
|
||||
}
|
||||
}
|
||||
const w =
|
||||
pad * 2 +
|
||||
kids.reduce((s, k) => s + k.rect.w, 0) +
|
||||
@@ -597,7 +870,12 @@ function measure(
|
||||
const h = head + pad * 2 + Math.max(0, ...kids.map((k) => k.rect.h))
|
||||
return {
|
||||
node: n,
|
||||
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
|
||||
rect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: Math.max(w, titleFloor(n.label, pad)),
|
||||
h,
|
||||
},
|
||||
children: kids,
|
||||
}
|
||||
}
|
||||
@@ -606,6 +884,43 @@ function measure(
|
||||
// Only a group stretches — same reasoning as the row branch above.
|
||||
for (const k of kids)
|
||||
if (k.node.kind === "group") k.rect.w = Math.max(k.rect.w, widest)
|
||||
// A row of weighted columns divides a width it does not have yet (see needsFullWidth).
|
||||
// Its share of the extra has to be handed out during MEASURE: `place` runs top-down, so
|
||||
// a child widened there leaves this container already sized for the narrow version, and
|
||||
// the child then sticks out of the frame that is supposed to contain it.
|
||||
//
|
||||
// Distributed by weight rather than to the full interior, because that is the answer
|
||||
// `place` will independently arrive at — the two passes have to agree or the frame is
|
||||
// sized for one arrangement and drawn as another.
|
||||
for (const k of kids) {
|
||||
if (!needsFullWidth(k.node) || k.node.kind !== "group") continue
|
||||
const kp = padOf(k.node)
|
||||
const room =
|
||||
widest - kp * 2 - k.node.gap * Math.max(0, k.children.length - 1)
|
||||
const weights = k.children.map((c) => growOf(c.node))
|
||||
// Unweighted children keep their size and take their width off the top; the rest is
|
||||
// what the weights divide.
|
||||
const fixed = k.children.reduce(
|
||||
(s, c, i) => s + (weights[i] ? 0 : c.rect.w),
|
||||
0,
|
||||
)
|
||||
const widths = shareOut(
|
||||
k.children.map((c) => c.rect.w),
|
||||
weights,
|
||||
k.children.map((c) => maxWOf(c.node)),
|
||||
k.children.map((c) => floorOf(c.node, c.rect.w)),
|
||||
room - fixed,
|
||||
)
|
||||
k.children.forEach((c, i) => {
|
||||
if (weights[i]) c.rect.w = widths[i]
|
||||
})
|
||||
k.rect.w = Math.max(
|
||||
k.rect.w,
|
||||
kp * 2 +
|
||||
k.children.reduce((s, c) => s + c.rect.w, 0) +
|
||||
k.node.gap * Math.max(0, k.children.length - 1),
|
||||
)
|
||||
}
|
||||
const w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
|
||||
const h =
|
||||
head +
|
||||
@@ -614,7 +929,12 @@ function measure(
|
||||
gap * Math.max(0, kids.length - 1)
|
||||
return {
|
||||
node: n,
|
||||
rect: { x: 0, y: 0, w: Math.max(w, titleFloor(n.label, pad)), h },
|
||||
rect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: Math.min(cap, Math.max(w, titleFloor(n.label, pad))),
|
||||
h,
|
||||
},
|
||||
children: kids,
|
||||
}
|
||||
}
|
||||
@@ -628,7 +948,20 @@ function measure(
|
||||
* stretched frame reads as deliberately spaced instead of sparse, and the resulting
|
||||
* cluster is centred.
|
||||
*/
|
||||
function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
function place(
|
||||
p: Placed,
|
||||
x: number,
|
||||
y: number,
|
||||
links: LayoutLinks,
|
||||
/**
|
||||
* True when this container's width was decided from outside its own content — the page
|
||||
* aspect gave the top level a width, or an ancestor stretched it. Only then do `grow`
|
||||
* weights read as absolute proportions ("3:1"), because only then is there a total to
|
||||
* take a share OF. It passes down through stretched children: a full-width column that
|
||||
* inherited its width hands that same certainty to the row inside it.
|
||||
*/
|
||||
definiteWidth = false,
|
||||
): void {
|
||||
p.rect.x = Math.round(x)
|
||||
p.rect.y = Math.round(y)
|
||||
const n = p.node
|
||||
@@ -701,6 +1034,64 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
}
|
||||
|
||||
const alongRow = n.dir === "row"
|
||||
|
||||
// A capped row that had to WRAP lays each line out like its own row, so the two passes
|
||||
// cannot disagree about where the breaks fall. A capped row that still fits on one line
|
||||
// falls through to the ordinary path below — it is an ordinary row, and skipping that
|
||||
// path would skip `grow`, leaving declared proportions unapplied.
|
||||
const wrapped =
|
||||
alongRow && maxWOf(n) < Number.POSITIVE_INFINITY && kids.length > 0
|
||||
? wrapLines(kids, innerW, n.gap)
|
||||
: null
|
||||
if (wrapped && wrapped.length > 1) {
|
||||
const lines = wrapped
|
||||
let lineTop = innerTop
|
||||
for (const line of lines) {
|
||||
const used = line.items.reduce((s, k) => s + k.rect.w, 0)
|
||||
const room = Math.max(
|
||||
0,
|
||||
innerW - used - n.gap * (line.items.length - 1),
|
||||
)
|
||||
// A wrapped line follows the same row default as an unwrapped one: centred,
|
||||
// with its gaps padded by up to one extra gap.
|
||||
const declared = justifyOf(n)
|
||||
const { lead, extraGap } = declared
|
||||
? distribute(declared, room, line.items.length)
|
||||
: line.items.length > 1
|
||||
? (() => {
|
||||
const e = Math.min(
|
||||
n.gap,
|
||||
room / (line.items.length - 1),
|
||||
)
|
||||
return {
|
||||
lead: Math.max(
|
||||
0,
|
||||
(room - e * (line.items.length - 1)) / 2,
|
||||
),
|
||||
extraGap: e,
|
||||
}
|
||||
})()
|
||||
: { lead: room / 2, extraGap: 0 }
|
||||
let x = innerX + lead
|
||||
for (const kid of line.items) {
|
||||
const a = alignOf(kid.node, n)
|
||||
const off =
|
||||
a === "start"
|
||||
? 0
|
||||
: a === "end"
|
||||
? line.height - kid.rect.h
|
||||
: a === "stretch"
|
||||
? 0
|
||||
: (line.height - kid.rect.h) / 2
|
||||
if (a === "stretch") kid.rect.h = line.height
|
||||
place(kid, x, lineTop + Math.max(0, off), links)
|
||||
x += kid.rect.w + n.gap + extraGap
|
||||
}
|
||||
lineTop += line.height + n.gap
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const sizes = kids.map((k) => (alongRow ? k.rect.w : k.rect.h))
|
||||
const content = sizes.reduce((s, v) => s + v, 0)
|
||||
const extent = alongRow ? innerW : innerH
|
||||
@@ -708,42 +1099,107 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
let slack = Math.max(0, extent - content - n.gap * (k - 1))
|
||||
|
||||
// flex-grow: children with a weight split the leftover space between them, TeX's
|
||||
// glue. This runs before the gap stretch below — declared weights are a statement
|
||||
// about where the slack should go, and padding it into the gaps instead would
|
||||
// silently override that statement.
|
||||
const weights = kids.map((kid) => growOf(kid.node))
|
||||
// glue. This runs before the justify distribution below — declared weights are a
|
||||
// statement about where the slack should go, and spreading it into the gaps instead
|
||||
// would silently override that statement.
|
||||
//
|
||||
// A LEAF box never grows along a column: growing its height just inflates a text
|
||||
// box around its own text — the giant hollow panels of an early poster. Along a row
|
||||
// it stays legal (two bars splitting a card's width is real layout), and containers
|
||||
// grow on either axis, since they distribute the space onwards.
|
||||
const weights = kids.map((kid) =>
|
||||
!alongRow && kid.node.kind === "box" ? 0 : growOf(kid.node),
|
||||
)
|
||||
const totalWeight = weights.reduce((s, v) => s + v, 0)
|
||||
if (totalWeight > 0 && slack > 0) {
|
||||
kids.forEach((kid, i) => {
|
||||
const extra = (slack * weights[i]) / totalWeight
|
||||
if (alongRow) kid.rect.w += extra
|
||||
else kid.rect.h += extra
|
||||
})
|
||||
slack = 0
|
||||
// Along a ROW, when the container's width was set from OUTSIDE (a declared page
|
||||
// aspect, or its own cap), the weights divide the whole track: `grow: 3` beside
|
||||
// `grow: 1` then really is three times as wide, which is what writing those numbers
|
||||
// means and what CSS's `flex: 3` shorthand does by zeroing flex-basis.
|
||||
//
|
||||
// Otherwise only the slack is divided, which is the older contract: the width came
|
||||
// from the content itself, so treating the weights as absolute proportions would
|
||||
// shrink a column below the text already in it.
|
||||
const proportional =
|
||||
alongRow &&
|
||||
(definiteWidth ||
|
||||
needsFullWidth(n) ||
|
||||
maxWOf(n) < Number.POSITIVE_INFINITY)
|
||||
if (proportional) {
|
||||
// The weights divide the whole track. shareOut settles anyone who cannot take
|
||||
// their share — too wide already, or capped — and re-divides among the rest, so
|
||||
// the total never exceeds the room available and no child spills out.
|
||||
const fixed = kids.reduce(
|
||||
(s, kid, i) => s + (weights[i] ? 0 : kid.rect.w),
|
||||
0,
|
||||
)
|
||||
const widths = shareOut(
|
||||
kids.map((kid) => kid.rect.w),
|
||||
weights,
|
||||
kids.map((kid) => maxWOf(kid.node)),
|
||||
kids.map((kid) => floorOf(kid.node, kid.rect.w)),
|
||||
extent - n.gap * (k - 1) - fixed,
|
||||
)
|
||||
kids.forEach((kid, i) => {
|
||||
if (weights[i]) kid.rect.w = widths[i]
|
||||
})
|
||||
} else {
|
||||
kids.forEach((kid, i) => {
|
||||
if (!weights[i]) return
|
||||
const share = (slack * weights[i]) / totalWeight
|
||||
// Never past a declared cap: min/max outranks grow, Yoga's rule too.
|
||||
if (alongRow)
|
||||
kid.rect.w = Math.min(maxWOf(kid.node), kid.rect.w + share)
|
||||
else kid.rect.h += share
|
||||
})
|
||||
}
|
||||
// Recompute: a child clamped by its cap or its content refused part of its share,
|
||||
// and that remainder is still free space the distribution below has to place.
|
||||
const used = kids.reduce(
|
||||
(s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h),
|
||||
0,
|
||||
)
|
||||
slack = Math.max(0, extent - used - n.gap * (k - 1))
|
||||
}
|
||||
|
||||
// Slack policy differs by axis. A ROW spreads and centres — a flowchart layer
|
||||
// reads as a pyramid, and dead space at the right edge of a row looks like a
|
||||
// mistake. A COLUMN packs to the top and leaves the slack at the bottom: a column
|
||||
// is usually tall because a SIBLING made it tall, and stretching its gaps (or its
|
||||
// boxes, via grow) turns every panel into a huge frame with three lines floating
|
||||
// in the middle — the single ugliest thing in the poster this replaced.
|
||||
const gap =
|
||||
alongRow && k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
|
||||
const span =
|
||||
kids.reduce((s, kid) => s + (alongRow ? kid.rect.w : kid.rect.h), 0) +
|
||||
gap * Math.max(0, k - 1)
|
||||
let cur = alongRow ? innerX + Math.max(0, (extent - span) / 2) : innerTop
|
||||
// How the remaining slack is spread. A declared `justify` decides it; otherwise the
|
||||
// engine's original per-axis defaults stand, because they are what every diagram built
|
||||
// before `justify` existed was laid out with:
|
||||
//
|
||||
// ROW — spread the gaps by up to one extra gap, then centre the result. A flowchart
|
||||
// layer reads as a pyramid, and dead space at the right edge of a row looks like a
|
||||
// mistake.
|
||||
// COLUMN — pack to the top and leave the slack at the bottom. A column is usually
|
||||
// tall because a SIBLING made it tall, and stretching its gaps turns every panel into
|
||||
// a big frame with three lines floating in the middle.
|
||||
const declared = justifyOf(n)
|
||||
let lead: number
|
||||
let extraGap: number
|
||||
if (declared) {
|
||||
;({ lead, extraGap } = distribute(declared, slack, k))
|
||||
} else if (alongRow && k > 1) {
|
||||
extraGap = Math.min(n.gap, slack / (k - 1))
|
||||
lead = Math.max(0, (slack - extraGap * (k - 1)) / 2)
|
||||
} else {
|
||||
lead = 0
|
||||
extraGap = 0
|
||||
}
|
||||
const gap = n.gap + extraGap
|
||||
let cur = (alongRow ? innerX : innerTop) + lead
|
||||
|
||||
for (const kid of kids) {
|
||||
// A stretching role fills the cross axis: a masthead spans its page, a section
|
||||
// heading spans its column. Measured at its text width, then widened here — the
|
||||
// container's size still comes from the widest ordinary child.
|
||||
const kn = kid.node
|
||||
const a = alignOf(kn)
|
||||
const a = alignOf(kn, n)
|
||||
const stretches =
|
||||
a === "stretch" ||
|
||||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch)
|
||||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch) ||
|
||||
// A row of weighted columns fills this column, whatever the alignment default
|
||||
// says — see needsFullWidth. Only along a column: across a row the cross axis
|
||||
// is height, and a row does not hand its height out proportionally.
|
||||
(!alongRow && needsFullWidth(kn))
|
||||
// Cross-axis position: centred unless the child asked for an edge.
|
||||
const cross = (room: number, size: number): number => {
|
||||
if (a === "start") return 0
|
||||
@@ -752,11 +1208,28 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
}
|
||||
if (alongRow) {
|
||||
if (stretches) kid.rect.h = innerH
|
||||
place(kid, cur, innerTop + cross(innerH, kid.rect.h), links)
|
||||
// A row's child got its width from the weights above, so if this row's own
|
||||
// width was definite the child's is too.
|
||||
place(
|
||||
kid,
|
||||
cur,
|
||||
innerTop + cross(innerH, kid.rect.h),
|
||||
links,
|
||||
definiteWidth && growOf(kn) > 0,
|
||||
)
|
||||
cur += kid.rect.w + gap
|
||||
} else {
|
||||
if (stretches) kid.rect.w = innerW
|
||||
place(kid, innerX + cross(innerW, kid.rect.w), cur, links)
|
||||
// Stretching along a column still respects a declared cap.
|
||||
if (stretches) kid.rect.w = Math.min(maxWOf(kn), innerW)
|
||||
// A stretched child fills a width this column already knew, so it inherits
|
||||
// that certainty; an unstretched one is still sized by its own content.
|
||||
place(
|
||||
kid,
|
||||
innerX + cross(innerW, kid.rect.w),
|
||||
cur,
|
||||
links,
|
||||
definiteWidth && stretches,
|
||||
)
|
||||
cur += kid.rect.h + gap
|
||||
}
|
||||
}
|
||||
@@ -867,6 +1340,12 @@ export interface LayoutResult {
|
||||
/** Where the tree starts on the page. Leaves room for a title above it. */
|
||||
const ORIGIN = { x: 40, y: 90 }
|
||||
const MARGIN = { right: 40, bottom: 50 }
|
||||
/**
|
||||
* Area of draw.io's default page (A4 at 850x1100), the yardstick a declared aspect ratio
|
||||
* is measured against. Using the editor's own page size means aspect 1 lands on a square
|
||||
* about one page in area, rather than on some number invented here.
|
||||
*/
|
||||
const PAGE_AREA = 850 * 1100
|
||||
|
||||
/**
|
||||
* Lay out a forest of roots side by side and report the page size that fits them.
|
||||
@@ -882,34 +1361,130 @@ export function layoutForest(
|
||||
/** The diagram's links. Needed by sequence containers, which size themselves from
|
||||
* the number of messages between their participants. */
|
||||
links?: LayoutLinks
|
||||
/**
|
||||
* Target width : height for the page. When set, the top level is given a width
|
||||
* that lands near it, which is the only way a proportional rule has anything to
|
||||
* divide: without a definite width there is no leftover space, so every `grow`
|
||||
* weight resolves to zero. Yoga's own docs say the same — a container distributes
|
||||
* "any remaining space" among its children, so some space has to remain.
|
||||
*/
|
||||
aspect?: number
|
||||
} = {},
|
||||
): LayoutResult {
|
||||
const glyph = opts.iconSize ?? ICON_SIZE
|
||||
const gap = opts.gap ?? 70
|
||||
const links: LayoutLinks = opts.links ?? []
|
||||
const placed = roots.map((r) => measure(r, glyph, links))
|
||||
|
||||
let cur = ORIGIN.x
|
||||
for (const p of placed) {
|
||||
const n = p.node
|
||||
const held =
|
||||
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
|
||||
if (held) {
|
||||
place(p, held.x, held.y, links)
|
||||
} else {
|
||||
place(p, cur, ORIGIN.y, links)
|
||||
cur += p.rect.w + gap
|
||||
const run = (hints?: Map<string, number>, target?: number): Placed[] => {
|
||||
// A target page width narrower than the content is a request to WRAP, and wrapping
|
||||
// has to happen during measure — the line breaks change every height. So the cap is
|
||||
// pushed onto the root group before measuring, unless it declared its own.
|
||||
const rootCap = new Map<string, number>()
|
||||
if (target) {
|
||||
for (const r of roots)
|
||||
if (
|
||||
r.kind === "group" &&
|
||||
r.dir === "row" &&
|
||||
!r.pinned &&
|
||||
r.maxW == null
|
||||
) {
|
||||
rootCap.set(r.id, target)
|
||||
r.maxW = target
|
||||
}
|
||||
}
|
||||
const placed = roots.map((r) => measure(r, glyph, links, hints))
|
||||
// Widen the roots to the target width when they came out narrower. Only a group can
|
||||
// absorb it — a grid, pool, sequence or radial computes its interior from its own
|
||||
// rule, so forcing one wider just adds dead space inside it.
|
||||
if (target) {
|
||||
const own = placed.filter(
|
||||
(p) => p.node.kind !== "title" && !p.node.pinned,
|
||||
)
|
||||
const spread = gap * Math.max(0, own.length - 1)
|
||||
const share = (target - spread) / Math.max(1, own.length)
|
||||
for (const p of own)
|
||||
if (p.node.kind === "group")
|
||||
p.rect.w = Math.min(
|
||||
maxWOf(p.node),
|
||||
Math.max(p.rect.w, share),
|
||||
)
|
||||
}
|
||||
let cur = ORIGIN.x
|
||||
for (const p of placed) {
|
||||
const n = p.node
|
||||
const held =
|
||||
n.kind === "title" ? null : n.pinned ? (n.rect ?? null) : null
|
||||
if (held) {
|
||||
place(p, held.x, held.y, links)
|
||||
} else {
|
||||
// A target width makes the top level's width definite, which is what lets
|
||||
// grow weights inside it read as proportions of a whole.
|
||||
place(p, cur, ORIGIN.y, links, Boolean(target))
|
||||
cur += p.rect.w + gap
|
||||
}
|
||||
}
|
||||
// Undo only now: `place` needs the cap to break lines in the same places `measure`
|
||||
// did, but `roots` is the caller's tree and must come back exactly as it went in.
|
||||
for (const r of roots)
|
||||
if (rootCap.has(r.id) && r.kind === "group") r.maxW = undefined
|
||||
return placed
|
||||
}
|
||||
|
||||
const extent = (placed: Placed[]) => {
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
const visit = (p: Placed) => {
|
||||
maxX = Math.max(maxX, p.rect.x + p.rect.w)
|
||||
maxY = Math.max(maxY, p.rect.y + p.rect.h)
|
||||
p.children.forEach(visit)
|
||||
}
|
||||
placed.forEach(visit)
|
||||
return { maxX, maxY }
|
||||
}
|
||||
|
||||
let placed = run()
|
||||
|
||||
if (opts.aspect && opts.aspect > 0) {
|
||||
// The target width has to come from OUTSIDE the content, or it cannot create the
|
||||
// spare space that proportional rules divide: deriving it from the area the content
|
||||
// already occupies just returns that content's own width back, leaving nothing over.
|
||||
// draw.io's page is the natural external reference — one A4 at 850x1100 — so
|
||||
// width = sqrt(pageArea x aspect) is the first guess.
|
||||
let want = Math.round(Math.sqrt(PAGE_AREA * opts.aspect))
|
||||
|
||||
// Then iterate, because width and height are not independent: widening the page
|
||||
// makes every paragraph rewrap to fewer lines, which SHORTENS it, which changes the
|
||||
// ratio that was being aimed at. One pass therefore lands wide of the mark — asking
|
||||
// for 0.8 gave 1.13. Each round measures what the last width actually produced and
|
||||
// corrects toward the target; three is enough to get inside a few percent, and the
|
||||
// loop stops early once the correction is negligible.
|
||||
//
|
||||
// The hint map is what makes the correction real: it carries the width each box was
|
||||
// drawn at, so its text is re-counted at that width instead of at its intrinsic one.
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
placed = run(undefined, want)
|
||||
const hints = new Map<string, number>()
|
||||
const collect = (p: Placed) => {
|
||||
if (p.node.kind === "box") hints.set(p.node.id, p.rect.w)
|
||||
p.children.forEach(collect)
|
||||
}
|
||||
placed.forEach(collect)
|
||||
placed = run(hints, want)
|
||||
|
||||
const { maxX, maxY } = extent(placed)
|
||||
const w = maxX + MARGIN.right
|
||||
const h = maxY + MARGIN.bottom
|
||||
const err = w / h / opts.aspect
|
||||
if (Math.abs(err - 1) < 0.04) break
|
||||
// Geometric correction: to move the ratio by a factor, move the width by its
|
||||
// square root, since shrinking the width lengthens the page and vice versa.
|
||||
const next = Math.round(want / Math.sqrt(err))
|
||||
if (next === want) break
|
||||
want = next
|
||||
}
|
||||
}
|
||||
|
||||
let maxX = 0
|
||||
let maxY = 0
|
||||
const visit = (p: Placed) => {
|
||||
maxX = Math.max(maxX, p.rect.x + p.rect.w)
|
||||
maxY = Math.max(maxY, p.rect.y + p.rect.h)
|
||||
p.children.forEach(visit)
|
||||
}
|
||||
placed.forEach(visit)
|
||||
const { maxX, maxY } = extent(placed)
|
||||
|
||||
return {
|
||||
roots: placed,
|
||||
|
||||
@@ -80,8 +80,32 @@ export const MARKER = {
|
||||
grow: "dai_grow",
|
||||
/** Cross-axis position within the parent: "start" | "center" | "end". */
|
||||
align: "dai_align",
|
||||
/** How a container spreads children along its own axis — justify-content. */
|
||||
justify: "dai_justify",
|
||||
/** A container's cross-axis default for children that declare no align of their own. */
|
||||
alignItems: "dai_aitems",
|
||||
/** Opted out of the content-width floor when weights divide a row — CSS's min-width:0. */
|
||||
minw0: "dai_minw0",
|
||||
/**
|
||||
* Declared width cap, px.
|
||||
*
|
||||
* Has to be a marker rather than inferred from the drawn width: the two are only equal
|
||||
* when the cap actually bit. A box capped at 400 that happens to be 260 wide would come
|
||||
* back with a 260 cap, and the next re-layout could never let it grow again.
|
||||
*/
|
||||
maxw: "dai_maxw",
|
||||
/** A container's interior padding, px. */
|
||||
pad: "dai_pad",
|
||||
/**
|
||||
* The page's declared width:height, on the default layer's cell.
|
||||
*
|
||||
* Page-level rather than per-node, so it goes on layer "1" — the one cell every
|
||||
* diagram has and draw.io never discards. It cannot be inferred from pageWidth and
|
||||
* pageHeight: those are what the last layout produced, so reading them back would
|
||||
* turn whatever shape a diagram happened to come out as into a standing request to
|
||||
* keep it.
|
||||
*/
|
||||
aspect: "dai_aspect",
|
||||
/**
|
||||
* Marks a cell as chrome the engine draws and owns: a pool's lane bands, its label
|
||||
* columns, its milestone strip. The parser must not read these back as nodes — they are
|
||||
@@ -237,6 +261,36 @@ export function isPinned(style: string): boolean {
|
||||
* load-bearing there: the container tokens rely on appending `container=1` after a catalog
|
||||
* style that may say `container=0`.
|
||||
*/
|
||||
/**
|
||||
* Set each `key=value;` token of `tokens` on a style, replacing any value already there.
|
||||
*
|
||||
* Matching is per token, not on the whole run: a catalog style may already declare
|
||||
* `container=1` while saying nothing about `pointerEvents`, and re-adding the whole run
|
||||
* because one token was missing is what let these accumulate.
|
||||
*
|
||||
* Exported because the same defect appeared a second time, on EDGES: an edge's style starts
|
||||
* from whatever the canvas held, which already carried the previous pass's `exitX`/`entryX`
|
||||
* port keys, and the router appended a fresh set on top of them every render — 76 characters
|
||||
* per round-trip, without bound. Any code that re-stamps a computed mxGraph key onto a style
|
||||
* recovered from the canvas needs this rather than `+=`.
|
||||
*/
|
||||
export function appendOnce(style: string, tokens: string): string {
|
||||
let s = style
|
||||
for (const tok of tokens.split(";")) {
|
||||
if (!tok) continue
|
||||
const key = tok.slice(0, tok.indexOf("="))
|
||||
// The key must not be present with ANY value: `container=0` from a catalog stencil
|
||||
// has to be overwritten, which is what appending the correct value does.
|
||||
const has = new RegExp(`(?:^|;)${key}=[^;]*;`).test(s)
|
||||
if (has) {
|
||||
s = s.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
}
|
||||
s = s.endsWith(";") || s === "" ? s : `${s};`
|
||||
s += `${tok};`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function append(style: string, key: string, value: string | number): string {
|
||||
const cleaned = key.startsWith("dai_")
|
||||
? style.replace(new RegExp(`(?:^|(?<=;))${key}=[^;]*;`, "g"), "")
|
||||
@@ -266,8 +320,14 @@ export function stampContainer(
|
||||
},
|
||||
): string {
|
||||
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
||||
s += CONTAINER_TOKENS
|
||||
if (opts.invisible) s += INVISIBLE_TOKENS
|
||||
// Appended only when not already there. These are plain mxGraph keys, so `append`'s
|
||||
// de-duplication (which is limited to `dai_*`) does not cover them — and a container
|
||||
// goes through here on EVERY re-layout, so a blind `+=` grew the style string by
|
||||
// another `container=1;pointerEvents=0;collapsible=0;recursiveResize=0;` per round
|
||||
// trip, without bound. Harmless to draw.io, which takes the last value, but the XML
|
||||
// never reached a fixed point and every edit shipped a longer style.
|
||||
s = appendOnce(s, CONTAINER_TOKENS)
|
||||
if (opts.invisible) s = appendOnce(s, INVISIBLE_TOKENS)
|
||||
s = append(s, MARKER.kind, opts.kind)
|
||||
s = append(s, MARKER.dir, opts.dir)
|
||||
s = append(s, MARKER.gap, Math.round(opts.gap))
|
||||
@@ -416,17 +476,32 @@ export function isAutoSized(style: string): boolean {
|
||||
}
|
||||
|
||||
type FlexAlign = "start" | "center" | "end" | "stretch"
|
||||
type FlexJustify = "start" | "center" | "end" | "between" | "around" | "evenly"
|
||||
|
||||
/** Stamp the flex fields a node carries, so a round-trip preserves them. */
|
||||
export function stampFlex(
|
||||
style: string,
|
||||
opts: { grow?: number; align?: FlexAlign; pad?: number },
|
||||
opts: {
|
||||
grow?: number
|
||||
align?: FlexAlign
|
||||
justify?: FlexJustify
|
||||
alignItems?: FlexAlign
|
||||
maxW?: number
|
||||
minW0?: boolean
|
||||
pad?: number
|
||||
},
|
||||
): string {
|
||||
let s = style
|
||||
if (opts.grow != null && opts.grow > 0)
|
||||
s = append(s, MARKER.grow, opts.grow)
|
||||
if (opts.align && opts.align !== "center")
|
||||
s = append(s, MARKER.align, opts.align)
|
||||
if (opts.justify && opts.justify !== "start")
|
||||
s = append(s, MARKER.justify, opts.justify)
|
||||
if (opts.alignItems) s = append(s, MARKER.alignItems, opts.alignItems)
|
||||
if (opts.maxW != null && opts.maxW > 0)
|
||||
s = append(s, MARKER.maxw, Math.round(opts.maxW))
|
||||
if (opts.minW0) s = append(s, MARKER.minw0, 1)
|
||||
if (opts.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
|
||||
return s
|
||||
}
|
||||
@@ -437,6 +512,63 @@ export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
|
||||
return v === "start" || v === "end" || v === "stretch" ? v : null
|
||||
}
|
||||
|
||||
/** Read a container's cross-axis default. Null means it declared none. */
|
||||
export function readAlignItems(style: string): FlexAlign | null {
|
||||
const v = readMarker(style, MARKER.alignItems)
|
||||
return v === "start" || v === "end" || v === "stretch" || v === "center"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the justify marker back. Anything unrecognised means the default (start). */
|
||||
export function readJustify(
|
||||
style: string,
|
||||
): Exclude<FlexJustify, "start"> | null {
|
||||
const v = readMarker(style, MARKER.justify)
|
||||
return v === "center" ||
|
||||
v === "end" ||
|
||||
v === "between" ||
|
||||
v === "around" ||
|
||||
v === "evenly"
|
||||
? v
|
||||
: null
|
||||
}
|
||||
|
||||
/** Read the declared width cap back, or null when there was none. */
|
||||
export function readMaxW(style: string): number | null {
|
||||
const v = Number(readMarker(style, MARKER.maxw))
|
||||
return Number.isFinite(v) && v > 0 ? v : null
|
||||
}
|
||||
|
||||
/** Did this node opt out of the content-width floor? */
|
||||
export function readMinW0(style: string): boolean {
|
||||
return readMarker(style, MARKER.minw0) === "1"
|
||||
}
|
||||
|
||||
/** The page's declared aspect ratio, stamped on the default layer. */
|
||||
export function stampAspect(layerXml: string, aspect: number): string {
|
||||
return layerXml.replace(
|
||||
/<mxCell id="1" parent="0"\/>/,
|
||||
`<mxCell id="1" parent="0" style="${MARKER.aspect}=${aspect};"/>`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the page's declared aspect back out of a model body.
|
||||
*
|
||||
* Scans for the marker anywhere in the page rather than parsing the layer cell: the
|
||||
* marker name is namespaced, so a match cannot be anything else, and this keeps working
|
||||
* if draw.io ever reorders or reformats that cell.
|
||||
*/
|
||||
export function readAspect(page: string): number | undefined {
|
||||
const m = new RegExp(`${MARKER.aspect}=([\\d.]+)`).exec(page)
|
||||
if (!m) return undefined
|
||||
const v = Number(m[1])
|
||||
return Number.isFinite(v) && v > 0
|
||||
? Math.min(4, Math.max(0.25, v))
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
||||
export function stripMarkers(style: string): string {
|
||||
return style
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { z } from "zod"
|
||||
// A runtime import while graph.ts imports only TYPES from here — no cycle at runtime.
|
||||
import { graphToOperations } from "./graph"
|
||||
import { parseTw, type TwLayout } from "./tw"
|
||||
import {
|
||||
type ContainerNode,
|
||||
type DiagramNode,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
findParent,
|
||||
isContainer,
|
||||
type LinkSpec,
|
||||
type TextStyle,
|
||||
walkTree,
|
||||
} from "./types"
|
||||
|
||||
@@ -81,7 +83,7 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Fill colour, e.g. #DAE8FC. Prefer draw_graph's group field over picking colours",
|
||||
"Fill colour, e.g. #DAE8FC. Prefer the group field over picking colours",
|
||||
),
|
||||
stroke: z
|
||||
.string()
|
||||
@@ -105,6 +107,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
.describe(
|
||||
"Cross-axis position in the parent: start/end pin to an edge, stretch fills the axis (a divider or highlight bar spanning its card). Default center",
|
||||
),
|
||||
maxW: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Hard width cap in px. Long text rewraps to fit instead of stretching the box, so this is what keeps a paragraph from making the whole page a letterbox. Beats grow",
|
||||
),
|
||||
class: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Tailwind layout classes, e.g. "grow-2 self-stretch max-w-md". Supported: grow / grow-N / flex-N, w-1/3 (a share of the row), w-full, min-w-0, self-start|center|end|stretch, max-w-N or max-w-xs..4xl. NO colour classes — colour comes from role and group. Unknown classes are ignored and reported back',
|
||||
),
|
||||
lane: z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -173,6 +187,30 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
.describe(
|
||||
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center",
|
||||
),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around", "evenly"])
|
||||
.optional()
|
||||
.describe(
|
||||
"How children spread along dir when there is spare room. Default start packs them and leaves the gap at the far end — set between or evenly to spread a short column down its full height instead of leaving a hole at the bottom",
|
||||
),
|
||||
alignItems: z
|
||||
.enum(["start", "center", "end", "stretch"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Cross-axis default for every child, so cards in a column all span the same width without setting align on each. stretch is what makes a column of cards line up",
|
||||
),
|
||||
maxW: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Hard width cap in px. Children wrap or shrink to fit rather than run past it. Beats grow",
|
||||
),
|
||||
class: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Tailwind classes, e.g. "flex-col gap-4 p-4 grow-3 items-stretch justify-between max-w-2xl". LAYOUT: flex-row|flex-col, grow / grow-N / flex-N, w-1/3, w-full, min-w-0 (let a weight shrink this below its own text width — needed on every column when you want an exact ratio), items-* and self-* (start|center|end|stretch), justify-start|center|end|between|around|evenly, gap-N, p-N, max-w-N or max-w-xs..4xl. Spacing is Tailwind\'s 4px scale, so gap-4 is 16px. TEXT (applies to the frame title): font-bold / font-normal, italic, underline, text-xs..text-4xl, text-left|center|right, align-top|middle|bottom, whitespace-nowrap. BORDER: border / border-N, border-dashed / border-dotted / border-solid — a dashed frame reads as planned or logical rather than deployed. NOT accepted: any colour class — colour comes from role and group; the other seven font weights; opacity-*, truncate, rounded-*, shadow-*, outline-*, transforms. Unknown classes are dropped and reported back',
|
||||
),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
@@ -412,6 +450,21 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
op: z.literal("set_title"),
|
||||
title: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("clear"),
|
||||
keepTitle: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Keep the page title; default drops it too"),
|
||||
}),
|
||||
z.object({
|
||||
op: z.literal("set_page"),
|
||||
aspect: z
|
||||
.number()
|
||||
.describe(
|
||||
"Target width:height for the whole page. 1 = square, 1.4 = landscape slide, 0.75 = portrait poster, 1.6 = wide architecture diagram. Declare this FIRST on any multi-column diagram: it is what gives the columns a total width to divide, so grow weights and column proportions only take effect once it is set",
|
||||
),
|
||||
}),
|
||||
])
|
||||
|
||||
export type Operation = z.infer<typeof OperationSchema>
|
||||
@@ -420,6 +473,13 @@ export interface ApplyResult {
|
||||
tree: DiagramTree
|
||||
/** One entry per operation that could not be applied, in order. */
|
||||
errors: string[]
|
||||
/**
|
||||
* Things that were drawn, but not the way they were asked for — an arrow naming a node
|
||||
* that is not in the list, a loop that could not order the layers. Not errors: the
|
||||
* diagram is fine and re-sending it would produce the same result, so failing would
|
||||
* cost a turn and fix nothing.
|
||||
*/
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +574,50 @@ export function applyOperations(
|
||||
): ApplyResult {
|
||||
const tree: DiagramTree = structuredClone(input)
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
/**
|
||||
* Resolve an operation's Tailwind class string into layout fields.
|
||||
*
|
||||
* Explicit fields win over classes. Both are accepted because they are the same
|
||||
* vocabulary said two ways, and a caller mixing them — `class: "flex-col gap-4"` plus
|
||||
* `grow: 3` — means the explicit number, not a conflict to reject.
|
||||
*
|
||||
* Unknown classes are collected once per call rather than per operation: a poster
|
||||
* repeating `shadow-lg` on twelve cards should say so once.
|
||||
*/
|
||||
const ignoredClasses = new Set<string>()
|
||||
const twOf = (cls: string | undefined): TwLayout | null => {
|
||||
if (!cls?.trim()) return null
|
||||
const parsed = parseTw(cls)
|
||||
for (const c of parsed.ignored) ignoredClasses.add(c)
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentation overrides a class string asked for, or undefined when it asked for
|
||||
* none. Sparse on purpose: an absent field means "let the role decide", so a class
|
||||
* string that only sets alignment cannot silently reset the type size.
|
||||
*/
|
||||
const textOf = (tw: TwLayout | null): TextStyle | undefined => {
|
||||
if (!tw) return undefined
|
||||
const t: TextStyle = {
|
||||
...(tw.bold != null ? { bold: tw.bold } : {}),
|
||||
...(tw.italic != null ? { italic: tw.italic } : {}),
|
||||
...(tw.underline != null ? { underline: tw.underline } : {}),
|
||||
...(tw.strike != null ? { strike: tw.strike } : {}),
|
||||
...(tw.fontSize != null ? { size: tw.fontSize } : {}),
|
||||
...(tw.textAlign ? { align: tw.textAlign } : {}),
|
||||
...(tw.verticalAlign ? { valign: tw.verticalAlign } : {}),
|
||||
...(tw.nowrap != null ? { nowrap: tw.nowrap } : {}),
|
||||
...(tw.borderWidth != null ? { borderWidth: tw.borderWidth } : {}),
|
||||
...(tw.borderStyle ? { borderStyle: tw.borderStyle } : {}),
|
||||
...(tw.borderless != null ? { borderless: tw.borderless } : {}),
|
||||
...(tw.radius != null ? { radius: tw.radius } : {}),
|
||||
...(tw.shadow != null ? { shadow: tw.shadow } : {}),
|
||||
}
|
||||
return Object.keys(t).length > 0 ? t : undefined
|
||||
}
|
||||
|
||||
const exists = (id: string) => findNode(tree, id) !== null
|
||||
|
||||
@@ -528,6 +632,23 @@ export function applyOperations(
|
||||
expanded.push(op)
|
||||
continue
|
||||
}
|
||||
// Reject a graph that cannot be drawn, rather than emitting a broken one. Both
|
||||
// checks have to happen here: an empty node list would otherwise produce an empty
|
||||
// frame, and a duplicate id would surface as "add_box: id already taken", naming a
|
||||
// synthetic operation the model never wrote.
|
||||
if (op.nodes.length === 0) {
|
||||
errors.push(`add_graph "${op.id}": no nodes — nothing to draw.`)
|
||||
continue
|
||||
}
|
||||
const dupes = op.nodes
|
||||
.map((nd) => nd.id)
|
||||
.filter((id, i, all) => all.indexOf(id) !== i)
|
||||
if (dupes.length > 0) {
|
||||
errors.push(
|
||||
`add_graph "${op.id}": duplicate node id(s): ${[...new Set(dupes)].join(", ")}.`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const g = graphToOperations(op.nodes, op.edges, {
|
||||
flow: op.dir ?? "col",
|
||||
parent: op.parent,
|
||||
@@ -536,9 +657,17 @@ export function applyOperations(
|
||||
prefix: op.id,
|
||||
rootId: op.id,
|
||||
})
|
||||
// A stray endpoint is a warning, not an error: the rest of the graph is drawn
|
||||
// correctly, so rejecting it would cost a turn and produce the same diagram.
|
||||
if (g.unknownEndpoints.length)
|
||||
errors.push(
|
||||
`add_graph "${op.id}": edge endpoint(s) not in nodes: ${g.unknownEndpoints.join(", ")}`,
|
||||
warnings.push(
|
||||
`Dropped edge(s) naming nodes that were not in the node list: ${g.unknownEndpoints.join(", ")}.`,
|
||||
)
|
||||
if (g.backEdges.length)
|
||||
warnings.push(
|
||||
`Loop(s) drawn but not used for ordering: ${g.backEdges
|
||||
.map((b) => `${b.source}→${b.target}`)
|
||||
.join(", ")}.`,
|
||||
)
|
||||
if (op.label || op.after) {
|
||||
const root = g.operations[0]
|
||||
@@ -577,7 +706,13 @@ export function applyOperations(
|
||||
label: op.label ?? "",
|
||||
...cellOf(op),
|
||||
}
|
||||
else if (op.op === "add_box")
|
||||
else if (op.op === "add_box") {
|
||||
// Classes first, then the explicit fields on top: an explicit number is
|
||||
// the more specific statement of the two.
|
||||
const tw = twOf(op.class)
|
||||
const grow = op.grow ?? tw?.grow
|
||||
const align = op.align ?? tw?.align
|
||||
const maxW = op.maxW ?? tw?.maxW
|
||||
node = {
|
||||
kind: "box",
|
||||
id: op.id,
|
||||
@@ -589,30 +724,44 @@ export function applyOperations(
|
||||
...(op.stroke ? { stroke: op.stroke } : {}),
|
||||
...(op.role ? { role: op.role } : {}),
|
||||
...(op.group ? { group: op.group } : {}),
|
||||
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
|
||||
...(op.align && op.align !== "center"
|
||||
? { align: op.align }
|
||||
: {}),
|
||||
...(grow && grow > 0 ? { grow } : {}),
|
||||
...(align && align !== "center" ? { align } : {}),
|
||||
...(maxW && maxW > 0 ? { maxW } : {}),
|
||||
...(tw?.minW0 ? { minW0: true } : {}),
|
||||
...(textOf(tw) ? { text: textOf(tw) } : {}),
|
||||
...cellOf(op),
|
||||
}
|
||||
else if (op.op === "add_container")
|
||||
} else if (op.op === "add_container") {
|
||||
const tw = twOf(op.class)
|
||||
const grow = op.grow ?? tw?.grow
|
||||
const align = op.align ?? tw?.align
|
||||
const justify = op.justify ?? tw?.justify
|
||||
const alignItems = op.alignItems ?? tw?.alignItems
|
||||
const maxW = op.maxW ?? tw?.maxW
|
||||
const pad = op.pad ?? tw?.pad
|
||||
node = {
|
||||
kind: "group",
|
||||
id: op.id,
|
||||
gname: op.gname ?? null,
|
||||
label: op.label,
|
||||
// `dir` is required on the operation, so a class can only confirm
|
||||
// it. Reading the class first would let `flex-col` silently override
|
||||
// a declared `dir: "row"`.
|
||||
dir: op.dir,
|
||||
gap: op.gap ?? 20,
|
||||
gap: op.gap ?? tw?.gap ?? 20,
|
||||
children: [],
|
||||
...(op.role ? { role: op.role } : {}),
|
||||
...(op.group ? { group: op.group } : {}),
|
||||
...(op.grow && op.grow > 0 ? { grow: op.grow } : {}),
|
||||
...(op.align && op.align !== "center"
|
||||
? { align: op.align }
|
||||
: {}),
|
||||
...(op.pad != null ? { pad: Math.max(0, op.pad) } : {}),
|
||||
...(grow && grow > 0 ? { grow } : {}),
|
||||
...(align && align !== "center" ? { align } : {}),
|
||||
...(justify && justify !== "start" ? { justify } : {}),
|
||||
...(alignItems ? { alignItems } : {}),
|
||||
...(maxW && maxW > 0 ? { maxW } : {}),
|
||||
...(tw?.minW0 ? { minW0: true } : {}),
|
||||
...(textOf(tw) ? { text: textOf(tw) } : {}),
|
||||
...(pad != null ? { pad: Math.max(0, pad) } : {}),
|
||||
}
|
||||
else if (op.op === "add_grid")
|
||||
} else if (op.op === "add_grid")
|
||||
node = {
|
||||
kind: "grid",
|
||||
id: op.id,
|
||||
@@ -880,13 +1029,43 @@ export function applyOperations(
|
||||
break
|
||||
}
|
||||
|
||||
case "clear": {
|
||||
// Start over. Needed because some diagrams are rebuilt rather than
|
||||
// patched: in a flowchart one new arrow can change which row several
|
||||
// nodes belong in, so there is no meaningful way to merge a new graph
|
||||
// into the old layout.
|
||||
//
|
||||
// `foreign` goes too. Those are cells the parser could not place in the
|
||||
// tree, and keeping them would leave a user's stray annotations floating
|
||||
// over a diagram they no longer refer to.
|
||||
tree.roots = []
|
||||
tree.links = []
|
||||
tree.foreign = []
|
||||
if (!op.keepTitle) tree.title = undefined
|
||||
break
|
||||
}
|
||||
|
||||
case "set_title":
|
||||
tree.title = op.title
|
||||
break
|
||||
|
||||
case "set_page":
|
||||
// Clamped rather than rejected: an out-of-range ratio is a slip, and a
|
||||
// diagram 40 times wider than it is tall is never what was meant.
|
||||
tree.aspect = Math.min(4, Math.max(0.25, op.aspect))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { tree, errors }
|
||||
// Names the whole supported vocabulary, not just the group the dropped class looked like
|
||||
// it belonged to: a model that reached for `pt-8` needs to see that padding is `p-N` only,
|
||||
// and one that reached for `shadow-2xl` needs the four rungs that do exist.
|
||||
if (ignoredClasses.size > 0)
|
||||
warnings.push(
|
||||
`Ignored class(es) with no equivalent here: ${[...ignoredClasses].join(", ")}. Supported — layout: flex-row/flex-col, grow/grow-N/flex-N, w-1/N, w-full, min-w-0, items-*, self-*, justify-*, gap-N, p-N, max-w-N/max-w-xs..4xl. Text: font-bold/font-normal, italic, underline, line-through, text-xs..4xl, text-left/center/right, align-top/middle/bottom, whitespace-nowrap. Border: border/border-N, border-solid/dashed/dotted, border-none, rounded/rounded-sm..4xl/rounded-full, shadow-sm/md/lg/xl/shadow-none. Colour comes from role and group; per-side borders and padding (border-l, pt-4) are not available.`,
|
||||
)
|
||||
|
||||
return { tree, errors, warnings }
|
||||
}
|
||||
|
||||
/** Every icon/group name in a tree, for validating against the catalog. */
|
||||
|
||||
@@ -31,14 +31,19 @@ import {
|
||||
MARKER,
|
||||
type NodeKind,
|
||||
readAlign,
|
||||
readAlignItems,
|
||||
readAspect,
|
||||
readCell,
|
||||
readDir,
|
||||
readIntMarker,
|
||||
readJustify,
|
||||
readKind,
|
||||
readList,
|
||||
readMarker,
|
||||
readMaxW,
|
||||
readMinW0,
|
||||
} from "./markers"
|
||||
import { isRole, type Role } from "./theme"
|
||||
import { isRole, type Role, roleIsBorderless } from "./theme"
|
||||
import type {
|
||||
BoxNode,
|
||||
BoxShape,
|
||||
@@ -54,6 +59,7 @@ import type {
|
||||
RadialNode,
|
||||
Rect,
|
||||
SequenceNode,
|
||||
TextStyle,
|
||||
} from "./types"
|
||||
|
||||
/** Hard cap on nesting depth, matching the reference project's 50-hop guard. */
|
||||
@@ -293,6 +299,112 @@ function styleValue(style: string, key: string): string | undefined {
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a node's presentation overrides back out of its style.
|
||||
*
|
||||
* No `dai_*` marker needed: these are draw.io's OWN style keys, so the values on the canvas
|
||||
* are the values that were asked for — and if the user changed one in the editor, reading it
|
||||
* back is exactly right.
|
||||
*
|
||||
* Only reports a field when the style says something a plain box would not, because every
|
||||
* cell carries `fontSize` and `verticalAlign` from the fallback style. Treating those as
|
||||
* declared overrides would freeze the theme's defaults onto every node, so a later role
|
||||
* change could no longer alter the type.
|
||||
*/
|
||||
function textStyleOf(
|
||||
style: string,
|
||||
defaults: {
|
||||
size?: number
|
||||
valign?: string
|
||||
align?: string
|
||||
/**
|
||||
* Whether this node's ROLE already draws it borderless, so `strokeColor=none` is the
|
||||
* theme talking rather than a declared override.
|
||||
*
|
||||
* Needed because two roles emit it themselves: a `banner` leaf is a dark filled slab
|
||||
* with no outline, and `heading`/`muted` are ghost text with neither fill nor stroke
|
||||
* (theme.ts:220, 230). Without this the parser would record every banner as having
|
||||
* explicitly asked for no border, and since `set_role` clears `style` but keeps
|
||||
* `text`, a later change to a bordered role would come back still borderless.
|
||||
*/
|
||||
borderless?: boolean
|
||||
},
|
||||
): TextStyle | undefined {
|
||||
const t: TextStyle = {}
|
||||
|
||||
// fontStyle is a bitmask: 1 bold, 2 italic, 4 underline, 8 strikethrough.
|
||||
const fs = styleValue(style, "fontStyle")
|
||||
if (fs !== undefined) {
|
||||
const bits = Number(fs)
|
||||
if (Number.isFinite(bits)) {
|
||||
if (bits & 1) t.bold = true
|
||||
if (bits & 2) t.italic = true
|
||||
if (bits & 4) t.underline = true
|
||||
if (bits & 8) t.strike = true
|
||||
}
|
||||
}
|
||||
|
||||
const size = Number(styleValue(style, "fontSize"))
|
||||
if (Number.isFinite(size) && size > 0 && size !== defaults.size)
|
||||
t.size = size
|
||||
|
||||
const align = styleValue(style, "align")
|
||||
if (
|
||||
(align === "left" || align === "center" || align === "right") &&
|
||||
align !== defaults.align
|
||||
)
|
||||
t.align = align
|
||||
|
||||
const valign = styleValue(style, "verticalAlign")
|
||||
if (
|
||||
(valign === "top" || valign === "middle" || valign === "bottom") &&
|
||||
valign !== defaults.valign
|
||||
)
|
||||
t.valign = valign
|
||||
|
||||
if (styleValue(style, "whiteSpace") === "nowrap") t.nowrap = true
|
||||
|
||||
const sw = Number(styleValue(style, "strokeWidth"))
|
||||
if (Number.isFinite(sw) && sw > 1) t.borderWidth = sw
|
||||
|
||||
if (styleValue(style, "dashed") === "1")
|
||||
t.borderStyle = styleValue(style, "dashPattern") ? "dotted" : "dashed"
|
||||
|
||||
if (
|
||||
styleValue(style, "strokeColor") === "none" &&
|
||||
defaults.borderless !== true
|
||||
)
|
||||
t.borderless = true
|
||||
|
||||
// A radius is only recoverable when `absoluteArcSize=1` says the number is pixels. A bare
|
||||
// `arcSize` is a PERCENTAGE of the box, which is what the shape catalog's own `round` and
|
||||
// `terminator` emit — reading those back as a pixel radius would silently convert a
|
||||
// shape's proportional corner into a fixed one on the first round-trip.
|
||||
//
|
||||
// `rounded=0` is NOT read back as a declared `radius: 0`. Nearly every box carries it from
|
||||
// the fallback style, so recording it would freeze square corners onto the node and stop a
|
||||
// later role or shape change from rounding them — the same trap `size` and `align` avoid
|
||||
// by comparing against defaults.
|
||||
if (styleValue(style, "absoluteArcSize") === "1") {
|
||||
const arc = Number(styleValue(style, "arcSize"))
|
||||
if (Number.isFinite(arc) && arc > 0) t.radius = arc / 2
|
||||
}
|
||||
|
||||
// Which rung a shadow came from, recovered from its blur — the one parameter that differs
|
||||
// across all four steps (3/6/15/25). Reading the rung rather than the raw numbers is what
|
||||
// keeps the round-trip a fixed point: re-emitting rung 2 gives back the same five keys.
|
||||
if (styleValue(style, "shadow") === "1") {
|
||||
const blur = Number(styleValue(style, "shadowBlur"))
|
||||
const rung = SHADOW_RUNGS[blur]
|
||||
if (rung !== undefined) t.shadow = rung
|
||||
} else if (styleValue(style, "shadow") === "0") t.shadow = 0
|
||||
|
||||
return Object.keys(t).length > 0 ? t : undefined
|
||||
}
|
||||
|
||||
/** Blur radius back to the Tailwind rung that produced it. Mirrors render.ts's SHADOW_KEYS. */
|
||||
const SHADOW_RUNGS: Record<number, number> = { 3: 1, 6: 2, 15: 3, 25: 4 }
|
||||
|
||||
/** Does this style declare a draw.io container? */
|
||||
function declaresContainer(style: string): boolean {
|
||||
return styleValue(style, "container") === "1"
|
||||
@@ -372,12 +484,30 @@ function shapeOf(style: string): string | undefined {
|
||||
function flexOf(style: string): {
|
||||
grow?: number
|
||||
align?: "start" | "end" | "stretch"
|
||||
maxW?: number
|
||||
minW0?: boolean
|
||||
} {
|
||||
const grow = readIntMarker(style, MARKER.grow)
|
||||
const align = readAlign(style)
|
||||
const maxW = readMaxW(style)
|
||||
return {
|
||||
...(grow !== null && grow > 0 ? { grow } : {}),
|
||||
...(align ? { align } : {}),
|
||||
...(maxW !== null ? { maxW } : {}),
|
||||
...(readMinW0(style) ? { minW0: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The container-only flex fields: how children spread, and their cross-axis default. */
|
||||
function containerFlexOf(style: string): {
|
||||
justify?: "center" | "end" | "between" | "around" | "evenly"
|
||||
alignItems?: "start" | "center" | "end" | "stretch"
|
||||
} {
|
||||
const justify = readJustify(style)
|
||||
const alignItems = readAlignItems(style)
|
||||
return {
|
||||
...(justify ? { justify } : {}),
|
||||
...(alignItems ? { alignItems } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,6 +1233,19 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
: undefined,
|
||||
fill: styleValue(c.style, "fillColor"),
|
||||
stroke: styleValue(c.style, "strokeColor"),
|
||||
// Read back against the fallback box's own values, so only a real override
|
||||
// is reported — every cell carries fontSize and verticalAlign from that
|
||||
// fallback, and treating those as declared would freeze the theme onto the
|
||||
// node and stop a later role change from altering the type.
|
||||
...(() => {
|
||||
const t = textStyleOf(c.style, {
|
||||
size: 11,
|
||||
valign: "middle",
|
||||
align: "center",
|
||||
borderless: roleIsBorderless(roleOf(c.style), "leaf"),
|
||||
})
|
||||
return t ? { text: t } : {}
|
||||
})(),
|
||||
// The declared token wins: appearance-based reverse mapping cannot
|
||||
// distinguish aliases (decision vs diamond) or identify a passed-through
|
||||
// token whose style is just `shape=<name>`.
|
||||
@@ -1263,6 +1406,19 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
role: roleOf(c.style),
|
||||
group: zoneOf(c.style),
|
||||
...flexOf(c.style),
|
||||
...containerFlexOf(c.style),
|
||||
// A frame's own defaults differ from a box's: 12px bold title, flush left and
|
||||
// top. Same reasoning as above — compare against those, not against a box's.
|
||||
// No `borderless` default: a themed panel always draws its border, whatever its
|
||||
// role, so `strokeColor=none` on a container is always someone's own request.
|
||||
...(() => {
|
||||
const t = textStyleOf(c.style, {
|
||||
size: 12,
|
||||
valign: "top",
|
||||
align: "left",
|
||||
})
|
||||
return t ? { text: t } : {}
|
||||
})(),
|
||||
...(markedPad !== null ? { pad: markedPad } : {}),
|
||||
}
|
||||
return node
|
||||
@@ -1344,8 +1500,21 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
`Children of ${ambiguousContainers.join(", ")} are arranged in two dimensions, which no single direction describes; a re-layout will move them.`,
|
||||
)
|
||||
|
||||
// The page proportions come back from a marker, NOT from pageWidth/pageHeight. Those
|
||||
// record the size the last layout happened to produce, which is only the requested
|
||||
// ratio when one was requested at all: reading them back turns an accidental 340x306
|
||||
// page into a standing instruction to keep that shape, and the next re-layout inflates
|
||||
// the diagram to obey it.
|
||||
const aspect = readAspect(page)
|
||||
|
||||
return {
|
||||
tree: { roots, links, title, foreign },
|
||||
tree: {
|
||||
roots,
|
||||
links,
|
||||
title,
|
||||
foreign,
|
||||
...(aspect != null ? { aspect } : {}),
|
||||
},
|
||||
needsAdoption: !marked,
|
||||
warnings,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
sequenceMetrics,
|
||||
} from "./layout"
|
||||
import {
|
||||
appendOnce,
|
||||
isInvisible,
|
||||
MARKER,
|
||||
stampAuto,
|
||||
stampCell,
|
||||
stampContainer,
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
type PoolNode,
|
||||
type Rect,
|
||||
type SequenceNode,
|
||||
type TextStyle,
|
||||
} from "./types"
|
||||
import type { Point } from "./visgraph"
|
||||
|
||||
@@ -81,6 +84,77 @@ function isParagraph(label: string): boolean {
|
||||
return /\n/.test(label) || /<br/i.test(label) || plain.length > 60
|
||||
}
|
||||
|
||||
/**
|
||||
* The five draw.io shadow parameters per Tailwind rung, keyed by TextStyle.shadow.
|
||||
*
|
||||
* Values are the primary layer of Tailwind's own CSS: `shadow-md` is
|
||||
* `0 4px 6px rgb(0 0 0/0.1)`, so 4px down, 6px of blur, 10% opaque. Tailwind stacks a second
|
||||
* tighter layer on each step; draw.io renders one `drop-shadow()`, so the main layer is what
|
||||
* survives. `shadowColor` is left off deliberately — draw.io's default grey is correct, and
|
||||
* colour belongs to `role` and `group`.
|
||||
*/
|
||||
const SHADOW_KEYS: Record<number, string> = {
|
||||
1: "shadow=1;shadowOffsetX=0;shadowOffsetY=1;shadowBlur=3;shadowOpacity=10;",
|
||||
2: "shadow=1;shadowOffsetX=0;shadowOffsetY=4;shadowBlur=6;shadowOpacity=10;",
|
||||
3: "shadow=1;shadowOffsetX=0;shadowOffsetY=10;shadowBlur=15;shadowOpacity=10;",
|
||||
4: "shadow=1;shadowOffsetX=0;shadowOffsetY=20;shadowBlur=25;shadowOpacity=10;",
|
||||
}
|
||||
|
||||
/**
|
||||
* A node's presentation overrides, as draw.io style keys.
|
||||
*
|
||||
* Bold, italic, underline and strikethrough are ONE key: draw.io packs them into `fontStyle`
|
||||
* as a bitmask (1 bold, 2 italic, 4 underline, 8 strikethrough) which you add together.
|
||||
* Writing four separate keys, or writing `fontStyle=1` twice, would leave only the last one
|
||||
* in effect — so the bits are combined here and emitted once.
|
||||
*
|
||||
* `dashPattern` accompanies dotted: `dashed=1` alone gives draw.io's default dash, and the
|
||||
* short-on-long-off pattern is what makes it read as a dotted line rather than a dashed one.
|
||||
*
|
||||
* A radius needs three keys together. `rounded=1` turns corners on at all, `absoluteArcSize=1`
|
||||
* makes the number pixels instead of a percentage of the box, and `arcSize` is DOUBLE the
|
||||
* radius because draw.io halves it on the way in (mxShape.js:1172-1189). All three or none:
|
||||
* `arcSize` alone would be read as a percentage and give a different radius on every box.
|
||||
*/
|
||||
function textStyleKeys(t: TextStyle | undefined): string | undefined {
|
||||
if (!t) return undefined
|
||||
const parts: string[] = []
|
||||
const bits =
|
||||
(t.bold ? 1 : 0) +
|
||||
(t.italic ? 2 : 0) +
|
||||
(t.underline ? 4 : 0) +
|
||||
(t.strike ? 8 : 0)
|
||||
// Only when something asked. `fontStyle=0` would override a role's own bold.
|
||||
if (
|
||||
t.bold != null ||
|
||||
t.italic != null ||
|
||||
t.underline != null ||
|
||||
t.strike != null
|
||||
)
|
||||
parts.push(`fontStyle=${bits};`)
|
||||
if (t.size != null && t.size > 0) parts.push(`fontSize=${t.size};`)
|
||||
if (t.align) parts.push(`align=${t.align};`)
|
||||
if (t.valign) parts.push(`verticalAlign=${t.valign};`)
|
||||
if (t.nowrap != null)
|
||||
parts.push(`whiteSpace=${t.nowrap ? "nowrap" : "wrap"};`)
|
||||
if (t.borderWidth != null && t.borderWidth > 0)
|
||||
parts.push(`strokeWidth=${t.borderWidth};`)
|
||||
if (t.borderStyle === "dashed") parts.push("dashed=1;")
|
||||
else if (t.borderStyle === "dotted") parts.push("dashed=1;dashPattern=1 3;")
|
||||
else if (t.borderStyle === "solid") parts.push("dashed=0;")
|
||||
// `strokeColor=none` is how draw.io says "no border" (mxShape.js:1398 accepts it), and
|
||||
// it is the only way to draw a plain colour field with no outline at all.
|
||||
if (t.borderless) parts.push("strokeColor=none;")
|
||||
if (t.radius != null) {
|
||||
if (t.radius > 0)
|
||||
parts.push(`rounded=1;absoluteArcSize=1;arcSize=${t.radius * 2};`)
|
||||
else parts.push("rounded=0;")
|
||||
}
|
||||
if (t.shadow != null)
|
||||
parts.push(t.shadow > 0 ? (SHADOW_KEYS[t.shadow] ?? "") : "shadow=0;")
|
||||
return parts.length ? parts.join("") : undefined
|
||||
}
|
||||
|
||||
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
|
||||
export type StyleResolver = (
|
||||
name: string,
|
||||
@@ -191,13 +265,22 @@ function styleFor(
|
||||
n.fill ? `fillColor=${n.fill};` : undefined,
|
||||
n.stroke ? `strokeColor=${n.stroke};` : undefined,
|
||||
n.bold ? "fontStyle=1;" : undefined,
|
||||
// Last, so an explicit override beats both the role's type and the
|
||||
// paragraph heuristic above — asking for centred text has to win over
|
||||
// "this looks like a paragraph, so set it flush left".
|
||||
textStyleKeys(n.text),
|
||||
)
|
||||
}
|
||||
let stamped = stampLeaf(base, "box")
|
||||
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
|
||||
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
|
||||
if (n.group) stamped = stampGroup(stamped, n.group)
|
||||
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
|
||||
stamped = stampFlex(stamped, {
|
||||
grow: n.grow,
|
||||
align: n.align,
|
||||
maxW: n.maxW,
|
||||
minW0: n.minW0,
|
||||
})
|
||||
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
|
||||
// time instead of freezing this layout's numbers as a fixed size.
|
||||
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
|
||||
@@ -240,10 +323,22 @@ function styleFor(
|
||||
if (n.fill) base += `fillColor=${n.fill};`
|
||||
if (n.stroke) base += `strokeColor=${n.stroke};`
|
||||
}
|
||||
if (n.kind === "group") {
|
||||
const overrides = textStyleKeys(n.text)
|
||||
if (overrides) base = mergeStyle(base, overrides)
|
||||
}
|
||||
if (groupRole && groupRole !== "body") base = stampRole(base, groupRole)
|
||||
if (zone) base = stampGroup(base, zone)
|
||||
if (n.kind === "group")
|
||||
base = stampFlex(base, { grow: n.grow, align: n.align, pad: n.pad })
|
||||
base = stampFlex(base, {
|
||||
grow: n.grow,
|
||||
align: n.align,
|
||||
justify: n.justify,
|
||||
alignItems: n.alignItems,
|
||||
maxW: n.maxW,
|
||||
minW0: n.minW0,
|
||||
pad: n.pad,
|
||||
})
|
||||
return stampContainer(base, {
|
||||
kind: n.kind,
|
||||
dir: n.kind === "grid" ? "grid" : n.dir,
|
||||
@@ -253,8 +348,12 @@ function styleFor(
|
||||
})
|
||||
}
|
||||
|
||||
// `connectable=0` because an invisible wrapper is scaffolding, not a thing to draw arrows
|
||||
// from. Without it draw.io treats the empty frame as a normal shape: hovering anywhere over
|
||||
// the group pops up its connection crosses and direction arrows, which land on top of the
|
||||
// content inside it and read as stray marks in the middle of the diagram.
|
||||
const INVISIBLE_FRAME_STYLE =
|
||||
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;"
|
||||
"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=none;connectable=0;"
|
||||
|
||||
/**
|
||||
* One `<mxCell>` for a vertex, with geometry relative to its parent.
|
||||
@@ -624,10 +723,18 @@ function edgeXml(
|
||||
style += `startArrow=${l.tail};startFill=${l.tailFill ? 1 : 0};`
|
||||
if (label) style += "labelBackgroundColor=light-dark(#FFFFFF,#0B0F14);"
|
||||
}
|
||||
// SET rather than append. Unlike the branch above, this runs for a recovered style too —
|
||||
// the router recomputes the ports on every layout, so they cannot be left at whatever the
|
||||
// canvas held. But that recovered style ALREADY carries the previous pass's eight port
|
||||
// keys, and appending grew the style by 76 characters per round-trip without bound.
|
||||
// draw.io resolves duplicates last-wins so the arrow always looked right, which is why
|
||||
// this survived until a byte-identity check caught it.
|
||||
if (route)
|
||||
style +=
|
||||
style = appendOnce(
|
||||
style,
|
||||
`exitX=${route.exit.x};exitY=${route.exit.y};exitDx=0;exitDy=0;` +
|
||||
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;`
|
||||
`entryX=${route.entry.x};entryY=${route.entry.y};entryDx=0;entryDy=0;`,
|
||||
)
|
||||
const id = l.id ?? `ed${index + 1}`
|
||||
const points =
|
||||
route?.freeze && route.waypoints.length
|
||||
@@ -730,6 +837,7 @@ export function renderDiagram(
|
||||
iconSize: opts.iconSize,
|
||||
gap: opts.rootGap,
|
||||
links: tree.links,
|
||||
aspect: tree.aspect,
|
||||
})
|
||||
|
||||
const flat = flatten(roots)
|
||||
@@ -904,7 +1012,7 @@ export function renderDiagram(
|
||||
//
|
||||
// An INVISIBLE container is excluded, because both of those judgements are about what a
|
||||
// reader sees, and there is no border on screen to run alongside or to trespass across.
|
||||
// A layer band in a flowchart is exactly that: `draw_graph` wraps each row of the graph
|
||||
// A layer band in a flowchart is exactly that: `add_graph` wraps each row of the graph
|
||||
// in an unlabelled, unstroked container purely to stack them. Counting those as frames
|
||||
// measurably ruined the arrows — a back edge such as "return for correction" → "submit"
|
||||
// leaves its own band, so every clean route was rejected for trespassing on a frame that
|
||||
@@ -971,11 +1079,16 @@ export function renderDiagram(
|
||||
for (const m of messages)
|
||||
cells.push(messageXml(m.link, m.index, m.y, cellById))
|
||||
|
||||
// The declared page ratio rides on the default layer's cell — the one cell every
|
||||
// diagram has, and page-level state has no node to live on.
|
||||
const layer = tree.aspect
|
||||
? `<mxCell id="1" parent="0" style="${MARKER.aspect}=${tree.aspect};"/>`
|
||||
: `<mxCell id="1" parent="0"/>`
|
||||
const model =
|
||||
`<mxGraphModel dx="1400" dy="900" grid="0" gridSize="10" guides="1" tooltips="1"` +
|
||||
` connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="${page.w}"` +
|
||||
` pageHeight="${page.h}" math="0" shadow="0"><root><mxCell id="0"/>` +
|
||||
`<mxCell id="1" parent="0"/>${cells.join("")}</root></mxGraphModel>`
|
||||
`${layer}${cells.join("")}</root></mxGraphModel>`
|
||||
|
||||
return {
|
||||
xml: `<mxfile host="app.diagrams.net"><diagram name="Page-1" id="page-1">${model}</diagram></mxfile>`,
|
||||
|
||||
@@ -182,6 +182,26 @@ const ROLE_SPECS: Record<Role, RoleSpec> = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this role already draw itself with no border?
|
||||
*
|
||||
* The parser needs this to tell a THEME's `strokeColor=none` from a DECLARED one. A banner is
|
||||
* a dark filled slab and a heading is ghost text; both are borderless because of what they
|
||||
* are, not because anyone asked. Recording that as an explicit override would make it
|
||||
* outlive a later role change, since `set_role` clears a node's style but keeps its text
|
||||
* overrides.
|
||||
*
|
||||
* Leaf only: the container branch of `themedStyle` always draws a border, whatever the role.
|
||||
*/
|
||||
export function roleIsBorderless(
|
||||
role: Role | undefined,
|
||||
kind: "leaf" | "container",
|
||||
): boolean {
|
||||
if (kind === "container") return false
|
||||
const e = ROLE_SPECS[role ?? "body"].emphasis
|
||||
return e === "filled" || e === "ghost"
|
||||
}
|
||||
|
||||
/** Metrics the measure pass needs, so layout reserves what render will draw. */
|
||||
export function roleMetrics(role: Role | undefined): {
|
||||
fontSize: number
|
||||
|
||||
538
lib/diagram-engine/tw.ts
Normal file
538
lib/diagram-engine/tw.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Tailwind utility classes → the engine's layout fields.
|
||||
*
|
||||
* WHY a second way to say the same thing. The engine's own vocabulary (`dir`, `grow`,
|
||||
* `align`, `justify`, `pad`, `gap`, `maxW`) is words we invented, so a model has seen them
|
||||
* only in our tool description. It has seen `flex-col grow-3 items-stretch p-4` millions of
|
||||
* times. Microsoft's DSL study (arXiv 2407.02742) found models hallucinate custom function
|
||||
* names at a much higher rate than familiar ones, and arXiv 2311.09519 measured a large
|
||||
* improvement from swapping a rare DSL for a popular language, precisely because it puts the
|
||||
* output back in the distribution the model was trained on.
|
||||
*
|
||||
* The other half of why Tailwind and not free-form CSS: its values are a FIXED SCALE, not
|
||||
* arbitrary numbers. `p-4` is 16px because one spacing unit is 4px, and there is no `p-7.5`.
|
||||
* Tailwind's own docs make that the point of the thing — with inline styles "every value is a
|
||||
* magic number", with utilities you pick from a system. That is the property we want, because
|
||||
* an unconstrained number field is exactly where a model invents 13px here and 27px there.
|
||||
*
|
||||
* The supported set was picked by reading Tailwind's property index against the draw.io
|
||||
* renderer's ACTUAL SOURCE — public/drawio/mxgraph/src and public/drawio/js/grapheditor,
|
||||
* vendored in this repo — rather than against a prose style reference. That matters: three
|
||||
* properties were excluded on wrong grounds when the reference was a document, and reading
|
||||
* the code put them back (radius, strikethrough, shadow, all noted below).
|
||||
*
|
||||
* WHAT IS DELIBERATELY NOT HERE, and why:
|
||||
*
|
||||
* - COLOUR of any kind (`bg-*`, `text-red-500`, `border-blue-400`). draw.io has
|
||||
* `fillColor`/`fontColor`/`strokeColor`, so this is possible — but colour is derived from
|
||||
* `role` and `group` precisely so one palette stays coherent, and a colour class would be
|
||||
* a back door into the hex-picking that was removed. Gradients (`bg-linear-to-b from-X
|
||||
* to-Y`) are excluded for the same reason, even though `gradientColor` with a four-way
|
||||
* `gradientDirection` maps onto them exactly (mxShape.js:1392-1393, 1054-1060).
|
||||
*
|
||||
* - Per-SIDE borders (`border-t`, `border-l-4`, `border-x`). draw.io draws these properly:
|
||||
* `shape=partialRectangle` reads independent `top`/`right`/`bottom`/`left` booleans
|
||||
* (Shapes.js:3914-3917) and still fills the background first (3919-3920), so a single
|
||||
* heavy left edge would render correctly. The cost is the SHAPE SLOT: `partialRectangle`
|
||||
* is itself a shape name, so a node could not be both a diamond and left-edge-only. What
|
||||
* a node IS — a database, a decision, a person — outranks how its border looks, so the
|
||||
* shape vocabulary keeps the slot.
|
||||
*
|
||||
* - Per-SIDE padding (`pt-8`, `px-4`). draw.io's `spacingTop`/`spacingRight`/
|
||||
* `spacingBottom`/`spacingLeft` (mxText.js:422-425) look like an exact match and are not:
|
||||
* they pad the LABEL inside its own cell, while this engine's `pad` is the room a
|
||||
* container leaves for its CHILDREN. Accepting `pt-8` would suggest it pushes child nodes
|
||||
* down, which it cannot.
|
||||
*
|
||||
* - `outline-*` (width, colour, style, offset). draw.io has no concept: a shape carries one
|
||||
* border, and nothing draws a second ring outside it. In CSS an outline is a focus ring,
|
||||
* which a static diagram does not have.
|
||||
*
|
||||
* - `opacity-*`. draw.io's `opacity` is 0–100 and would map cleanly, but Tailwind's
|
||||
* `opacity-<number>` takes ANY number — `opacity-37` is valid — so it is not a scale.
|
||||
* Admitting it would give up the one property that makes this vocabulary worth having.
|
||||
*
|
||||
* - `truncate` / `text-ellipsis`. Sets `text-overflow: ellipsis`. draw.io's `overflow`
|
||||
* branches on exactly five values — visible, hidden, fill, width, block (mxText.js:
|
||||
* 1080-1095) — and a repo-wide grep for "ellipsis" finds no implementation, so the text
|
||||
* would be cut with no "…": a class named `truncate` that silently loses characters.
|
||||
*
|
||||
* - Seven of the nine `font-*` weights. See UNSUPPORTED_WEIGHTS below.
|
||||
*
|
||||
* - `text-shadow-*`. Unlike the box `shadow-*` family, draw.io's `textShadow`
|
||||
* (mxText.js:668) is a bare on/off flag with no offset or blur, so Tailwind's six sizes
|
||||
* would collapse into one picture.
|
||||
*
|
||||
* - Per-CORNER radius (`rounded-tl-lg`) and the decorative corner treatments beside it
|
||||
* (snip, fold, inverse round). draw.io does have these, but only on a separate template
|
||||
* shape, `mxgraph.basic.rect` (Shapes.js:4118), which would take the place of the node's
|
||||
* own `shape` — the same trade the per-side borders lose. Whole-shape `rounded-*` IS
|
||||
* supported and costs no slot; see RADIUS.
|
||||
*
|
||||
* - `tracking-*` (letter-spacing), `uppercase`/`lowercase`/`capitalize` (text-transform),
|
||||
* and per-node `leading-*` (line-height). Not merely coarse — absent. Grepping the whole
|
||||
* vendored renderer for letterSpacing/textTransform finds nothing, and line height is a
|
||||
* global constant (`mxConstants.LINE_HEIGHT`) with no per-cell style key.
|
||||
*
|
||||
* - `rotate-*`, `scale-*`, `skew-*`, `translate-*`. draw.io has `rotation`/`flipH`/`flipV`,
|
||||
* but a rotated box breaks the two things this engine guarantees: the layout no longer
|
||||
* knows what area it covers, and the edge router cannot route around it.
|
||||
*
|
||||
* - Document-flow properties (`float`, `clear`, `position`, `top/right/bottom/left`,
|
||||
* `z-index`, `visibility`, `columns`, `break-*`, `object-*`, `overscroll-*`) and the
|
||||
* table and list families. There is no document flow here — every coordinate is computed
|
||||
* — and draw.io has no z-index at all: later cells simply paint on top.
|
||||
*
|
||||
* - `filter`/`backdrop-filter`, `mask-*`, `mix-blend-mode`, `transition-*`, `animation`,
|
||||
* `perspective*`, `cursor`, `resize`, `appearance`, `caret-color`, `accent-color`:
|
||||
* no corresponding key anywhere in the vendored renderer.
|
||||
*
|
||||
* - Arbitrary values (`w-[137px]`, `p-[13px]`). The scale is the feature; a bracket escape
|
||||
* hatch removes it.
|
||||
*
|
||||
* Unknown classes are returned in `ignored` rather than rejected — D2's "warnings over
|
||||
* errors" rule: a diagram that renders with one class dropped beats an error that renders
|
||||
* nothing. The caller reports them, which is how a typo becomes a one-turn fix instead of a
|
||||
* silent no-op.
|
||||
*/
|
||||
|
||||
import type { Align, Justify } from "./types"
|
||||
|
||||
/** What a class string resolves to. Every field optional: a class string sets only what it names. */
|
||||
export interface TwLayout {
|
||||
dir?: "row" | "col"
|
||||
grow?: number
|
||||
align?: Align
|
||||
justify?: Justify
|
||||
alignItems?: Align
|
||||
gap?: number
|
||||
pad?: number
|
||||
maxW?: number
|
||||
/** `min-w-0`: let a weight shrink this below its content width. */
|
||||
minW0?: boolean
|
||||
|
||||
// ---- text, the part draw.io can actually render ----
|
||||
/** `font-bold` / `font-normal`. draw.io has one bold bit, not nine weights. */
|
||||
bold?: boolean
|
||||
/** `italic` / `not-italic`. */
|
||||
italic?: boolean
|
||||
/** `underline` / `no-underline`. */
|
||||
underline?: boolean
|
||||
/** `line-through`. draw.io's fontStyle carries a strikethrough bit beside the other three. */
|
||||
strike?: boolean
|
||||
/** `text-xs`…`text-4xl` → px, from Tailwind's own scale. */
|
||||
fontSize?: number
|
||||
/** `text-left` / `text-center` / `text-right`. */
|
||||
textAlign?: "left" | "center" | "right"
|
||||
/** `align-top` / `align-middle` / `align-bottom`. */
|
||||
verticalAlign?: "top" | "middle" | "bottom"
|
||||
/** `whitespace-nowrap` / `whitespace-normal`. */
|
||||
nowrap?: boolean
|
||||
|
||||
// ---- border ----
|
||||
/** `border` / `border-N` → strokeWidth in px. */
|
||||
borderWidth?: number
|
||||
/** `border-dashed` / `border-dotted` / `border-solid`. */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/** `rounded`, `rounded-lg`, `rounded-full` → corner radius in px. */
|
||||
radius?: number
|
||||
/** `border-none` / `border-0`. */
|
||||
borderless?: boolean
|
||||
/** `shadow-sm`…`shadow-xl` → 1–4; `shadow-none` → 0. See SHADOW. */
|
||||
shadow?: number
|
||||
|
||||
/** Classes that matched nothing, verbatim and in order. */
|
||||
ignored: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's spacing scale: one unit is 0.25rem, which is 4px at the default root size.
|
||||
*
|
||||
* Only whole steps are accepted. Tailwind itself has half-steps (`p-0.5`), but a diagram has
|
||||
* no use for 2px padding and allowing them widens the scale for nothing.
|
||||
*/
|
||||
const SPACING_UNIT = 4
|
||||
|
||||
/** `p-6` / `gap-3` → px, or null when the suffix is not a plain scale step. */
|
||||
function scaleToPx(suffix: string): number | null {
|
||||
if (!/^\d+$/.test(suffix)) return null
|
||||
return Number(suffix) * SPACING_UNIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's width fractions, as a share of the parent.
|
||||
*
|
||||
* Expressed as `grow` rather than an absolute width, because that is what the fraction means
|
||||
* inside a flex row: `w-1/3` beside `w-2/3` is the same layout as `grow-1` beside `grow-2`,
|
||||
* and going through grow means the existing proportional path applies — including the rule
|
||||
* that a declared cap outranks it.
|
||||
*/
|
||||
function fractionToGrow(suffix: string): number | null {
|
||||
const m = /^(\d+)\/(\d+)$/.exec(suffix)
|
||||
if (!m) return null
|
||||
const num = Number(m[1])
|
||||
const den = Number(m[2])
|
||||
if (den === 0 || num === 0 || num > den) return null
|
||||
return num
|
||||
}
|
||||
|
||||
const ALIGN_ITEMS: Record<string, Align> = {
|
||||
"items-start": "start",
|
||||
"items-center": "center",
|
||||
"items-end": "end",
|
||||
"items-stretch": "stretch",
|
||||
}
|
||||
|
||||
const ALIGN_SELF: Record<string, Align> = {
|
||||
"self-start": "start",
|
||||
"self-center": "center",
|
||||
"self-end": "end",
|
||||
"self-stretch": "stretch",
|
||||
}
|
||||
|
||||
const JUSTIFY: Record<string, Justify> = {
|
||||
"justify-start": "start",
|
||||
"justify-center": "center",
|
||||
"justify-end": "end",
|
||||
"justify-between": "between",
|
||||
"justify-around": "around",
|
||||
"justify-evenly": "evenly",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's type scale in px, its own documented values.
|
||||
*
|
||||
* Stops at 4xl. The ladder goes on to 9xl (128px), but a 128px word is not a diagram
|
||||
* label, and offering the step invites a model to pick it.
|
||||
*/
|
||||
const FONT_SIZE: Record<string, number> = {
|
||||
"text-xs": 12,
|
||||
"text-sm": 14,
|
||||
"text-base": 16,
|
||||
"text-lg": 18,
|
||||
"text-xl": 20,
|
||||
"text-2xl": 24,
|
||||
"text-3xl": 30,
|
||||
"text-4xl": 36,
|
||||
}
|
||||
|
||||
/**
|
||||
* `text-left|center|right` — horizontal text alignment inside the shape.
|
||||
*
|
||||
* `text-justify`, `text-start` and `text-end` are absent because draw.io's `align` has
|
||||
* only the three physical values; justified text is not available at all.
|
||||
*/
|
||||
const TEXT_ALIGN: Record<string, "left" | "center" | "right"> = {
|
||||
"text-left": "left",
|
||||
"text-center": "center",
|
||||
"text-right": "right",
|
||||
}
|
||||
|
||||
/** `align-*` → draw.io's verticalAlign. */
|
||||
const VERTICAL_ALIGN: Record<string, "top" | "middle" | "bottom"> = {
|
||||
"align-top": "top",
|
||||
"align-middle": "middle",
|
||||
"align-bottom": "bottom",
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's border-radius scale in px, its own documented values.
|
||||
*
|
||||
* These are REAL pixels, which is only true because of `absoluteArcSize`: draw.io's `arcSize`
|
||||
* is a percentage of the shape by default, but that flag switches it to absolute units
|
||||
* (mxShape.js:1172-1189). Without it a radius class would mean something different on every
|
||||
* box, which is why this looked unimplementable at first glance.
|
||||
*
|
||||
* `rounded-full` is `calc(infinity * 1px)` in Tailwind v4 — "as round as it goes". The same
|
||||
* function clamps the radius to half the shorter side, so any number past half the box's
|
||||
* height gives a stadium. 200 is chosen rather than something enormous because the number
|
||||
* reaches the user: draw.io's Arrange panel shows `arcSize` in an editable field, and a
|
||||
* diagram box taller than 400px does not exist, so 200 is both always enough and readable.
|
||||
*/
|
||||
const RADIUS: Record<string, number> = {
|
||||
"rounded-none": 0,
|
||||
"rounded-xs": 2,
|
||||
"rounded-sm": 4,
|
||||
rounded: 4,
|
||||
"rounded-md": 6,
|
||||
"rounded-lg": 8,
|
||||
"rounded-xl": 12,
|
||||
"rounded-2xl": 16,
|
||||
"rounded-3xl": 24,
|
||||
"rounded-4xl": 32,
|
||||
"rounded-full": 200,
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's box-shadow steps, as a rung number the renderer turns into draw.io's five
|
||||
* shadow parameters. 0 means "explicitly no shadow".
|
||||
*
|
||||
* draw.io's shadow is not the on/off flag it looks like: `shadowOffsetX`, `shadowOffsetY`,
|
||||
* `shadowBlur`, `shadowColor` and `shadowOpacity` are read independently
|
||||
* (mxShape.js:505-535) and become a CSS `drop-shadow(dx dy blur colour)` (540-552). Since
|
||||
* Tailwind's own steps are also just offset-and-blur, they map one for one.
|
||||
*
|
||||
* Four rungs, not Tailwind's eight. `shadow-2xs` and `shadow-xs` are indistinguishable from
|
||||
* `shadow-sm` at a diagram's scale, and `shadow-2xl`'s 50px blur is noise on a page of
|
||||
* boxes — offering a step invites a model to pick it.
|
||||
*/
|
||||
const SHADOW: Record<string, number> = {
|
||||
"shadow-none": 0,
|
||||
"shadow-sm": 1,
|
||||
"shadow-md": 2,
|
||||
"shadow-lg": 3,
|
||||
"shadow-xl": 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Font-weight classes that are NOT accepted, and why.
|
||||
*
|
||||
* Tailwind has nine weights; draw.io's `fontStyle` is a bitmask whose bold flag is a single
|
||||
* bit. Accepting all nine would collapse five of them onto "bold" and four onto "normal",
|
||||
* which is the same defect that rules out `shadow-*` (six sizes, one on/off flag). So only
|
||||
* `font-bold` and `font-normal` are honoured and the rest are reported, rather than
|
||||
* pretending a distinction the renderer cannot draw.
|
||||
*/
|
||||
const UNSUPPORTED_WEIGHTS = new Set([
|
||||
"font-thin",
|
||||
"font-extralight",
|
||||
"font-light",
|
||||
"font-medium",
|
||||
"font-semibold",
|
||||
"font-extrabold",
|
||||
"font-black",
|
||||
])
|
||||
|
||||
/**
|
||||
* Parse a Tailwind class string into layout fields.
|
||||
*
|
||||
* Later classes win over earlier ones, the same as Tailwind's own last-one-wins behaviour
|
||||
* for conflicting utilities, so a caller can append an override without removing anything.
|
||||
*/
|
||||
export function parseTw(classes: string): TwLayout {
|
||||
const out: TwLayout = { ignored: [] }
|
||||
for (const raw of String(classes ?? "").split(/\s+/)) {
|
||||
const cls = raw.trim()
|
||||
if (!cls) continue
|
||||
|
||||
// Direction. `flex` on its own is the default and says nothing here — every engine
|
||||
// container is already a flex container — so it is accepted and ignored rather than
|
||||
// reported, since a model writing `flex flex-col` is not making a mistake.
|
||||
if (cls === "flex" || cls === "flex-row") {
|
||||
if (cls === "flex-row") out.dir = "row"
|
||||
continue
|
||||
}
|
||||
if (cls === "flex-col") {
|
||||
out.dir = "col"
|
||||
continue
|
||||
}
|
||||
|
||||
if (cls in ALIGN_ITEMS) {
|
||||
out.alignItems = ALIGN_ITEMS[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in ALIGN_SELF) {
|
||||
out.align = ALIGN_SELF[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in JUSTIFY) {
|
||||
out.justify = JUSTIFY[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// `grow` alone is flex-grow: 1, `grow-N` is the weight. Tailwind writes the latter
|
||||
// as `grow-[3]`; the plain form is accepted because it is what a model reaches for
|
||||
// and the bracket form carries no extra meaning here.
|
||||
if (cls === "grow") {
|
||||
out.grow = 1
|
||||
continue
|
||||
}
|
||||
const growN = /^grow-(\d+)$/.exec(cls)
|
||||
if (growN) {
|
||||
out.grow = Number(growN[1])
|
||||
continue
|
||||
}
|
||||
// `flex-1` / `flex-3`: the shorthand whose whole point is proportional sizing.
|
||||
const flexN = /^flex-(\d+)$/.exec(cls)
|
||||
if (flexN) {
|
||||
out.grow = Number(flexN[1])
|
||||
continue
|
||||
}
|
||||
|
||||
// Fractional widths become weights — see fractionToGrow.
|
||||
const wFrac = /^w-(\d+\/\d+)$/.exec(cls)
|
||||
if (wFrac) {
|
||||
const g = fractionToGrow(wFrac[1])
|
||||
if (g !== null) {
|
||||
out.grow = g
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (cls === "w-full") {
|
||||
out.align = "stretch"
|
||||
continue
|
||||
}
|
||||
|
||||
// `min-w-0` is the standard CSS escape hatch for "let the weight win over my
|
||||
// content width". Without it a weighted child is floored by its own text — that is
|
||||
// real flexbox behaviour, since `min-width` defaults to `auto` — so a narrow column
|
||||
// beside a wide one settles at its text width and a declared 2:1 comes out 1.4:1.
|
||||
if (cls === "min-w-0") {
|
||||
out.minW0 = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Spacing. `p-*` is interior padding, `gap-*` the space between children. Tailwind's
|
||||
// per-side variants (`pt-*`, `px-*`) are not here: the engine has one padding value,
|
||||
// and quietly treating `pt-8` as padding on all four sides would be wrong in a way
|
||||
// the model could not see.
|
||||
const pad = /^p-(\d+)$/.exec(cls)
|
||||
if (pad) {
|
||||
const px = scaleToPx(pad[1])
|
||||
if (px !== null) {
|
||||
out.pad = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const gap = /^gap-(\d+)$/.exec(cls)
|
||||
if (gap) {
|
||||
const px = scaleToPx(gap[1])
|
||||
if (px !== null) {
|
||||
out.gap = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// `max-w-*` uses the spacing scale too, so `max-w-96` is 384px. Tailwind's named
|
||||
// sizes are also accepted, because a model reaches for `max-w-md` more readily than
|
||||
// for a step number.
|
||||
const maxW = /^max-w-(\d+)$/.exec(cls)
|
||||
if (maxW) {
|
||||
const px = scaleToPx(maxW[1])
|
||||
if (px !== null) {
|
||||
out.maxW = px
|
||||
continue
|
||||
}
|
||||
}
|
||||
const named = NAMED_MAX_W[cls]
|
||||
if (named) {
|
||||
out.maxW = named
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- text ----
|
||||
// The three flags draw.io's fontStyle bitmask actually carries. They combine by
|
||||
// adding bits, so bold + italic is legal and needs no special case here.
|
||||
if (cls === "font-bold" || cls === "font-normal") {
|
||||
out.bold = cls === "font-bold"
|
||||
continue
|
||||
}
|
||||
// The other seven weights fall through to `ignored` on purpose, so the model is
|
||||
// told the distinction was dropped instead of quietly getting plain bold.
|
||||
if (UNSUPPORTED_WEIGHTS.has(cls)) {
|
||||
out.ignored.push(cls)
|
||||
continue
|
||||
}
|
||||
if (cls === "italic" || cls === "not-italic") {
|
||||
out.italic = cls === "italic"
|
||||
continue
|
||||
}
|
||||
if (cls === "underline" || cls === "no-underline") {
|
||||
out.underline = cls === "underline"
|
||||
continue
|
||||
}
|
||||
// Strikethrough is its own bit (8) beside bold/italic/underline, so it combines with
|
||||
// them rather than replacing one. `no-underline` above deliberately does NOT clear
|
||||
// it: in CSS both are values of `text-decoration-line`, and Tailwind's `no-underline`
|
||||
// means "not underlined", not "undecorated".
|
||||
if (cls === "line-through") {
|
||||
out.strike = true
|
||||
continue
|
||||
}
|
||||
// `text-*` is three different Tailwind properties sharing one prefix: size
|
||||
// (text-lg), alignment (text-left) and COLOUR (text-red-500). The size and
|
||||
// alignment tables are exact-match, so a colour class falls through to `ignored`
|
||||
// rather than being mistaken for a size.
|
||||
if (cls in FONT_SIZE) {
|
||||
out.fontSize = FONT_SIZE[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in TEXT_ALIGN) {
|
||||
out.textAlign = TEXT_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls in VERTICAL_ALIGN) {
|
||||
out.verticalAlign = VERTICAL_ALIGN[cls]
|
||||
continue
|
||||
}
|
||||
if (cls === "whitespace-nowrap" || cls === "whitespace-normal") {
|
||||
out.nowrap = cls === "whitespace-nowrap"
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- border ----
|
||||
// `border` alone is 1px, `border-N` is N px — Tailwind's border width is a plain
|
||||
// pixel count, not the 4px spacing scale.
|
||||
if (cls === "border") {
|
||||
out.borderWidth = 1
|
||||
continue
|
||||
}
|
||||
// `border-0` and `border-none` both mean no border, so they are handled before the
|
||||
// numeric case (which would otherwise read border-0 as a zero-width border and
|
||||
// leave draw.io drawing its default hairline).
|
||||
if (cls === "border-none" || cls === "border-0") {
|
||||
out.borderless = true
|
||||
continue
|
||||
}
|
||||
const bw = /^border-(\d+)$/.exec(cls)
|
||||
if (bw) {
|
||||
out.borderWidth = Number(bw[1])
|
||||
continue
|
||||
}
|
||||
if (
|
||||
cls === "border-solid" ||
|
||||
cls === "border-dashed" ||
|
||||
cls === "border-dotted"
|
||||
) {
|
||||
out.borderStyle = cls.slice("border-".length) as
|
||||
| "solid"
|
||||
| "dashed"
|
||||
| "dotted"
|
||||
continue
|
||||
}
|
||||
// Whole-shape corner radius. Per-corner classes (`rounded-tl-lg`) fall through to
|
||||
// `ignored`: draw.io only offers those on a separate template shape.
|
||||
if (cls in RADIUS) {
|
||||
out.radius = RADIUS[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop shadow. Per-side border classes (`border-l-4`) fall through to `ignored`, and
|
||||
// so does every colour form (`shadow-blue-500`) since these tables are exact-match.
|
||||
if (cls in SHADOW) {
|
||||
out.shadow = SHADOW[cls]
|
||||
continue
|
||||
}
|
||||
|
||||
out.ignored.push(cls)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Tailwind's named max-width steps, in px.
|
||||
*
|
||||
* Tailwind's own values, rounded to whole pixels. Stopping at `4xl` is deliberate: anything
|
||||
* wider than about a thousand pixels is not a cap a diagram needs, and offering the whole
|
||||
* ladder invites a model to pick one at random.
|
||||
*/
|
||||
const NAMED_MAX_W: Record<string, number> = {
|
||||
"max-w-xs": 320,
|
||||
"max-w-sm": 384,
|
||||
"max-w-md": 448,
|
||||
"max-w-lg": 512,
|
||||
"max-w-xl": 576,
|
||||
"max-w-2xl": 672,
|
||||
"max-w-3xl": 768,
|
||||
"max-w-4xl": 896,
|
||||
}
|
||||
@@ -31,6 +31,83 @@ export interface PoolCell {
|
||||
*/
|
||||
export type Align = "start" | "center" | "end" | "stretch"
|
||||
|
||||
/**
|
||||
* Presentation a node may override, beyond what its `role` decides.
|
||||
*
|
||||
* The admission test is that draw.io can draw the distinction FAITHFULLY — see tw.ts for the
|
||||
* properties that failed it and why. Most fields here are one style key with one value; a few
|
||||
* (`shadow`, `borderStyle`, the radius trio) expand to a fixed group of keys, which is fine
|
||||
* because the field still names one visual decision. What is not allowed is a field whose
|
||||
* values collapse onto fewer pictures than it promises.
|
||||
*
|
||||
* Kept as one optional object rather than a dozen loose fields so the round-trip has one
|
||||
* thing to carry and the node type does not grow a field per CSS property.
|
||||
*
|
||||
* `role` remains the primary way to say what a node IS; this is for the cases where the
|
||||
* model needs to override one aspect of how it looks.
|
||||
*/
|
||||
export interface TextStyle {
|
||||
/** Bold. draw.io's fontStyle carries one bold bit, not a weight ladder. */
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
underline?: boolean
|
||||
/** Strikethrough — a fourth bit in the same mask, so it combines with the others. */
|
||||
strike?: boolean
|
||||
/** Type size in px. */
|
||||
size?: number
|
||||
/** Horizontal text alignment inside the shape. */
|
||||
align?: "left" | "center" | "right"
|
||||
/** Vertical text alignment inside the shape. */
|
||||
valign?: "top" | "middle" | "bottom"
|
||||
/** Keep the label on one line instead of wrapping it. */
|
||||
nowrap?: boolean
|
||||
/** Border thickness in px. */
|
||||
borderWidth?: number
|
||||
/** Border line style. Dashed and dotted read as "planned", "optional", "logical". */
|
||||
borderStyle?: "solid" | "dashed" | "dotted"
|
||||
/**
|
||||
* Corner radius in px.
|
||||
*
|
||||
* Real pixels, not a percentage: draw.io's `arcSize` is a percentage of the shape by
|
||||
* default, but `absoluteArcSize=1` switches it to absolute units, and it halves the
|
||||
* value, so an 8px radius is emitted as `arcSize=16` (mxShape.getArcSize,
|
||||
* mxShape.js:1172-1189).
|
||||
*
|
||||
* Overrides the radius of a shape that has one of its own: `round` and `terminator` are
|
||||
* rounded rectangles already, and changing how round they are does not change what they
|
||||
* are, so a radius class is allowed to win.
|
||||
*/
|
||||
radius?: number
|
||||
/** No border at all — a plain colour field. */
|
||||
borderless?: boolean
|
||||
/**
|
||||
* Drop shadow, as a rung: 1–4 for Tailwind's sm/md/lg/xl, 0 for explicitly none.
|
||||
*
|
||||
* A rung rather than raw offsets because draw.io takes five separate numbers
|
||||
* (`shadowOffsetX/Y`, `shadowBlur`, `shadowColor`, `shadowOpacity` — mxShape.js:505-535)
|
||||
* and letting a caller set them individually is exactly the magic-number freedom this
|
||||
* vocabulary exists to remove.
|
||||
*/
|
||||
shadow?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How a container spreads its children along its own stacking axis — CSS's
|
||||
* justify-content, and Yoga's six values.
|
||||
*
|
||||
* Until this existed the policy was hard-coded and differed per axis: a row padded its
|
||||
* gaps and centred the result, a column packed to the top and left every spare pixel in
|
||||
* one slab at the bottom. That slab is the empty bottom-left corner of a poster, and
|
||||
* nothing the model could declare would move it.
|
||||
*/
|
||||
export type Justify =
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "between"
|
||||
| "around"
|
||||
| "evenly"
|
||||
|
||||
/**
|
||||
* What a box IS, drawn as its conventional outline.
|
||||
*
|
||||
@@ -78,6 +155,16 @@ export interface BoxNode {
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/**
|
||||
* Hard cap on width, px. Text rewraps to fit instead of running the box wider, so
|
||||
* this is what stops one long sentence stretching a whole page into a letterbox.
|
||||
* Higher priority than `grow`, matching Yoga's min/max rule.
|
||||
*/
|
||||
maxW?: number
|
||||
/** Let a `grow` weight shrink this below its own text width — CSS's `min-width: 0`. */
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: type, alignment, border. Absent means the role decides. */
|
||||
text?: TextStyle
|
||||
/** Flowchart outline. Absent means a plain rectangle. */
|
||||
shape?: BoxShape
|
||||
style?: string
|
||||
@@ -118,6 +205,22 @@ export interface GroupNode {
|
||||
grow?: number
|
||||
/** Cross-axis behaviour within the parent. Absent = center; stretch = fill it. */
|
||||
align?: Align
|
||||
/** How the children spread along `dir`. Absent = start (packed, no extra spacing). */
|
||||
justify?: Justify
|
||||
/** Cross-axis default for every child that does not declare its own `align`. */
|
||||
alignItems?: Align
|
||||
/** Hard cap on width, px. Children wrap or shrink to fit rather than overflow it. */
|
||||
maxW?: number
|
||||
/**
|
||||
* Let a `grow` weight shrink this below its own content width — CSS's `min-width: 0`.
|
||||
*
|
||||
* Without it a weighted child is floored by its text, which is real flexbox behaviour
|
||||
* (`min-width` defaults to `auto`) but means a declared 2:1 quietly resolves to
|
||||
* whatever the two columns' text allows.
|
||||
*/
|
||||
minW0?: boolean
|
||||
/** Presentation overrides: title type, alignment, frame border. */
|
||||
text?: TextStyle
|
||||
/** Interior padding, px. Absent = the default (24). */
|
||||
pad?: number
|
||||
style?: string
|
||||
@@ -273,6 +376,16 @@ export interface DiagramTree {
|
||||
links: LinkSpec[]
|
||||
/** Page title, if the diagram has one. */
|
||||
title?: string
|
||||
/**
|
||||
* Target width : height of the whole page. 1 is square, 1.6 landscape, 0.7 portrait.
|
||||
*
|
||||
* This is the one number that decides whether a diagram reads as a poster or as a
|
||||
* letterbox, and it cannot be derived: the same content is a legitimate 1-column
|
||||
* portrait or 3-column landscape. So the model declares it, the engine gives the top
|
||||
* level a width to match, and every proportional rule below finally has a share of
|
||||
* something real to divide up.
|
||||
*/
|
||||
aspect?: number
|
||||
/**
|
||||
* Cells the parser could not fit into the tree — a user's own annotation boxes, a
|
||||
* legend, shapes from an imported file. Kept verbatim and re-emitted untouched so
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
* WebAssembly issues with Next.js server-side rendering.
|
||||
*/
|
||||
|
||||
// Default system prompt (~1900 tokens) - works with all models
|
||||
// Default system prompt - works with all models. Keep it to the things that are true no
|
||||
// matter which tool gets picked: how to choose, and the shape vocabulary the tools share.
|
||||
// Anything specific to one tool belongs in THAT tool's description (app/api/chat/route.ts),
|
||||
// where it only costs context when the model actually reaches for it.
|
||||
export const DEFAULT_SYSTEM_PROMPT = `
|
||||
You are an expert diagram creation assistant specializing in draw.io XML generation.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams. You declare the structure and a layout engine computes the geometry; for a small class of diagrams you write the XML yourself.
|
||||
Your primary function is chat with user and crafting clear, well-organized visual diagrams. You declare the structure and a layout engine computes the geometry — you never write draw.io XML for a new diagram.
|
||||
You can see images that users upload, and you can read the text content extracted from PDF documents they upload.
|
||||
ALWAYS respond in the same language as the user's last message.
|
||||
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure (2-3 sentences max), then pick the tool by the diagram's LAYOUT SHAPE — see "Choosing the right tool" below. Most diagrams go through draw_graph or restructure_diagram, which compute the layout for you; display_diagram (hand-written XML) is the exception, reserved for diagrams whose exact positions ARE the content.
|
||||
When you are asked to create a diagram, briefly describe your plan about the layout and structure (2-3 sentences max), then build it with restructure_diagram, which computes the layout for you; edit_diagram patches a diagram already on the canvas.
|
||||
After generating or editing a diagram, you don't need to say anything. The user can see the diagram - no need to describe it.
|
||||
|
||||
## App Context
|
||||
@@ -30,152 +33,73 @@ You can read and modify diagrams by generating draw.io XML code through tool cal
|
||||
4. **Export** (via draw.io toolbar): Users can save diagrams as .drawio, .svg, or .png files.
|
||||
5. **Clear Chat** (trash icon, bottom-right of chat input): Clears the conversation and resets the diagram.
|
||||
|
||||
You utilize the following tools:
|
||||
---Tool1---
|
||||
tool name: display_diagram
|
||||
description: Display a NEW diagram on draw.io. Use this when creating a diagram from scratch or when major structural changes are needed.
|
||||
parameters: {
|
||||
xml: string
|
||||
}
|
||||
---Tool2---
|
||||
tool name: edit_diagram
|
||||
description: Edit specific parts of the EXISTING diagram. Use this when making small targeted changes like adding/removing elements, changing labels, or adjusting properties. This is more efficient than regenerating the entire diagram.
|
||||
parameters: {
|
||||
edits: Array<{search: string, replace: string}>
|
||||
}
|
||||
---Tool3---
|
||||
tool name: append_diagram
|
||||
description: Continue generating diagram XML when display_diagram was truncated due to output length limits. Only use this after display_diagram truncation.
|
||||
parameters: {
|
||||
xml: string // Continuation fragment (NO wrapper tags like <mxGraphModel> or <root>)
|
||||
}
|
||||
---Tool4---
|
||||
tool name: get_shape_library
|
||||
description: Get shape/icon library documentation. Use this to discover available icon shapes (Azure, GCP, Kubernetes, Material Design, etc.) before creating diagrams with special icons. ALWAYS call this before using any icon library — never guess the syntax.
|
||||
parameters: {
|
||||
library: string // Library name: azure2, gcp2, kubernetes, cisco19, flowchart, bpmn, material_design, etc.
|
||||
}
|
||||
---Tool5---
|
||||
tool name: restructure_diagram
|
||||
description: Build or edit a diagram by declaring STRUCTURE instead of XML. You say what nests inside what; the engine computes every coordinate, size and arrow route. Containers always fit their contents and siblings never overlap. Boxes and containers accept a role (banner/heading/callout/good/bad/metric/muted) for visual hierarchy — the engine's theme styles each role consistently. Never pass coordinates, XML or style strings.
|
||||
parameters: {
|
||||
operations: Array<Operation> // add_icon | add_box | add_container | add_grid | add_pool | add_sequence | add_radial | remove | move | set_label | set_dir | set_gap | link | unlink | set_title
|
||||
}
|
||||
---Tool6---
|
||||
tool name: search_stencils
|
||||
description: Find AWS stencil names for restructure_diagram. Returns real names with their official colours. Call this before naming any AWS icon — a name you invent is rejected.
|
||||
parameters: {
|
||||
query: string
|
||||
kind?: "icon" | "group"
|
||||
limit?: number
|
||||
}
|
||||
---Tool7---
|
||||
tool name: draw_graph
|
||||
description: Draw a flowchart, decision tree, dependency graph, ER diagram or site map from nodes and arrows alone. You give NO positions and NO nesting; the engine works out how many rows there are, who shares a row, and who goes left of whom, so arrows do not cross or run through unrelated boxes. Replaces the whole diagram — use restructure_diagram to edit afterwards.
|
||||
parameters: {
|
||||
nodes: Array<{id: string, label: string, shape?: string, icon?: string, group?: string}>
|
||||
edges: Array<{source: string, target: string, label?: string, dashed?: boolean}>
|
||||
title?: string
|
||||
flow?: "col" | "row" // col (default): top to bottom. row: left to right
|
||||
}
|
||||
Set the same group name on nodes that belong to one zone (remote vs local, frontend vs backend, roles); the engine colours each group consistently. Never pick colours yourself.
|
||||
---End of tools---
|
||||
|
||||
## Choosing the right tool
|
||||
|
||||
IMPORTANT: Divide by the diagram's LAYOUT SHAPE, not by which icon set it uses. The engine
|
||||
tools (draw_graph, restructure_diagram) are the DEFAULT: they compute every coordinate, size
|
||||
and arrow route, so nothing overlaps and no arrow cuts through a box. Hand-written XML via
|
||||
display_diagram is the exception, not the default.
|
||||
Every new diagram is built with restructure_diagram: it computes every coordinate, size and
|
||||
arrow route, so nothing overlaps and no arrow cuts through a box. You never write draw.io XML
|
||||
yourself for a new diagram — edit_diagram is for patching what is already on the canvas.
|
||||
|
||||
Use draw_graph when the diagram is boxes joined by arrows and the arrows define the order:
|
||||
Within restructure_diagram, pick the OPERATION by the diagram's layout shape, not by which
|
||||
icon set it uses:
|
||||
|
||||
Use add_graph when the arrows define the order:
|
||||
flowcharts, decision trees, process diagrams, approval flows, CI/CD pipelines, state machines,
|
||||
git/branching workflows, dependency graphs, ER diagrams, site maps, data-flow diagrams,
|
||||
and any "illustrate how X works" where X is a sequence of steps or states.
|
||||
You supply only nodes and edges. Do NOT try to lay these out yourself and do NOT write XML for
|
||||
them — a flowchart written as XML or as nested containers comes out as one column, which forces
|
||||
every branch to jump over the step beside it.
|
||||
You supply only nodes and edges — no positions, no nesting. Do NOT try to lay these out
|
||||
yourself out of containers and boxes: a flowchart declared as nesting comes out as one
|
||||
column, which forces every branch to jump over the step beside it.
|
||||
Omit parent for a whole-page flowchart (send clear first when replacing one); set parent
|
||||
to put a flow inside one zone of a bigger diagram — an architecture zone whose contents
|
||||
follow the data flow, a poster column with a small flowchart in it.
|
||||
|
||||
Use restructure_diagram's add_graph when ONE ZONE of a nested diagram is arrow-ordered:
|
||||
an architecture diagram where a zone's contents follow the data flow, a poster column with
|
||||
a small flowchart in it. add_graph takes nodes+edges like draw_graph, lays them out inside
|
||||
its container, and the container joins the outer layout like any node (dir: col flows
|
||||
down, row flows right).
|
||||
|
||||
Use restructure_diagram when the diagram's meaning is in NESTING or in a fixed frame:
|
||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils first.
|
||||
Use the nesting operations when the diagram's meaning is in NESTING or in a fixed frame:
|
||||
- Cloud architecture (AWS/Azure/GCP/Kubernetes): things inside things. Call search_stencils
|
||||
first; the tool's description carries the per-zone recipe.
|
||||
- Swimlane and BPMN diagrams: add_pool with one lane per role, then add_box with lane and col.
|
||||
- Sequence diagrams: add_sequence, one add_box per participant, then link with a step number.
|
||||
- Mind maps and org charts: add_radial, one add_box per node, then link parent to child.
|
||||
This applies to BOTH creating and editing.
|
||||
|
||||
Use restructure_diagram ALSO for poster-style layouts — paper summaries, cheat sheets,
|
||||
infographics, comparison sheets. The layout model is flexbox: row/col containers nest
|
||||
freely, and a box with INTERNAL structure is just an invisible col container (pad 10-14)
|
||||
holding smaller boxes. Three knobs, use them everywhere:
|
||||
- grow: columns split leftover width by weight (grow 3 / grow 2 makes a 3:2 page).
|
||||
- align "stretch": a child fills its parent's cross axis (width, in a column). Use it on
|
||||
headings, highlight bars and body boxes so a column's edges line up. Note stretch is
|
||||
about WIDTH — content keeps its natural height and packs to the top of its column; the
|
||||
engine leaves leftover vertical space at the bottom, never inflates boxes to fill it.
|
||||
So do NOT give leaf boxes grow to "fill" a column — balance columns by moving content.
|
||||
- pad: small (8-14) for tight cards, default 24 for roomy sections.
|
||||
Labels take inline HTML — <b>, <i>, <font color="#1B5E20">, <br> — so one box can hold a
|
||||
bold keyword, a second paragraph, a coloured verdict line. Emoji in headings (💡 Core Idea)
|
||||
cost nothing and read instantly.
|
||||
Recipe: a col container as the page (banner box as masthead, align stretch — no set_title,
|
||||
the banner IS the title), a row of col containers with grow weights as columns, each section
|
||||
a heading-role box + content. Roles (callout/good/bad/metric/muted) are the hierarchy,
|
||||
group names are the colour, and the engine guarantees nothing overlaps.
|
||||
A comparison card, concretely:
|
||||
add_container id=std dir=col gap=8 pad=12 grow=1 role=bad (a red panel)
|
||||
add_box parent=std label="<b>Standard Prompting</b>" align=stretch
|
||||
add_box parent=std label="Q: …the problem text…" align=stretch
|
||||
add_box parent=std role=bad label="A: The answer is 11." align=stretch
|
||||
add_box parent=std label="<font color=\\"#B85450\\"><b>✗ Often Wrong</b></font>" align=start
|
||||
The same nesting operations cover poster-style layouts — paper summaries, cheat sheets,
|
||||
infographics, comparison sheets. The tool's own description carries the recipe; what matters
|
||||
when choosing is that a poster is a nest of row/col containers, not an arrow-ordered graph.
|
||||
|
||||
Use display_diagram only for diagrams that need ABSOLUTE positioning, where the engine's layout
|
||||
would be wrong rather than merely different:
|
||||
UI mockups and wireframes, floor plans, circuit and P&ID diagrams, seating charts,
|
||||
Gantt charts, anything where the exact position of each element is the content.
|
||||
|
||||
- Use edit_diagram for: small changes to a diagram that was made with display_diagram.
|
||||
- Use append_diagram for: ONLY when display_diagram was truncated due to output length - continue generating from where you stopped
|
||||
- Use get_shape_library for: discovering icons for a library, before display_diagram.
|
||||
Use edit_diagram for a small, targeted change to whatever is already on the canvas — a label,
|
||||
a colour, one shape added or removed. It patches the XML in place, so it also works on a
|
||||
diagram the user drew by hand. For anything structural, go back to restructure_diagram.
|
||||
|
||||
Working with restructure_diagram:
|
||||
- Say the page shape FIRST, with set_page: aspect is width:height (1 square, 1.4 landscape
|
||||
slide, 0.75 portrait poster, 1.6 wide architecture). Nothing proportional works before it —
|
||||
column weights need a total width to take a share of, and without one they do nothing.
|
||||
- Layout, type, borders and surface are Tailwind classes on any container or box:
|
||||
layout grow-3 / w-2/3 for a column's share (add min-w-0 to every column when the ratio
|
||||
has to be exact — otherwise a column will not shrink below its own text, exactly
|
||||
as in a browser), items-stretch so cards line up, justify-between to spread a
|
||||
short column instead of leaving a hole, gap-4 and p-6 for spacing (Tailwind's
|
||||
4px scale), max-w-md to cap a width so long text wraps instead of stretching
|
||||
the page.
|
||||
type font-bold, italic, underline, line-through, text-xs..text-4xl,
|
||||
text-left/center/right, align-top/middle/bottom, whitespace-nowrap.
|
||||
border border-2 for thickness, border-dashed or border-dotted — a dashed frame reads
|
||||
as planned or logical rather than deployed. border-none for a plain colour
|
||||
field with no outline.
|
||||
surface rounded-lg / rounded-xl / rounded-full for corners (real pixels, so the same
|
||||
class is the same corner everywhere), shadow-md / shadow-lg to lift a card off
|
||||
the panel behind it. One elevation level per group of cards, not on everything.
|
||||
NOT accepted, and reported back to you when you use them: every colour class and gradients
|
||||
(colour comes from role and group), the seven font weights between thin and black,
|
||||
opacity-*, truncate, per-side borders (border-l) and per-side padding (pt-4), per-corner
|
||||
radius, tracking-*, uppercase, leading-*, outline-*, and transforms.
|
||||
- Look every AWS icon name up with search_stencils first. Batch the lookups.
|
||||
- Editing: send only the operations for what changes. The engine re-reads the current structure from the canvas each time, so you never re-send the diagram. Adding one service is one operation.
|
||||
- The tool replies with an outline of the resulting structure. Use the ids in it to name things in your next call.
|
||||
- Pack related services into one labelled area using add_grid with 3-8 icons, rather than giving each service its own frame — a frame holding a single icon renders as a mostly empty box.
|
||||
- Nesting order for AWS: Region → VPC → Availability Zone → Subnet. Managed and global services (CloudFront, Route 53, S3, DynamoDB, SQS, SNS) sit OUTSIDE the VPC.
|
||||
- A container with an empty label is an invisible wrapper. Use it to group several containers along one axis without drawing another visible frame.
|
||||
- If the user has manually moved or recoloured something, that is already part of what the engine reads back — do not try to restore it.
|
||||
|
||||
Swimlane diagrams (add_pool):
|
||||
- lanes are the roles, top to bottom. Every step goes in exactly one lane.
|
||||
- Each step declares lane (which role) and col (which step of the process). Columns advance left to
|
||||
right; leave a cell empty when a role does nothing at that point — that is information.
|
||||
- Give two steps the same col when they happen at the same time in different lanes.
|
||||
- phases is optional and labels groups of columns, e.g. ["Intake", "Review", "Decision"].
|
||||
- orientation defaults to horizontal (lanes stacked down, flow left to right). Set it to
|
||||
"vertical" when the user asks for vertical swimlanes: lanes become columns and the flow
|
||||
runs downwards.
|
||||
|
||||
Sequence diagrams (add_sequence):
|
||||
- One add_box per participant, left to right in the order they first act.
|
||||
- Every message is a link with a step number. The step is the message's ORDER, so number them
|
||||
1, 2, 3… in the order they happen. A reply is its own link back the other way.
|
||||
- A participant calling itself is a link from a node to itself.
|
||||
|
||||
Mind maps and org charts (add_radial):
|
||||
- Children are a FLAT list — every node is added with the radial container as its parent, never
|
||||
nested inside another box. The hierarchy comes from the links.
|
||||
- link from parent to child. The node nothing points at becomes the centre.
|
||||
- spread: "radial" for a mind map (branches on both sides, compact). "down" for an org chart
|
||||
(everything below its manager, which is the only way a reporting line reads correctly).
|
||||
|
||||
Box shapes, for both draw_graph and add_box — a shape says what a node IS:
|
||||
Box shapes, for both add_graph's nodes and add_box — a shape says what a node IS:
|
||||
- Flowchart: "decision" (a diamond) for a branch, "terminator" for a start or end point, "data"
|
||||
for input or output, "document" for a report, "round" for a soft-edged step.
|
||||
- Semantic: "cylinder" for a database, "queue" for a message queue, "person" for an actor or
|
||||
@@ -185,158 +109,36 @@ Box shapes, for both draw_graph and add_box — a shape says what a node IS:
|
||||
Use shapes: a database drawn as a cylinder needs no "database" caption; a reader takes a
|
||||
diamond to mean a choice. Drawing everything as the same rectangle throws that away.
|
||||
|
||||
Core capabilities:
|
||||
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations
|
||||
- Convert user descriptions into visually appealing diagrams
|
||||
- Structure complex systems into clear, organized visual components
|
||||
- Generate valid, well-formed XML strings, for the diagrams that need display_diagram
|
||||
|
||||
Layout constraints (for display_diagram only — the engine tools compute layout themselves):
|
||||
- CRITICAL: Keep all diagram elements within a single page viewport to avoid page breaks
|
||||
- Position all elements with x coordinates between 0-800 and y coordinates between 0-600
|
||||
- Maximum width for containers (like AWS cloud boxes): 700 pixels
|
||||
- Maximum height for containers: 550 pixels
|
||||
- Use compact, efficient layouts that fit the entire diagram in one view
|
||||
- Start positioning from reasonable margins (e.g., x=40, y=40) and keep elements grouped closely
|
||||
- For large diagrams with many elements, use vertical stacking or grid layouts that stay within bounds
|
||||
- Avoid spreading elements too far apart horizontally - users should see the complete diagram without a page break line
|
||||
|
||||
Note that:
|
||||
- Use proper tool calls to generate or edit diagrams;
|
||||
- never return raw XML in text responses,
|
||||
- never use display_diagram to generate messages that you want to send user directly. e.g. to generate a "hello" text box when you want to greet user.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes and connectors while maintaining visual clarity.
|
||||
- Return XML only via tool calls, never in text responses.
|
||||
- If user asks you to replicate a diagram based on an image, remember to match the diagram style and layout as closely as possible. Especially, pay attention to the lines and shapes, for example, if the lines are straight or curved, and if the shapes are rounded or square.
|
||||
- For cloud/tech diagrams (AWS, Azure, GCP, K8s) or when using icon libraries (material_design, webicons, etc.), call get_shape_library first to discover available icon shapes and their correct syntax. NEVER guess icon style syntax — always look it up first.
|
||||
- NEVER include XML comments (<!-- ... -->) in your generated XML. Draw.io strips comments, which breaks edit_diagram patterns.
|
||||
|
||||
When using edit_diagram tool:
|
||||
- Use operations: update (modify cell by id), add (new cell), delete (remove cell by id)
|
||||
- For update/add: provide cell_id and complete new_xml (full mxCell element including mxGeometry)
|
||||
- For delete: only cell_id is needed
|
||||
- Find the cell_id from "Current diagram XML" in system context
|
||||
- Example update: {"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
- Example delete: {"operations": [{"operation": "delete", "cell_id": "5"}]}
|
||||
- Example add: {"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
|
||||
⚠️ JSON ESCAPING: Every " inside new_xml MUST be escaped as \\". Example: id=\\"5\\" value=\\"Label\\"
|
||||
|
||||
## Draw.io XML Structure Reference
|
||||
|
||||
**IMPORTANT:** You only generate the mxCell elements. The wrapper structure and root cells (id="0", id="1") are added automatically.
|
||||
|
||||
Example - generate ONLY this:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Generate ONLY mxCell elements - NO wrapper tags (<mxfile>, <mxGraphModel>, <root>)
|
||||
2. Do NOT include root cells (id="0" or id="1") - they are added automatically
|
||||
3. ALL mxCell elements must be siblings - NEVER nest mxCell inside another mxCell
|
||||
4. Use unique sequential IDs starting from "2"
|
||||
5. Set parent="1" for top-level shapes, or parent="<container-id>" for grouped elements
|
||||
|
||||
Shape (vertex) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
Connector (edge) example:
|
||||
\`\`\`xml
|
||||
<mxCell id="3" style="endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
|
||||
### Edge Routing Rules:
|
||||
When creating edges/connectors, you MUST follow these rules to avoid overlapping lines:
|
||||
|
||||
**Rule 1: NEVER let multiple edges share the same path**
|
||||
- If two edges connect the same pair of nodes, they MUST exit/enter at DIFFERENT positions
|
||||
- Use exitY=0.3 for first edge, exitY=0.7 for second edge (NOT both 0.5)
|
||||
|
||||
**Rule 2: For bidirectional connections (A↔B), use OPPOSITE sides**
|
||||
- A→B: exit from RIGHT side of A (exitX=1), enter LEFT side of B (entryX=0)
|
||||
- B→A: exit from LEFT side of B (exitX=0), enter RIGHT side of A (entryX=1)
|
||||
|
||||
**Rule 3: Always specify exitX, exitY, entryX, entryY explicitly**
|
||||
- Every edge MUST have these 4 attributes set in the style
|
||||
- Example: style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;"
|
||||
|
||||
**Rule 4: Route edges AROUND intermediate shapes (obstacle avoidance) - CRITICAL!**
|
||||
- Before creating an edge, identify ALL shapes positioned between source and target
|
||||
- If any shape is in the direct path, you MUST use waypoints to route around it
|
||||
- For DIAGONAL connections: route along the PERIMETER (outside edge) of the diagram, NOT through the middle
|
||||
- Add 20-30px clearance from shape boundaries when calculating waypoint positions
|
||||
- Route ABOVE (lower y), BELOW (higher y), or to the SIDE of obstacles
|
||||
- NEVER draw a line that visually crosses over another shape's bounding box
|
||||
|
||||
**Rule 5: Plan layout strategically BEFORE generating XML**
|
||||
- Organize shapes into visual layers/zones (columns or rows) based on diagram flow
|
||||
- Space shapes 150-200px apart to create clear routing channels for edges
|
||||
- Mentally trace each edge: "What shapes are between source and target?"
|
||||
- Prefer layouts where edges naturally flow in one direction (left-to-right or top-to-bottom)
|
||||
|
||||
**Rule 6: Use multiple waypoints for complex routing**
|
||||
- One waypoint is often not enough - use 2-3 waypoints to create proper L-shaped or U-shaped paths
|
||||
- Each direction change needs a waypoint (corner point)
|
||||
- Waypoints should form clear horizontal/vertical segments (orthogonal routing)
|
||||
- Calculate positions by: (1) identify obstacle boundaries, (2) add 20-30px margin
|
||||
|
||||
**Rule 7: Choose NATURAL connection points based on flow direction**
|
||||
- NEVER use corner connections (e.g., entryX=1,entryY=1) - they look unnatural
|
||||
- For TOP-TO-BOTTOM flow: exit from bottom (exitY=1), enter from top (entryY=0)
|
||||
- For LEFT-TO-RIGHT flow: exit from right (exitX=1), enter from left (entryX=0)
|
||||
- For DIAGONAL connections: use the side closest to the target, not corners
|
||||
- Example: Node below-right of source → exit from bottom (exitY=1) OR right (exitX=1), not corner
|
||||
|
||||
**Before generating XML, mentally verify:**
|
||||
1. "Do any edges cross over shapes that aren't their source/target?" → If yes, add waypoints
|
||||
2. "Do any two edges share the same path?" → If yes, adjust exit/entry points
|
||||
3. "Are any connection points at corners (both X and Y are 0 or 1)?" → If yes, use edge centers instead
|
||||
4. "Could I rearrange shapes to reduce edge crossings?" → If yes, revise layout
|
||||
|
||||
|
||||
\`\`\`
|
||||
- Use proper tool calls to generate or edit diagrams; never return raw XML in text responses.
|
||||
- Focus on producing clean, professional diagrams that effectively communicate the intended
|
||||
information through thoughtful layout and design choices.
|
||||
- When artistic drawings are requested, creatively compose them using standard diagram shapes
|
||||
and connectors while maintaining visual clarity.
|
||||
- If user asks you to replicate a diagram based on an image, match the diagram style and layout
|
||||
as closely as possible. Pay attention to the lines and shapes — whether lines are straight or
|
||||
curved, whether shapes are rounded or square.
|
||||
- NEVER include XML comments (<!-- ... -->) in an edit_diagram replacement. Draw.io strips
|
||||
comments, which breaks the search patterns.
|
||||
|
||||
`
|
||||
|
||||
// Style instructions - only included when minimalStyle is false
|
||||
const STYLE_INSTRUCTIONS = `
|
||||
Common styles:
|
||||
- Shapes: rounded=1 (rounded corners), fillColor=#hex, strokeColor=#hex
|
||||
- Edges: endArrow=classic/block/open/none, startArrow=none/classic, curved=1, edgeStyle=orthogonalEdgeStyle
|
||||
- Text: fontSize=14, fontStyle=1 (bold), align=center/left/right
|
||||
Colour and emphasis come from the engine, not from you: set role for hierarchy
|
||||
(banner/heading/callout/good/bad/metric/muted) and group for which colour family a set of nodes
|
||||
shares. Never pass a hex colour or a style string, and never a colour utility class
|
||||
(bg-blue-500, text-red-600) — those are dropped. Classes cover layout, type and surface
|
||||
(corners, borders, shadow); COLOUR is the one thing they never carry.
|
||||
`
|
||||
|
||||
// Minimal style instruction - skip styling and focus on layout (prepended to prompt for emphasis)
|
||||
// Minimal style instruction - plain output, no theme (prepended to prompt for emphasis)
|
||||
const MINIMAL_STYLE_INSTRUCTION = `
|
||||
## ⚠️ MINIMAL STYLE MODE ACTIVE ⚠️
|
||||
|
||||
### No Styling - Plain Black/White Only
|
||||
- NO fillColor, NO strokeColor, NO rounded, NO fontSize, NO fontStyle
|
||||
- NO color attributes (no hex colors like #ff69b4)
|
||||
- Style: "whiteSpace=wrap;html=1;" for shapes, "html=1;endArrow=classic;" for edges
|
||||
- IGNORE all color/style examples below
|
||||
|
||||
### Container/Group Shapes - MUST be Transparent
|
||||
- For container shapes (boxes that contain other shapes): use "fillColor=none;" to make background transparent
|
||||
- This prevents containers from covering child elements
|
||||
- Example: style="whiteSpace=wrap;html=1;fillColor=none;" for container rectangles
|
||||
|
||||
### Focus on Layout Quality
|
||||
Since we skip styling, STRICTLY follow the "Edge Routing Rules" section below:
|
||||
- SPACING: Minimum 50px gap between all elements
|
||||
- NO OVERLAPS: Elements and edges must never overlap
|
||||
- Follow ALL 7 Edge Routing Rules for arrow positioning
|
||||
- Use waypoints to route edges AROUND obstacles
|
||||
- Use different exitY/entryY values for multiple edges between same nodes
|
||||
The user asked for plain, unstyled output. Do NOT set role or group on any node, and do not use
|
||||
inline HTML (<b>, <font color>) in labels. Structure alone carries the meaning: nesting, shapes
|
||||
and arrow direction. The engine will render everything in one neutral style.
|
||||
|
||||
`
|
||||
|
||||
@@ -346,55 +148,13 @@ const EXTENDED_ADDITIONS = `
|
||||
|
||||
## Extended Tool Reference
|
||||
|
||||
### display_diagram Details
|
||||
|
||||
**VALIDATION RULES** (XML will be rejected if violated):
|
||||
1. Generate ONLY mxCell elements - wrapper tags and root cells are added automatically
|
||||
2. All mxCell elements must be siblings - never nested inside other mxCell elements
|
||||
3. Every mxCell needs a unique id attribute (start from "2")
|
||||
4. Every mxCell needs a valid parent attribute (use "1" for top-level, or container-id for grouped)
|
||||
5. Edge source/target attributes must reference existing cell IDs
|
||||
6. Escape special characters in values: < for <, > for >, & for &, " for "
|
||||
|
||||
**Example with swimlanes and edges** (generate ONLY this - no wrapper tags):
|
||||
\`\`\`xml
|
||||
<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
|
||||
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
|
||||
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### append_diagram Details
|
||||
|
||||
**WHEN TO USE:** Only call this tool when display_diagram output was truncated (you'll see an error message about truncation).
|
||||
|
||||
**CRITICAL RULES:**
|
||||
1. Do NOT include any wrapper tags - just continue the mxCell elements
|
||||
2. Continue from EXACTLY where your previous output stopped
|
||||
3. Complete the remaining mxCell elements
|
||||
4. If still truncated, call append_diagram again with the next fragment
|
||||
|
||||
**Example:** If previous output ended with \`<mxCell id="x" style="rounded=1\`, continue with \`;" vertex="1">...\` and complete the remaining elements.
|
||||
|
||||
### edit_diagram Details
|
||||
|
||||
edit_diagram uses ID-based operations to modify cells directly by their id attribute.
|
||||
|
||||
**Operations:**
|
||||
Three operations, all addressed by the cell's id attribute:
|
||||
- **update**: Replace an existing cell. Provide cell_id and new_xml.
|
||||
- **add**: Add a new cell. Provide cell_id (new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges (source/target) are auto-deleted. Only specify ONE cell_id.
|
||||
- **add**: Add a new cell. Provide cell_id (a new unique id) and new_xml.
|
||||
- **delete**: Remove a cell. **Cascade is automatic**: children AND edges touching it are removed
|
||||
with it. Pass ONE cell_id — do not list the children separately.
|
||||
|
||||
**Input Format:**
|
||||
\`\`\`json
|
||||
@@ -407,70 +167,27 @@ edit_diagram uses ID-based operations to modify cells directly by their id attri
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Examples:**
|
||||
|
||||
Change label:
|
||||
Change a label:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Add new shape:
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "add", "cell_id": "new1", "new_xml": "<mxCell id=\\"new1\\" value=\\"New Box\\" style=\\"rounded=1;fillColor=#dae8fc;\\" vertex=\\"1\\" parent=\\"1\\">\\n <mxGeometry x=\\"400\\" y=\\"200\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/>\\n</mxCell>"}]}
|
||||
\`\`\`
|
||||
|
||||
Delete container (children & edges auto-deleted):
|
||||
Delete a container (children and edges go too):
|
||||
\`\`\`json
|
||||
{"operations": [{"operation": "delete", "cell_id": "2"}]}
|
||||
\`\`\`
|
||||
|
||||
**Error Recovery:**
|
||||
If cell_id not found, check "Current diagram XML" for correct IDs. If major restructuring is needed, pick the tool by layout shape (draw_graph / restructure_diagram / display_diagram) as usual
|
||||
If a cell_id is not found, re-read the ids in "Current diagram XML". If the change is structural
|
||||
rather than a small patch, rebuild with restructure_diagram instead — it computes
|
||||
the layout, so you never hand-place anything.
|
||||
|
||||
### Keeping an edited diagram consistent
|
||||
|
||||
|
||||
|
||||
|
||||
## Edge Examples
|
||||
|
||||
### Two edges between same nodes (CORRECT - no overlap):
|
||||
\`\`\`xml
|
||||
<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
|
||||
<mxGeometry relative="1" as="geometry"/>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with single waypoint (simple detour):
|
||||
\`\`\`xml
|
||||
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;" edge="1" parent="1" source="a" target="b">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="300" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
|
||||
### Edge with waypoints (routing AROUND obstacles) - CRITICAL PATTERN:
|
||||
**Scenario:** Hotfix(right,bottom) → Main(center,top), but Develop(center,middle) is in between.
|
||||
**WRONG:** Direct diagonal line crosses over Develop
|
||||
**CORRECT:** Route around the OUTSIDE (go right first, then up)
|
||||
\`\`\`xml
|
||||
<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="750" y="80"/>
|
||||
<mxPoint x="750" y="150"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
\`\`\`
|
||||
This routes the edge to the RIGHT of all shapes (x=750), then enters Main from the right side.
|
||||
|
||||
**Key principle:** When connecting distant nodes diagonally, route along the PERIMETER of the diagram, not through the middle where other shapes exist.`
|
||||
A diagram built by the engine carries its structure in the cell styles (the dai_* markers). If you
|
||||
patch a cell with edit_diagram, leave those markers intact: restructure_diagram reads them back to
|
||||
understand the current structure, and a cell that loses them is treated as a hand-drawn shape and
|
||||
stops taking part in the computed layout.`
|
||||
|
||||
// Extended system prompt = DEFAULT + EXTENDED_ADDITIONS
|
||||
export const EXTENDED_SYSTEM_PROMPT = DEFAULT_SYSTEM_PROMPT + EXTENDED_ADDITIONS
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("add_graph", () => {
|
||||
expect(right.w).toBeGreaterThan(right.h)
|
||||
})
|
||||
|
||||
it("reports unknown edge endpoints as an error", () => {
|
||||
it("draws the rest of the graph and warns about an unknown edge endpoint", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{
|
||||
op: "add_graph",
|
||||
@@ -104,6 +104,10 @@ describe("add_graph", () => {
|
||||
edges: [{ source: "a", target: "ghost" }],
|
||||
},
|
||||
])
|
||||
expect(r.errors.join(" ")).toContain("ghost")
|
||||
// A warning, not an error: node "a" is perfectly drawable, and rejecting the whole
|
||||
// call would cost a turn to arrive back at the same diagram.
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.xml).toBeTruthy()
|
||||
expect(r.warnings.join(" ")).toContain("ghost")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,11 +90,62 @@ describe("grow", () => {
|
||||
|
||||
const flat = make(false)
|
||||
const grown = make(true)
|
||||
const extraMain = rectOf(grown, "main").w - rectOf(flat, "main").w
|
||||
const extraSide = rectOf(grown, "side").w - rectOf(flat, "side").w
|
||||
// The slack lands on the columns instead of the gaps, split 2:1.
|
||||
expect(extraMain).toBeGreaterThan(0)
|
||||
expect(extraMain / extraSide).toBeCloseTo(2, 0)
|
||||
|
||||
// The weights make main wider than it would be on its own content...
|
||||
expect(rectOf(grown, "main").w).toBeGreaterThan(rectOf(flat, "main").w)
|
||||
|
||||
// ...but NOT the full 2:1, and that is correct rather than a shortfall. `side`
|
||||
// will not shrink below the width of its own text, so the ratio settles wherever
|
||||
// that floor allows. A browser does exactly the same: `min-width` defaults to
|
||||
// `auto`, so a `flex: 2` column stops shrinking at its content too.
|
||||
const ratio = rectOf(grown, "main").w / rectOf(grown, "side").w
|
||||
expect(ratio).toBeGreaterThan(1.3)
|
||||
expect(ratio).toBeLessThan(2.1)
|
||||
})
|
||||
|
||||
it("min-w-0 lets the weights win over the content width", () => {
|
||||
// The CSS escape hatch, same spelling: with min-w-0 the narrow column may be
|
||||
// squeezed under its own text, so a declared 2:1 really comes out 2:1.
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_container", id: "page", label: "", dir: "col", gap: 16 },
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "A very wide masthead banner that stretches the page out",
|
||||
role: "banner",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cols",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
gap: 16,
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "main",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "grow-2 min-w-0",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "side",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0",
|
||||
},
|
||||
{ op: "add_box", id: "a", parent: "main", label: "main content" },
|
||||
{ op: "add_box", id: "b", parent: "side", label: "aside" },
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const ratio = rectOf(xml, "main").w / rectOf(xml, "side").w
|
||||
expect(ratio).toBeCloseTo(2, 0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
drawGraph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
graphToOperations,
|
||||
type Operation,
|
||||
restructureDiagram,
|
||||
} from "@/lib/diagram-engine"
|
||||
import {
|
||||
@@ -20,6 +20,31 @@ const e = (source: string, target: string, label?: string): GraphEdge => ({
|
||||
...(label ? { label } : {}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draw a whole-page graph the way the model does: one `add_graph` with no parent.
|
||||
*
|
||||
* There used to be a `drawGraph` entry point that did only this. It was removed once
|
||||
* `add_graph` covered the same ground with a `parent` argument — one code path instead of
|
||||
* two overlapping ones — and these tests kept their assertions by going through this.
|
||||
*/
|
||||
function drawGraph(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
opts: { title?: string; flow?: "col" | "row" } = {},
|
||||
) {
|
||||
const ops: Operation[] = [
|
||||
{
|
||||
op: "add_graph",
|
||||
id: "g",
|
||||
nodes,
|
||||
edges,
|
||||
...(opts.flow ? { dir: opts.flow } : {}),
|
||||
} as Operation,
|
||||
]
|
||||
if (opts.title) ops.unshift({ op: "set_title", title: opts.title })
|
||||
return restructureDiagram("", ops)
|
||||
}
|
||||
|
||||
describe("graphToOperations: layering", () => {
|
||||
it("puts a chain in one node per layer", () => {
|
||||
const { layers } = graphToOperations(
|
||||
|
||||
@@ -47,12 +47,38 @@ describe("stampContainer", () => {
|
||||
dir: "row",
|
||||
gap: 30,
|
||||
})
|
||||
// Duplicate keys are legal in draw.io and the LAST wins (verified in-browser),
|
||||
// so appending a second container=1 keeps the shape a container.
|
||||
expect(s.match(/container=1/g)?.length).toBe(2)
|
||||
// Stated exactly once. Duplicate keys are legal in draw.io and the last one wins
|
||||
// (verified in-browser), so a second copy was harmless to render — but a container
|
||||
// is re-stamped on EVERY layout, so appending unconditionally grew the style by
|
||||
// another copy per round-trip and the XML never settled.
|
||||
expect(s.match(/container=1/g)?.length).toBe(1)
|
||||
expect(readDir(s)).toBe("row")
|
||||
})
|
||||
|
||||
it("re-stamping is idempotent, so a round-trip settles", () => {
|
||||
const once = stampContainer(ACCOUNT_STYLE, {
|
||||
kind: "group",
|
||||
dir: "row",
|
||||
gap: 30,
|
||||
})
|
||||
const twice = stampContainer(once, {
|
||||
kind: "group",
|
||||
dir: "row",
|
||||
gap: 30,
|
||||
})
|
||||
expect(twice).toBe(once)
|
||||
})
|
||||
|
||||
it("corrects a catalog stencil that declares container=0", () => {
|
||||
const s = stampContainer("rounded=0;container=0;fillColor=none;", {
|
||||
kind: "group",
|
||||
dir: "col",
|
||||
gap: 12,
|
||||
})
|
||||
expect(s).not.toContain("container=0")
|
||||
expect(s.match(/container=1/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it("records kind, dir and gap so the parser need not guess", () => {
|
||||
const s = stampContainer(VPC_STYLE, {
|
||||
kind: "group",
|
||||
|
||||
199
tests/unit/diagram-engine-poster.test.ts
Normal file
199
tests/unit/diagram-engine-poster.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import {
|
||||
absoluteRects,
|
||||
escapesParent,
|
||||
outsidePage,
|
||||
overlaps,
|
||||
} from "./fixtures/geometry"
|
||||
|
||||
const page = (x: string) => {
|
||||
const m = x.match(/pageWidth="(\d+)" pageHeight="(\d+)"/)!
|
||||
return { w: +m[1], h: +m[2], aspect: +m[1] / +m[2] }
|
||||
}
|
||||
const ink = (x: string) => {
|
||||
const p = page(x)
|
||||
let a = 0
|
||||
for (const [, r] of absoluteRects(x)) a += r.w * r.h
|
||||
return a / (p.w * p.h)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole pipeline on one realistic diagram, built exactly the way the tool description
|
||||
* tells the model to build a poster.
|
||||
*
|
||||
* Every other test here checks one field in isolation, and each of those passed while the
|
||||
* realistic case was still wrong: the declared 2:1 columns came out 1:1 because the row
|
||||
* holding them was never given a width, and the declared portrait page came out landscape
|
||||
* because widening the page rewraps the text and shortens it, which one pass cannot account
|
||||
* for. Neither showed up until the pieces were used together.
|
||||
*/
|
||||
describe("a poster built exactly as the tool description now instructs", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 0.8 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Chain-of-Thought Prompting",
|
||||
role: "banner",
|
||||
class: "self-stretch",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "by",
|
||||
parent: "page",
|
||||
label: "Wei et al., 2022 · NeurIPS",
|
||||
role: "muted",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cols",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "left",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "grow-2 min-w-0 gap-3 items-stretch",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "right",
|
||||
parent: "cols",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0 gap-3 items-stretch justify-between",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "h1",
|
||||
parent: "left",
|
||||
label: "What it is",
|
||||
role: "heading",
|
||||
group: "idea",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p1",
|
||||
parent: "left",
|
||||
label: "Ask the model to lay out its intermediate steps before answering, instead of jumping straight to a result.",
|
||||
group: "idea",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "h2",
|
||||
parent: "left",
|
||||
label: "Why it helps",
|
||||
role: "heading",
|
||||
group: "why",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p2",
|
||||
parent: "left",
|
||||
label: "Breaking a hard problem into easy sub-steps makes the reasoning visible, so it can be checked and debugged. The biggest gains show up on maths, logic and multi-hop questions.",
|
||||
group: "why",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "h3",
|
||||
parent: "left",
|
||||
label: "Worked example",
|
||||
role: "heading",
|
||||
group: "eg",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p3",
|
||||
parent: "left",
|
||||
label: "Roger has 5 tennis balls and buys 2 cans of 3 balls each. 2 x 3 = 6 new balls; 5 + 6 = 11 balls.",
|
||||
group: "eg",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "h4",
|
||||
parent: "right",
|
||||
label: "Costs & limits",
|
||||
role: "heading",
|
||||
group: "cost",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p4",
|
||||
parent: "right",
|
||||
label: "More tokens, slower, pricier.",
|
||||
group: "cost",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p5",
|
||||
parent: "right",
|
||||
label: "Steps can look sound yet still be wrong.",
|
||||
role: "bad",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "m1",
|
||||
parent: "right",
|
||||
label: "+40%",
|
||||
role: "metric",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p6",
|
||||
parent: "right",
|
||||
label: "Mainly emerges in large models.",
|
||||
role: "muted",
|
||||
},
|
||||
] as Operation[])
|
||||
|
||||
it("builds cleanly, portrait, in proportion, with nothing spilling", () => {
|
||||
expect(r.errors).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const p = page(xml)
|
||||
const rects = absoluteRects(xml)
|
||||
console.log(" page:", p, " ink:", (ink(xml) * 100).toFixed(0) + "%")
|
||||
console.log(
|
||||
" columns — left:",
|
||||
rects.get("left")!.w,
|
||||
"right:",
|
||||
rects.get("right")!.w,
|
||||
"ratio:",
|
||||
(rects.get("left")!.w / rects.get("right")!.w).toFixed(2),
|
||||
)
|
||||
console.log(" warnings:", r.warnings.length ? r.warnings : "none")
|
||||
|
||||
// Portrait was asked for.
|
||||
expect(p.aspect).toBeLessThan(1.0)
|
||||
// 2:1 was asked for, and min-w-0 was given on both columns.
|
||||
const ratio = rects.get("left")!.w / rects.get("right")!.w
|
||||
expect(ratio).toBeGreaterThan(1.8)
|
||||
expect(ratio).toBeLessThan(2.2)
|
||||
// Nothing broken.
|
||||
expect(overlaps(rects, ["left", "right"])).toEqual([])
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
})
|
||||
|
||||
it("re-reading the canvas gives the same diagram back", () => {
|
||||
const again = restructureDiagram(r.xml as string, [])
|
||||
expect(again.errors).toEqual([])
|
||||
expect(page(again.xml as string)).toEqual(page(r.xml as string))
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
})
|
||||
@@ -348,6 +348,35 @@ describe("edges survive", () => {
|
||||
})
|
||||
expect(renderDiagram(t).xml).not.toContain('as="points"')
|
||||
})
|
||||
|
||||
it("an edge's style does not grow across repeated re-layouts", () => {
|
||||
// The router recomputes the connection points every pass, and an edge recovered from
|
||||
// the canvas already carries the previous pass's set. Appending them added 76
|
||||
// characters per round-trip forever. draw.io resolves duplicate keys last-wins, so
|
||||
// the arrow always LOOKED right — only measuring the string catches it.
|
||||
let t = tree([group("f", "row", [icon("a"), icon("b")], "F")], {
|
||||
links: [{ source: "a", target: "b" }],
|
||||
})
|
||||
const styleOfEdge = (xml: string) =>
|
||||
/<mxCell id="ed1"[^>]*style="([^"]*)"/.exec(xml)?.[1] ?? ""
|
||||
|
||||
const lengths: number[] = []
|
||||
const portCounts: number[] = []
|
||||
let xml = ""
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
xml = renderDiagram(t).xml
|
||||
const s = styleOfEdge(xml)
|
||||
lengths.push(s.length)
|
||||
portCounts.push((s.match(/exitX=/g) ?? []).length)
|
||||
t = parseDiagram(xml).tree
|
||||
}
|
||||
|
||||
// Same length every pass, and the port keys stated once rather than accumulating.
|
||||
expect(new Set(lengths).size).toBe(1)
|
||||
expect(portCounts).toEqual([1, 1, 1, 1])
|
||||
// Which is what lets the XML itself reach a fixed point.
|
||||
expect(renderDiagram(parseDiagram(xml).tree).xml).toBe(xml)
|
||||
})
|
||||
})
|
||||
|
||||
describe("foreign cells survive", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { drawGraph, restructureDiagram } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import {
|
||||
absoluteRects,
|
||||
escapesParent,
|
||||
@@ -117,18 +117,22 @@ describe("roles", () => {
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("draw_graph nodes accept roles too", () => {
|
||||
const r = drawGraph(
|
||||
[
|
||||
{ id: "t", label: "Pipeline", role: "heading" },
|
||||
{ id: "a", label: "Build" },
|
||||
{ id: "warn", label: "Flaky stage", role: "bad" },
|
||||
],
|
||||
[
|
||||
{ source: "t", target: "a" },
|
||||
{ source: "a", target: "warn" },
|
||||
],
|
||||
)
|
||||
it("add_graph nodes accept roles too", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{
|
||||
op: "add_graph",
|
||||
id: "g",
|
||||
nodes: [
|
||||
{ id: "t", label: "Pipeline", role: "heading" },
|
||||
{ id: "a", label: "Build" },
|
||||
{ id: "warn", label: "Flaky stage", role: "bad" },
|
||||
],
|
||||
edges: [
|
||||
{ source: "t", target: "a" },
|
||||
{ source: "a", target: "warn" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(r.errors).toEqual([])
|
||||
expect(last(styleOf(r.xml as string, "warn"), "fillColor")).toBe(
|
||||
"#F8CECC",
|
||||
|
||||
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal file
237
tests/unit/diagram-engine-tw-compose.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import { absoluteRects, escapesParent, outsidePage } from "./fixtures/geometry"
|
||||
|
||||
/**
|
||||
* The text and border classes used together on a realistic diagram.
|
||||
*
|
||||
* Every class is unit-tested in isolation above; this checks they compose — that an explicit
|
||||
* alignment survives alongside a role, that a dashed frame does not leak onto its sibling,
|
||||
* and that none of it disturbs the geometry or the round-trip.
|
||||
*/
|
||||
describe("text classes on a real diagram", () => {
|
||||
it("a comparison sheet using type, alignment and dashed borders", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.3 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Deployment options",
|
||||
role: "banner",
|
||||
class: "self-stretch text-center text-2xl font-bold",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cols",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "now",
|
||||
parent: "cols",
|
||||
label: "Today",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "plan",
|
||||
parent: "cols",
|
||||
label: "Planned",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0 gap-2 p-3 items-stretch border-2 border-dashed",
|
||||
group: "plan",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "n1",
|
||||
parent: "now",
|
||||
label: "Single region",
|
||||
class: "text-left",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "n2",
|
||||
parent: "now",
|
||||
label: "99.9% uptime",
|
||||
class: "font-bold text-lg",
|
||||
group: "now",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p1",
|
||||
parent: "plan",
|
||||
label: "Multi region",
|
||||
class: "text-left italic",
|
||||
group: "plan",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "p2",
|
||||
parent: "plan",
|
||||
label: "99.99% uptime",
|
||||
class: "font-bold text-lg",
|
||||
group: "plan",
|
||||
},
|
||||
] as Operation[])
|
||||
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.warnings).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const st = (id: string) =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
||||
const k = (s: string, key: string) => {
|
||||
const all = [
|
||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
||||
]
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
|
||||
// The masthead: centred 24px bold, and it spans the page.
|
||||
expect(k(st("mast"), "align")).toBe("center")
|
||||
expect(k(st("mast"), "fontSize")).toBe("24")
|
||||
expect(k(st("mast"), "fontStyle")).toBe("1")
|
||||
|
||||
// The "planned" column is dashed; "today" is not.
|
||||
expect(k(st("plan"), "dashed")).toBe("1")
|
||||
expect(k(st("plan"), "strokeWidth")).toBe("2")
|
||||
expect(k(st("now"), "dashed")).toBeUndefined()
|
||||
|
||||
// Type overrides land on the leaves too.
|
||||
expect(k(st("n2"), "fontSize")).toBe("18")
|
||||
expect(k(st("p1"), "fontStyle")).toBe("2") // italic only
|
||||
expect(k(st("p1"), "align")).toBe("left")
|
||||
|
||||
// Nothing broken by any of it.
|
||||
const rects = absoluteRects(xml)
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
expect(rects.get("mast")!.w).toBe(rects.get("cols")!.w)
|
||||
|
||||
// And it settles.
|
||||
const again = restructureDiagram(xml, [])
|
||||
expect(again.errors).toEqual([])
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("radius, shadow and borderless compose with roles, groups and shapes", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.2 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
// A shape that ALREADY owns rounded/arcSize, with a radius class on top. The
|
||||
// class is meant to win: a terminator is a rounded rectangle either way, and
|
||||
// changing how round it is does not change what it is.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "start",
|
||||
parent: "page",
|
||||
label: "Start",
|
||||
shape: "terminator",
|
||||
class: "rounded-lg self-stretch",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "cards",
|
||||
parent: "page",
|
||||
label: "",
|
||||
dir: "row",
|
||||
class: "gap-4",
|
||||
},
|
||||
// A raised card: radius and shadow together, on top of a role and a group.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "card",
|
||||
parent: "cards",
|
||||
label: "Raised card",
|
||||
role: "body",
|
||||
group: "one",
|
||||
class: "grow-1 min-w-0 rounded-xl shadow-md",
|
||||
},
|
||||
// A plain colour field: no outline at all, which nothing else could express.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "field",
|
||||
parent: "cards",
|
||||
label: "Colour field",
|
||||
role: "callout",
|
||||
class: "grow-1 min-w-0 border-none rounded-2xl",
|
||||
},
|
||||
// Struck-through beside bold, to prove the bits add rather than replace.
|
||||
{
|
||||
op: "add_box",
|
||||
id: "gone",
|
||||
parent: "page",
|
||||
label: "Superseded step",
|
||||
class: "line-through font-bold self-stretch",
|
||||
},
|
||||
] as Operation[])
|
||||
|
||||
expect(r.errors).toEqual([])
|
||||
expect(r.warnings).toEqual([])
|
||||
const xml = r.xml as string
|
||||
const st = (id: string) =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
||||
const k = (s: string, key: string) => {
|
||||
const all = [
|
||||
...s.matchAll(new RegExp(`(?:^|;)${key}=([^;]*)`, "g")),
|
||||
]
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
|
||||
// The class wins over the shape's own proportional corner, and brings the flag that
|
||||
// reinterprets the number as pixels. Without absoluteArcSize, 16 would mean 16% of
|
||||
// the box — a different radius on every node.
|
||||
expect(k(st("start"), "shape")).toBeUndefined() // terminator is rounded=1, not shape=
|
||||
expect(k(st("start"), "rounded")).toBe("1")
|
||||
expect(k(st("start"), "absoluteArcSize")).toBe("1")
|
||||
expect(k(st("start"), "arcSize")).toBe("16")
|
||||
|
||||
// The card keeps its role's fill while taking the class's radius and shadow.
|
||||
expect(k(st("card"), "arcSize")).toBe("24")
|
||||
expect(k(st("card"), "shadow")).toBe("1")
|
||||
expect(k(st("card"), "shadowBlur")).toBe("6")
|
||||
expect(k(st("card"), "fillColor")).not.toBe("none")
|
||||
|
||||
// borderless beats the role's own stroke; the role's fill survives, which is the
|
||||
// point of a colour field.
|
||||
expect(k(st("field"), "strokeColor")).toBe("none")
|
||||
expect(k(st("field"), "fillColor")).not.toBe("none")
|
||||
expect(k(st("field"), "arcSize")).toBe("32")
|
||||
|
||||
// Bold (1) plus strikethrough (8) in one key.
|
||||
expect(k(st("gone"), "fontStyle")).toBe("9")
|
||||
|
||||
// None of it disturbs the layout.
|
||||
expect(escapesParent(xml)).toEqual([])
|
||||
expect(outsidePage(xml)).toEqual([])
|
||||
const rects = absoluteRects(xml)
|
||||
expect(rects.get("card")!.w).toBeCloseTo(rects.get("field")!.w, 0)
|
||||
|
||||
// And it settles.
|
||||
const again = restructureDiagram(xml, [])
|
||||
expect(again.errors).toEqual([])
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
})
|
||||
559
tests/unit/diagram-engine-tw.test.ts
Normal file
559
tests/unit/diagram-engine-tw.test.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { Operation } from "@/lib/diagram-engine"
|
||||
import { restructureDiagram } from "@/lib/diagram-engine"
|
||||
import { parseDiagram } from "@/lib/diagram-engine/parse"
|
||||
import { parseTw } from "@/lib/diagram-engine/tw"
|
||||
import type { BoxNode } from "@/lib/diagram-engine/types"
|
||||
import { absoluteRects, rectOf } from "./fixtures/geometry"
|
||||
|
||||
describe("parseTw", () => {
|
||||
it("reads direction, weights, alignment and distribution", () => {
|
||||
expect(
|
||||
parseTw("flex flex-col grow-3 items-stretch justify-between"),
|
||||
).toEqual({
|
||||
dir: "col",
|
||||
grow: 3,
|
||||
alignItems: "stretch",
|
||||
justify: "between",
|
||||
ignored: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("puts spacing on Tailwind's 4px scale", () => {
|
||||
const r = parseTw("p-6 gap-4")
|
||||
expect(r.pad).toBe(24)
|
||||
expect(r.gap).toBe(16)
|
||||
})
|
||||
|
||||
it("treats a width fraction as a share of the row", () => {
|
||||
// w-2/3 beside w-1/3 has to be the same layout as grow-2 beside grow-1.
|
||||
expect(parseTw("w-2/3").grow).toBe(2)
|
||||
expect(parseTw("w-1/3").grow).toBe(1)
|
||||
})
|
||||
|
||||
it("reads both named and numeric max-width", () => {
|
||||
expect(parseTw("max-w-md").maxW).toBe(448)
|
||||
expect(parseTw("max-w-96").maxW).toBe(384)
|
||||
})
|
||||
|
||||
it("later classes win, the way Tailwind's own conflicts resolve", () => {
|
||||
expect(parseTw("grow-1 grow-4").grow).toBe(4)
|
||||
expect(parseTw("justify-start justify-evenly").justify).toBe("evenly")
|
||||
})
|
||||
|
||||
it("collects what it cannot honour instead of failing", () => {
|
||||
const r = parseTw(
|
||||
"grow-2 uppercase bg-blue-500 rounded-tl-xl hover:p-4",
|
||||
)
|
||||
expect(r.grow).toBe(2)
|
||||
expect(r.ignored).toEqual([
|
||||
"uppercase",
|
||||
"bg-blue-500",
|
||||
"rounded-tl-xl",
|
||||
"hover:p-4",
|
||||
])
|
||||
})
|
||||
|
||||
it("rejects arbitrary values, so the scale stays a scale", () => {
|
||||
// The point of a scale is that there is no p-7.5 and no w-[137px].
|
||||
expect(parseTw("p-[13px] w-[137px]").ignored).toEqual([
|
||||
"p-[13px]",
|
||||
"w-[137px]",
|
||||
])
|
||||
expect(parseTw("p-[13px]").pad).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("class on an operation", () => {
|
||||
it("drives real geometry: w-3/4 beside w-1/4 splits 3:1", () => {
|
||||
// min-w-0 on both, because a column will not otherwise shrink below its own text —
|
||||
// real flexbox behaviour, and what makes an exact ratio opt-in.
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.3 },
|
||||
{ op: "add_container", id: "row", label: "", dir: "row" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "main",
|
||||
parent: "row",
|
||||
label: "Main",
|
||||
dir: "col",
|
||||
class: "w-3/4 min-w-0",
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "side",
|
||||
parent: "row",
|
||||
label: "Side",
|
||||
dir: "col",
|
||||
class: "w-1/4 min-w-0",
|
||||
},
|
||||
{ op: "add_box", id: "a", parent: "main", label: "a" },
|
||||
{ op: "add_box", id: "b", parent: "side", label: "b" },
|
||||
] as Operation[])
|
||||
expect(r.errors).toEqual([])
|
||||
const rects = absoluteRects(r.xml as string)
|
||||
const ratio = rectOf(rects, "main").w / rectOf(rects, "side").w
|
||||
expect(ratio).toBeGreaterThan(2.7)
|
||||
expect(ratio).toBeLessThan(3.3)
|
||||
})
|
||||
|
||||
it("a max-width class caps a box and its text wraps instead", () => {
|
||||
const long =
|
||||
"A deliberately long single line that would otherwise stretch its box right across the page"
|
||||
// max-w-48 is 192px — below the 260px a plain box already caps itself at, so this
|
||||
// is a cap that actually bites. max-w-xs (320) would be a no-op here.
|
||||
const capped = restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: long, class: "max-w-48" },
|
||||
] as Operation[])
|
||||
const free = restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: long },
|
||||
] as Operation[])
|
||||
const a = rectOf(absoluteRects(capped.xml as string), "x")
|
||||
const b = rectOf(absoluteRects(free.xml as string), "x")
|
||||
expect(a.w).toBeLessThanOrEqual(192)
|
||||
// Same text in a narrower box means more lines, so it must be taller.
|
||||
expect(a.h).toBeGreaterThan(b.h)
|
||||
})
|
||||
|
||||
it("an explicit field outranks the class that says the same thing", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "set_page", aspect: 1.3 },
|
||||
{ op: "add_container", id: "row", label: "", dir: "row" },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "L",
|
||||
parent: "row",
|
||||
label: "L",
|
||||
dir: "col",
|
||||
class: "grow-1 min-w-0",
|
||||
grow: 3,
|
||||
},
|
||||
{
|
||||
op: "add_container",
|
||||
id: "R",
|
||||
parent: "row",
|
||||
label: "R",
|
||||
dir: "col",
|
||||
grow: 1,
|
||||
class: "min-w-0",
|
||||
},
|
||||
{ op: "add_box", id: "a", parent: "L", label: "a" },
|
||||
{ op: "add_box", id: "b", parent: "R", label: "b" },
|
||||
] as Operation[])
|
||||
expect(r.errors).toEqual([])
|
||||
const rects = absoluteRects(r.xml as string)
|
||||
const ratio = rectOf(rects, "L").w / rectOf(rects, "R").w
|
||||
expect(ratio).toBeGreaterThan(2.7)
|
||||
})
|
||||
|
||||
it("tells the model which classes it dropped, once each", () => {
|
||||
const r = restructureDiagram("", [
|
||||
{ op: "add_container", id: "c", label: "C", dir: "col" },
|
||||
{
|
||||
op: "add_box",
|
||||
id: "a",
|
||||
parent: "c",
|
||||
label: "a",
|
||||
class: "tracking-wide p-4",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "b",
|
||||
parent: "c",
|
||||
label: "b",
|
||||
class: "tracking-wide grow-2",
|
||||
},
|
||||
] as Operation[])
|
||||
expect(r.errors).toEqual([])
|
||||
const notes = r.warnings.join(" ")
|
||||
expect(notes).toContain("tracking-wide")
|
||||
// Reported once even though two cards carried it.
|
||||
expect(notes.match(/tracking-wide/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("a class-driven layout survives a round-trip through the canvas", () => {
|
||||
const first = restructureDiagram("", [
|
||||
{
|
||||
op: "add_container",
|
||||
id: "c",
|
||||
label: "C",
|
||||
dir: "col",
|
||||
class: "gap-4 p-6 items-stretch justify-between max-w-lg",
|
||||
},
|
||||
{ op: "add_box", id: "a", parent: "c", label: "a" },
|
||||
{ op: "add_box", id: "b", parent: "c", label: "b" },
|
||||
] as Operation[])
|
||||
expect(first.errors).toEqual([])
|
||||
const again = restructureDiagram(first.xml as string, [])
|
||||
expect(again.errors).toEqual([])
|
||||
|
||||
// Every field the classes set has to come back, or the next edit would silently
|
||||
// drop it: re-reading the canvas is the ONLY place the structure comes from.
|
||||
for (const marker of [
|
||||
"dai_gap=16", // gap-4
|
||||
"dai_pad=24", // p-6
|
||||
"dai_aitems=stretch", // items-stretch
|
||||
"dai_justify=between", // justify-between
|
||||
"dai_maxw=512", // max-w-lg
|
||||
])
|
||||
expect(again.xml).toContain(marker)
|
||||
|
||||
// Geometry is the real test of a round-trip: same structure in, same boxes out.
|
||||
// (The style string itself is not compared — re-stamping appends a duplicate
|
||||
// container=1, which draw.io resolves last-value-wins. See markers.ts.)
|
||||
const a = absoluteRects(first.xml as string)
|
||||
const b = absoluteRects(again.xml as string)
|
||||
for (const id of ["c", "a", "b"])
|
||||
expect(rectOf(b, id)).toEqual(rectOf(a, id))
|
||||
|
||||
// And it must reach a fixed point rather than drifting one step per pass.
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Text and border classes.
|
||||
*
|
||||
* The supported set was chosen by reading Tailwind's property index against draw.io's own
|
||||
* style reference. These tests pin both halves: that what IS accepted reaches the XML, and
|
||||
* that what was rejected stays rejected for the documented reason — the exclusions are the
|
||||
* interesting part, because each one is a property draw.io either cannot express at all or
|
||||
* can only express coarsely.
|
||||
*/
|
||||
describe("text and border classes", () => {
|
||||
const styleOf = (xml: string, id: string) =>
|
||||
xml.match(new RegExp(`id="${id}"[^>]*style="([^"]*)"`))![1]
|
||||
const key = (style: string, k: string) => {
|
||||
const all = [...style.matchAll(new RegExp(`(?:^|;)${k}=([^;]*)`, "g"))]
|
||||
return all.length ? all[all.length - 1][1] : undefined
|
||||
}
|
||||
const build = (cls: string) =>
|
||||
restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: "Hello", class: cls },
|
||||
] as Operation[])
|
||||
|
||||
it("packs bold, italic and underline into one fontStyle bitmask", () => {
|
||||
// draw.io adds the bits: 1 bold + 2 italic + 4 underline = 7. Three separate
|
||||
// fontStyle keys would leave only the last one in effect.
|
||||
const s = styleOf(
|
||||
build("font-bold italic underline").xml as string,
|
||||
"x",
|
||||
)
|
||||
expect(key(s, "fontStyle")).toBe("7")
|
||||
expect(s.match(/fontStyle=/g)).toHaveLength(1)
|
||||
|
||||
expect(
|
||||
key(styleOf(build("font-bold").xml as string, "x"), "fontStyle"),
|
||||
).toBe("1")
|
||||
expect(
|
||||
key(styleOf(build("italic").xml as string, "x"), "fontStyle"),
|
||||
).toBe("2")
|
||||
expect(
|
||||
key(styleOf(build("underline").xml as string, "x"), "fontStyle"),
|
||||
).toBe("4")
|
||||
expect(
|
||||
key(
|
||||
styleOf(build("font-bold italic").xml as string, "x"),
|
||||
"fontStyle",
|
||||
),
|
||||
).toBe("3")
|
||||
})
|
||||
|
||||
it("maps the type scale to Tailwind's own pixel values", () => {
|
||||
for (const [cls, px] of [
|
||||
["text-xs", "12"],
|
||||
["text-base", "16"],
|
||||
["text-2xl", "24"],
|
||||
["text-4xl", "36"],
|
||||
] as const)
|
||||
expect(
|
||||
key(styleOf(build(cls).xml as string, "x"), "fontSize"),
|
||||
).toBe(px)
|
||||
})
|
||||
|
||||
it("sets both text alignments", () => {
|
||||
const s = styleOf(build("text-right align-bottom").xml as string, "x")
|
||||
expect(key(s, "align")).toBe("right")
|
||||
expect(key(s, "verticalAlign")).toBe("bottom")
|
||||
})
|
||||
|
||||
it("an explicit alignment beats the paragraph heuristic", () => {
|
||||
// A long label is set flush left automatically. Asking for centre has to win, or
|
||||
// the class would silently do nothing on exactly the labels it matters for.
|
||||
const long =
|
||||
"A label long enough that the engine sets it flush left by itself, well past sixty characters"
|
||||
const auto = restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: long },
|
||||
] as Operation[])
|
||||
const forced = restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: long, class: "text-center" },
|
||||
] as Operation[])
|
||||
expect(key(styleOf(auto.xml as string, "x"), "align")).toBe("left")
|
||||
expect(key(styleOf(forced.xml as string, "x"), "align")).toBe("center")
|
||||
})
|
||||
|
||||
it("border width and dash style reach the stroke", () => {
|
||||
expect(
|
||||
key(styleOf(build("border-4").xml as string, "x"), "strokeWidth"),
|
||||
).toBe("4")
|
||||
expect(
|
||||
key(styleOf(build("border").xml as string, "x"), "strokeWidth"),
|
||||
).toBe("1")
|
||||
|
||||
const dashed = styleOf(build("border-dashed").xml as string, "x")
|
||||
expect(key(dashed, "dashed")).toBe("1")
|
||||
expect(key(dashed, "dashPattern")).toBeUndefined()
|
||||
|
||||
// Dotted needs the pattern too, or draw.io draws a dash and calls it dotted.
|
||||
const dotted = styleOf(build("border-dotted").xml as string, "x")
|
||||
expect(key(dotted, "dashed")).toBe("1")
|
||||
expect(key(dotted, "dashPattern")).toBe("1 3")
|
||||
})
|
||||
|
||||
it("whitespace-nowrap keeps a label on one line", () => {
|
||||
expect(
|
||||
key(
|
||||
styleOf(build("whitespace-nowrap").xml as string, "x"),
|
||||
"whiteSpace",
|
||||
),
|
||||
).toBe("nowrap")
|
||||
})
|
||||
|
||||
it("rejects the seven font weights draw.io cannot distinguish", () => {
|
||||
// draw.io's fontStyle has ONE bold bit, so five of Tailwind's nine weights would
|
||||
// collapse onto bold and four onto normal. Reporting them beats pretending.
|
||||
for (const w of [
|
||||
"font-thin",
|
||||
"font-extralight",
|
||||
"font-light",
|
||||
"font-medium",
|
||||
"font-semibold",
|
||||
"font-extrabold",
|
||||
"font-black",
|
||||
])
|
||||
expect(parseTw(w).ignored).toEqual([w])
|
||||
// The two that do map are not reported.
|
||||
expect(parseTw("font-bold font-normal").ignored).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects opacity, because Tailwind's is a free number rather than a scale", () => {
|
||||
// draw.io's opacity is 0-100 and would map, but `opacity-<number>` accepts any
|
||||
// number — admitting it gives up the constraint that justifies this vocabulary.
|
||||
expect(parseTw("opacity-25 opacity-37").ignored).toEqual([
|
||||
"opacity-25",
|
||||
"opacity-37",
|
||||
])
|
||||
})
|
||||
|
||||
it("rejects truncate, which promises an ellipsis draw.io cannot draw", () => {
|
||||
// Tailwind's truncate is overflow:hidden + text-overflow:ellipsis + nowrap, and
|
||||
// draw.io's overflow has no ellipsis value — the text would just be cut.
|
||||
expect(parseTw("truncate").ignored).toEqual(["truncate"])
|
||||
})
|
||||
|
||||
it("rejects every outline class, which draw.io has no concept of", () => {
|
||||
const outlines = [
|
||||
"outline-2",
|
||||
"outline-solid",
|
||||
"outline-dashed",
|
||||
"outline-offset-2",
|
||||
]
|
||||
expect(parseTw(outlines.join(" ")).ignored).toEqual(outlines)
|
||||
})
|
||||
|
||||
it("rejects per-side borders, which would cost the shape slot", () => {
|
||||
// draw.io draws these correctly via shape=partialRectangle, but that is a SHAPE
|
||||
// name — a node cannot be both a diamond and left-edge-only, and what a node IS
|
||||
// outranks how its border looks.
|
||||
const sides = ["border-t", "border-l-4", "border-x", "border-y-2"]
|
||||
expect(parseTw(sides.join(" ")).ignored).toEqual(sides)
|
||||
})
|
||||
|
||||
it("rejects per-side padding, which draw.io only has for the label", () => {
|
||||
// spacingTop/Right/Bottom/Left look like an exact match and are not: they pad the
|
||||
// LABEL inside its cell, while this engine's `pad` is room for a container's
|
||||
// CHILDREN. Accepting `pt-8` would imply it pushes child nodes down.
|
||||
const pads = ["pt-8", "px-4", "pb-2", "ps-6"]
|
||||
expect(parseTw(pads.join(" ")).ignored).toEqual(pads)
|
||||
})
|
||||
|
||||
it("rejects per-corner radius while accepting the whole-shape one", () => {
|
||||
// Per-corner lives only on the mxgraph.basic.rect template shape, which would take
|
||||
// the node's own shape — the same trade the per-side borders lose.
|
||||
expect(parseTw("rounded-tl-lg rounded-br-sm").ignored).toEqual([
|
||||
"rounded-tl-lg",
|
||||
"rounded-br-sm",
|
||||
])
|
||||
expect(parseTw("rounded-lg").radius).toBe(8)
|
||||
})
|
||||
|
||||
it("rejects text-shadow, whose draw.io key really is one flag", () => {
|
||||
// Unlike the box shadow family, `textShadow` has no offset or blur, so Tailwind's
|
||||
// six sizes would all draw the same picture.
|
||||
expect(parseTw("text-shadow-lg").ignored).toEqual(["text-shadow-lg"])
|
||||
})
|
||||
|
||||
it("rejects letter-spacing, case and line-height — absent, not coarse", () => {
|
||||
const absent = ["tracking-wide", "uppercase", "capitalize", "leading-6"]
|
||||
expect(parseTw(absent.join(" ")).ignored).toEqual(absent)
|
||||
})
|
||||
|
||||
it("accepts the radius scale in real pixels", () => {
|
||||
// Tailwind's own values. These are pixels only because absoluteArcSize switches
|
||||
// arcSize off its default percentage reading.
|
||||
expect(parseTw("rounded").radius).toBe(4)
|
||||
expect(parseTw("rounded-xs").radius).toBe(2)
|
||||
expect(parseTw("rounded-md").radius).toBe(6)
|
||||
expect(parseTw("rounded-xl").radius).toBe(12)
|
||||
expect(parseTw("rounded-2xl").radius).toBe(16)
|
||||
expect(parseTw("rounded-3xl").radius).toBe(24)
|
||||
expect(parseTw("rounded-4xl").radius).toBe(32)
|
||||
expect(parseTw("rounded-none").radius).toBe(0)
|
||||
// `rounded-full` is calc(infinity * 1px) in CSS. draw.io clamps to half the shorter
|
||||
// side, so the value only has to exceed half the tallest box a diagram ever has —
|
||||
// and it must stay readable, because the Arrange panel shows it in an editable field.
|
||||
const full = parseTw("rounded-full").radius as number
|
||||
expect(full).toBeGreaterThan(200 / 2)
|
||||
expect(full).toBeLessThan(1000)
|
||||
})
|
||||
|
||||
it("accepts four shadow rungs and an explicit none", () => {
|
||||
expect(parseTw("shadow-sm").shadow).toBe(1)
|
||||
expect(parseTw("shadow-md").shadow).toBe(2)
|
||||
expect(parseTw("shadow-lg").shadow).toBe(3)
|
||||
expect(parseTw("shadow-xl").shadow).toBe(4)
|
||||
expect(parseTw("shadow-none").shadow).toBe(0)
|
||||
// The steps that would be indistinguishable at a diagram's scale, and the colour
|
||||
// form, stay out.
|
||||
expect(
|
||||
parseTw("shadow-2xs shadow-xs shadow-2xl shadow-blue-500").ignored,
|
||||
).toEqual(["shadow-2xs", "shadow-xs", "shadow-2xl", "shadow-blue-500"])
|
||||
})
|
||||
|
||||
it("distinguishes no border from a zero-width one", () => {
|
||||
// `border-0` must not read as "a 0px border": draw.io would still draw its default
|
||||
// hairline. Both forms mean strokeColor=none.
|
||||
expect(parseTw("border-none").borderless).toBe(true)
|
||||
expect(parseTw("border-0").borderless).toBe(true)
|
||||
expect(parseTw("border-0").borderWidth).toBeUndefined()
|
||||
})
|
||||
|
||||
it("accepts line-through, and no-underline does not clear it", () => {
|
||||
expect(parseTw("line-through").strike).toBe(true)
|
||||
// Both are values of text-decoration-line in CSS, so "not underlined" is not
|
||||
// "undecorated".
|
||||
const r = parseTw("line-through no-underline")
|
||||
expect(r.strike).toBe(true)
|
||||
expect(r.underline).toBe(false)
|
||||
})
|
||||
|
||||
it("does not mistake a colour class for a type size", () => {
|
||||
// `text-` is three Tailwind properties at once: size, alignment and colour.
|
||||
const r = parseTw("text-red-500 text-lg")
|
||||
expect(r.fontSize).toBe(18)
|
||||
expect(r.ignored).toEqual(["text-red-500"])
|
||||
})
|
||||
|
||||
it("text and border survive a round-trip through the canvas", () => {
|
||||
const first = restructureDiagram("", [
|
||||
{
|
||||
op: "add_box",
|
||||
id: "x",
|
||||
label: "Note",
|
||||
class: "font-bold italic text-lg text-right align-top border-2 border-dashed",
|
||||
},
|
||||
] as Operation[])
|
||||
expect(first.errors).toEqual([])
|
||||
const again = restructureDiagram(first.xml as string, [])
|
||||
expect(again.errors).toEqual([])
|
||||
|
||||
const s = styleOf(again.xml as string, "x")
|
||||
expect(key(s, "fontStyle")).toBe("3")
|
||||
expect(key(s, "fontSize")).toBe("18")
|
||||
expect(key(s, "align")).toBe("right")
|
||||
expect(key(s, "verticalAlign")).toBe("top")
|
||||
expect(key(s, "strokeWidth")).toBe("2")
|
||||
expect(key(s, "dashed")).toBe("1")
|
||||
|
||||
// And it settles rather than drifting a step per pass.
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("radius, shadow, strike and borderless survive a round-trip", () => {
|
||||
const first = restructureDiagram("", [
|
||||
{
|
||||
op: "add_box",
|
||||
id: "card",
|
||||
label: "Card",
|
||||
class: "rounded-lg shadow-md",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "field",
|
||||
label: "Field",
|
||||
class: "border-none",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "old",
|
||||
label: "Superseded",
|
||||
class: "line-through",
|
||||
},
|
||||
] as Operation[])
|
||||
expect(first.errors).toEqual([])
|
||||
const again = restructureDiagram(first.xml as string, [])
|
||||
expect(again.errors).toEqual([])
|
||||
|
||||
const card = styleOf(again.xml as string, "card")
|
||||
// A radius needs all three keys: arcSize alone would be read as a percentage.
|
||||
expect(key(card, "rounded")).toBe("1")
|
||||
expect(key(card, "absoluteArcSize")).toBe("1")
|
||||
// Doubled on the way out because draw.io halves it on the way in.
|
||||
expect(key(card, "arcSize")).toBe("16")
|
||||
expect(key(card, "shadow")).toBe("1")
|
||||
expect(key(card, "shadowOffsetY")).toBe("4")
|
||||
expect(key(card, "shadowBlur")).toBe("6")
|
||||
|
||||
expect(key(styleOf(again.xml as string, "field"), "strokeColor")).toBe(
|
||||
"none",
|
||||
)
|
||||
// Bit 8, on its own since nothing asked for bold or italic.
|
||||
expect(key(styleOf(again.xml as string, "old"), "fontStyle")).toBe("8")
|
||||
|
||||
const third = restructureDiagram(again.xml as string, [])
|
||||
expect(third.xml).toBe(again.xml)
|
||||
})
|
||||
|
||||
it("a theme's own borderless and square corners are not read as requests", () => {
|
||||
// A heading is ghost text — no fill, no stroke, square — because of what it IS, and
|
||||
// nearly every box carries `rounded=0` from the fallback style. Recording either as a
|
||||
// declared override would outlive a later role change, since `set_role` clears a
|
||||
// node's style but keeps its text overrides. Same trap `size` and `align` avoid by
|
||||
// comparing against the defaults for the node's kind.
|
||||
const first = restructureDiagram("", [
|
||||
{ op: "add_box", id: "h", label: "Section", role: "heading" },
|
||||
{ op: "add_box", id: "b", label: "Body", role: "body" },
|
||||
] as Operation[])
|
||||
expect(first.errors).toEqual([])
|
||||
|
||||
const t = parseDiagram(first.xml as string).tree
|
||||
const heading = t.roots.find((n) => n.id === "h") as BoxNode
|
||||
const body = t.roots.find((n) => n.id === "b") as BoxNode
|
||||
expect(heading.role).toBe("heading")
|
||||
expect(heading.text?.borderless).toBeUndefined()
|
||||
expect(heading.text?.radius).toBeUndefined()
|
||||
expect(body.text?.radius).toBeUndefined()
|
||||
|
||||
// A class-declared one IS recorded, so the distinction is real rather than a blanket
|
||||
// refusal to read these keys.
|
||||
const asked = restructureDiagram("", [
|
||||
{ op: "add_box", id: "x", label: "Field", class: "border-none" },
|
||||
] as Operation[])
|
||||
const x = parseDiagram(asked.xml as string).tree.roots.find(
|
||||
(n) => n.id === "x",
|
||||
) as BoxNode
|
||||
expect(x.text?.borderless).toBe(true)
|
||||
})
|
||||
})
|
||||
210
tests/unit/tool-call-card.test.tsx
Normal file
210
tests/unit/tool-call-card.test.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { ToolCallCard } from "@/components/chat/ToolCallCard"
|
||||
|
||||
const dict = {
|
||||
tools: { complete: "Complete" },
|
||||
chat: { copied: "c", failedToCopy: "f", copyResponse: "r" },
|
||||
}
|
||||
const base = {
|
||||
expandedTools: {},
|
||||
setExpandedTools: () => {},
|
||||
onCopy: () => {},
|
||||
copiedToolCallId: null,
|
||||
copyFailedToolCallId: null,
|
||||
dict,
|
||||
}
|
||||
|
||||
/**
|
||||
* What the tool-call card shows in the chat.
|
||||
*
|
||||
* Both diagram tools happen to name their argument `operations`, but the items have
|
||||
* different shapes: edit_diagram sends `operation`/`cell_id`/`new_xml` patches, while
|
||||
* restructure_diagram sends `op`/`id` structural steps. The card used to dispatch on
|
||||
* "does an operations key exist", so a restructure call was rendered as edit patches and
|
||||
* every row printed a blank `cell_id:` label with nothing after it.
|
||||
*/
|
||||
describe("ToolCallCard", () => {
|
||||
it("shows restructure_diagram operations, not blank cell_id rows", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={{
|
||||
type: "tool-restructure_diagram",
|
||||
toolCallId: "t1",
|
||||
state: "output-available",
|
||||
input: {
|
||||
operations: [
|
||||
{ op: "set_page", aspect: 0.8 },
|
||||
{
|
||||
op: "add_container",
|
||||
id: "page",
|
||||
label: "",
|
||||
dir: "col",
|
||||
class: "gap-4",
|
||||
},
|
||||
{
|
||||
op: "add_box",
|
||||
id: "mast",
|
||||
parent: "page",
|
||||
label: "Title",
|
||||
role: "banner",
|
||||
},
|
||||
{
|
||||
op: "add_graph",
|
||||
id: "g",
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges: [{ source: "a", target: "b" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: 'Diagram updated.\n\npage: col (wrapper)\n mast: box "Title"',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const text = container.textContent ?? ""
|
||||
// The bug: every row printed "cell_id:" with nothing after it.
|
||||
expect(text).not.toContain("cell_id:")
|
||||
// Operation names and ids are visible.
|
||||
for (const s of [
|
||||
"set_page",
|
||||
"add_container",
|
||||
"add_box",
|
||||
"add_graph",
|
||||
"page",
|
||||
"mast",
|
||||
])
|
||||
expect(text).toContain(s)
|
||||
// Arguments are summarised.
|
||||
expect(text).toContain("class=gap-4")
|
||||
expect(text).toContain("2 nodes, 1 edge")
|
||||
// The tool's own answer is shown, not thrown away.
|
||||
expect(text).toContain("Diagram updated.")
|
||||
// And it has a readable name.
|
||||
expect(text).toContain("Build Diagram")
|
||||
})
|
||||
|
||||
it("still renders edit_diagram patches the old way", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={{
|
||||
type: "tool-edit_diagram",
|
||||
toolCallId: "t2",
|
||||
state: "output-available",
|
||||
input: {
|
||||
operations: [
|
||||
{
|
||||
operation: "update",
|
||||
cell_id: "3",
|
||||
new_xml: '<mxCell id="3"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const text = container.textContent ?? ""
|
||||
expect(text).toContain("cell_id: 3")
|
||||
expect(text).toContain("update")
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Streaming: the tool input arrives character by character.
|
||||
*
|
||||
* The card renders on every frame of that, so it is handed JSON that has been repaired
|
||||
* mid-flight — an operation may be `{}`, have a half-typed name, or be a hole in the array.
|
||||
* The first version of the restructure renderer read `op.op.startsWith(...)` and crashed the
|
||||
* whole message with "Cannot read properties of undefined". These cases are what the earlier
|
||||
* tests missed by only ever passing complete input.
|
||||
*/
|
||||
describe("ToolCallCard while the input is still streaming", () => {
|
||||
const partial = (operations: unknown[]) => ({
|
||||
type: "tool-restructure_diagram",
|
||||
toolCallId: "s1",
|
||||
state: "input-streaming",
|
||||
input: { operations },
|
||||
})
|
||||
|
||||
it("renders an operation with no name yet", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard {...base} part={partial([{}]) as never} />,
|
||||
)
|
||||
expect(container.textContent).toContain("…")
|
||||
})
|
||||
|
||||
it("renders a half-typed operation name", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={partial([{ op: "add_contai" }]) as never}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("add_contai")
|
||||
})
|
||||
|
||||
it("survives a hole in the array", () => {
|
||||
// A repaired JSON array can have missing entries, which arrive as undefined.
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={
|
||||
partial([
|
||||
{ op: "set_page", aspect: 0.8 },
|
||||
undefined,
|
||||
null,
|
||||
{ op: "add_box", id: "a", label: "A" },
|
||||
]) as never
|
||||
}
|
||||
/>,
|
||||
)
|
||||
const text = container.textContent ?? ""
|
||||
expect(text).toContain("set_page")
|
||||
expect(text).toContain("add_box")
|
||||
})
|
||||
|
||||
it("survives an operation whose fields are half-formed", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={
|
||||
partial([
|
||||
{ op: "add_graph", id: "g", nodes: undefined },
|
||||
{ op: "add_box", label: null },
|
||||
{ op: 42 },
|
||||
]) as never
|
||||
}
|
||||
/>,
|
||||
)
|
||||
expect(container.textContent).toContain("add_graph")
|
||||
})
|
||||
|
||||
it("edit_diagram's renderer survives the same partial input", () => {
|
||||
const { container } = render(
|
||||
<ToolCallCard
|
||||
{...base}
|
||||
part={
|
||||
{
|
||||
type: "tool-edit_diagram",
|
||||
toolCallId: "s2",
|
||||
state: "input-streaming",
|
||||
input: {
|
||||
operations: [
|
||||
{},
|
||||
{ operation: "upda" },
|
||||
undefined,
|
||||
{ operation: "update", cell_id: "3" },
|
||||
],
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>,
|
||||
)
|
||||
const text = container.textContent ?? ""
|
||||
expect(text).toContain("cell_id: 3")
|
||||
// Exactly one label, for the one entry that has an id — a half-formed entry must
|
||||
// not print a bare "cell_id:" with nothing after it, which is the original bug.
|
||||
expect(text.match(/cell_id:/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user