mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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.
1150 lines
45 KiB
TypeScript
1150 lines
45 KiB
TypeScript
/**
|
|
* Structural operations — what the model sends instead of XML.
|
|
*
|
|
* The tree is re-derived from the canvas on every call, so an operation names existing
|
|
* nodes by id and says what to change. Adding one node costs a few dozen tokens; the
|
|
* equivalent as raw mxCell XML is hundreds, and re-emitting the whole diagram to add one
|
|
* icon costs thousands.
|
|
*
|
|
* Operations are applied in order, each against the result of the last, so a sequence
|
|
* like "add a frame, then move two nodes into it" works in a single call.
|
|
*/
|
|
|
|
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,
|
|
type DiagramTree,
|
|
findNode,
|
|
findParent,
|
|
isContainer,
|
|
type LinkSpec,
|
|
type TextStyle,
|
|
walkTree,
|
|
} from "./types"
|
|
|
|
export const OperationSchema = z.discriminatedUnion("op", [
|
|
z.object({
|
|
op: z.literal("add_icon"),
|
|
id: z.string().describe("New unique id for this node"),
|
|
parent: z
|
|
.string()
|
|
.optional()
|
|
.describe("Container id to add into; omit for top level"),
|
|
name: z.string().describe("Catalog stencil name, e.g. 's3' or 'ec2'"),
|
|
label: z.string().optional(),
|
|
lane: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Inside a pool: which lane (0-based row) this belongs to",
|
|
),
|
|
col: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Inside a pool: which column (0-based step) this sits in",
|
|
),
|
|
after: z
|
|
.string()
|
|
.optional()
|
|
.describe("Insert after this sibling id; omit to append"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_box"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z.string(),
|
|
role: z
|
|
.enum([
|
|
"banner",
|
|
"heading",
|
|
"body",
|
|
"callout",
|
|
"good",
|
|
"bad",
|
|
"metric",
|
|
"muted",
|
|
])
|
|
.optional()
|
|
.describe(
|
|
"What this IS: banner=masthead, heading=section title, callout=must-not-miss, good/bad=verdict, metric=key number, muted=fine print. The theme decides how each looks",
|
|
),
|
|
group: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Semantic zone name; nodes and panels sharing a group get the same hue from the engine's palette. Never pick colours",
|
|
),
|
|
fill: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Fill colour, e.g. #DAE8FC. Prefer the group field over picking colours",
|
|
),
|
|
stroke: z
|
|
.string()
|
|
.optional()
|
|
.describe("Border colour; pair it with fill"),
|
|
shape: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"What the node IS, drawn as its conventional outline. Catalog: decision/diamond, terminator (start/end), round, data (input/output), document, cylinder (database), queue, person (actor/user), cloud (external system), hexagon (service), ellipse (concept), callout (note), step (pipeline stage), note, card, process, tape, cube. Any other draw.io shape token also works verbatim. Omit for a plain rectangle",
|
|
),
|
|
grow: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Flex-grow weight: this box takes that share of the parent's leftover space along its stacking axis. Omit for natural size",
|
|
),
|
|
align: z
|
|
.enum(["start", "center", "end", "stretch"])
|
|
.optional()
|
|
.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()
|
|
.describe(
|
|
"Inside a pool: which lane (0-based row) this belongs to",
|
|
),
|
|
col: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Inside a pool: which column (0-based step) this sits in",
|
|
),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_container"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z
|
|
.string()
|
|
.describe("Frame title; empty string means invisible wrapper"),
|
|
role: z
|
|
.enum([
|
|
"banner",
|
|
"heading",
|
|
"body",
|
|
"callout",
|
|
"good",
|
|
"bad",
|
|
"metric",
|
|
"muted",
|
|
])
|
|
.optional()
|
|
.describe(
|
|
"Section role: heading=titled tinted panel, banner=masthead strip, good/bad=verdict panel",
|
|
),
|
|
group: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Semantic zone name; the panel and everything sharing this group take one hue",
|
|
),
|
|
dir: z.enum(["row", "col"]).describe("How children stack"),
|
|
gname: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
|
|
),
|
|
gap: z.number().optional(),
|
|
pad: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Interior padding px (default 24). Small values make tight cards; nest containers for internal structure",
|
|
),
|
|
grow: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Flex-grow weight: this container takes that share of the parent's leftover space. E.g. two columns with grow 2 and 1 split the width 2:1",
|
|
),
|
|
align: z
|
|
.enum(["start", "center", "end", "stretch"])
|
|
.optional()
|
|
.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({
|
|
op: z.literal("add_graph"),
|
|
id: z.string(),
|
|
parent: z
|
|
.string()
|
|
.optional()
|
|
.describe("Container to embed the graph in; omit for top level"),
|
|
label: z.string().optional().describe("Frame title; omit for none"),
|
|
dir: z
|
|
.enum(["col", "row"])
|
|
.optional()
|
|
.describe(
|
|
"Flow direction: col (default) downwards, row rightwards",
|
|
),
|
|
nodes: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
label: z.string(),
|
|
shape: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
group: z.string().optional(),
|
|
role: z
|
|
.enum([
|
|
"banner",
|
|
"heading",
|
|
"body",
|
|
"callout",
|
|
"good",
|
|
"bad",
|
|
"metric",
|
|
"muted",
|
|
])
|
|
.optional(),
|
|
}),
|
|
)
|
|
.describe("The graph's nodes"),
|
|
edges: z
|
|
.array(
|
|
z.object({
|
|
source: z.string(),
|
|
target: z.string(),
|
|
label: z.string().optional(),
|
|
dashed: z.boolean().optional(),
|
|
bold: z.boolean().optional(),
|
|
head: z.string().optional(),
|
|
tail: z.string().optional(),
|
|
headFill: z.boolean().optional(),
|
|
tailFill: z.boolean().optional(),
|
|
}),
|
|
)
|
|
.describe(
|
|
"The arrows. THEY decide the node positions — layering and ordering are computed from them",
|
|
),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_grid"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z.string(),
|
|
cols: z.number().describe("Number of columns"),
|
|
gap: z.number().optional(),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_pool"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z.string().describe("Pool title, e.g. the process name"),
|
|
lanes: z
|
|
.array(z.string())
|
|
.describe(
|
|
"Role names, one per lane, top to bottom. Steps go in these lanes via add_box lane/col",
|
|
),
|
|
phases: z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe("Milestone labels spanning the columns; omit for none"),
|
|
orientation: z
|
|
.enum(["horizontal", "vertical"])
|
|
.optional()
|
|
.describe(
|
|
"horizontal (default): lanes stack down, flow goes right",
|
|
),
|
|
gap: z.number().optional(),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_sequence"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z.string().describe("Diagram title; empty string for none"),
|
|
gap: z
|
|
.number()
|
|
.optional()
|
|
.describe("Horizontal spacing between participants"),
|
|
step: z
|
|
.number()
|
|
.optional()
|
|
.describe("Vertical spacing between messages"),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("add_radial"),
|
|
id: z.string(),
|
|
parent: z.string().optional(),
|
|
label: z.string().describe("Frame title; empty string for none"),
|
|
spread: z
|
|
.enum(["radial", "down"])
|
|
.optional()
|
|
.describe(
|
|
"radial (default): branches on both sides, for a mind map. down: everything below the centre, for an org chart",
|
|
),
|
|
gap: z.number().optional(),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("remove"),
|
|
id: z
|
|
.string()
|
|
.describe("Node to delete; its descendants and edges go too"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("move"),
|
|
id: z.string(),
|
|
parent: z
|
|
.string()
|
|
.optional()
|
|
.describe("New container id; omit to move to top level"),
|
|
after: z.string().optional(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_label"),
|
|
id: z.string(),
|
|
label: z.string(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_shape"),
|
|
id: z.string(),
|
|
shape: z
|
|
.string()
|
|
.describe("New shape token; 'box' resets to a plain rectangle"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_role"),
|
|
id: z.string(),
|
|
role: z
|
|
.enum([
|
|
"banner",
|
|
"heading",
|
|
"body",
|
|
"callout",
|
|
"good",
|
|
"bad",
|
|
"metric",
|
|
"muted",
|
|
])
|
|
.describe("New information role; 'body' resets to the default"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_group"),
|
|
id: z.string(),
|
|
group: z
|
|
.string()
|
|
.describe("New semantic zone; empty string removes the zone"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_dir"),
|
|
id: z.string().describe("Container to re-orient"),
|
|
dir: z.enum(["row", "col"]),
|
|
}),
|
|
z.object({
|
|
op: z.literal("set_gap"),
|
|
id: z.string(),
|
|
gap: z.number(),
|
|
}),
|
|
z.object({
|
|
op: z.literal("link"),
|
|
id: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Edge id. Required for a second edge between the same two nodes (parallel relationships), so each can be addressed later",
|
|
),
|
|
source: z.string(),
|
|
target: z.string(),
|
|
label: z.string().optional(),
|
|
dashed: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Dashed line — replication, sync, policy"),
|
|
bold: z
|
|
.boolean()
|
|
.optional()
|
|
.describe(
|
|
"A thick coloured arrow for THE key relationship — a transformation, the main flow. Use sparingly: one or two per diagram",
|
|
),
|
|
head: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Arrowhead at the target. block/open/diamond/diamondThin/oval/cross/none, ER: ERone/ERmany/ERoneToMany/ERzeroToMany/ERzeroToOne. UML inheritance: head=block headFill=false. Omit for a plain arrow",
|
|
),
|
|
tail: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Arrowhead at the source, same values as head. UML composition: tail=diamondThin tailFill=true. ER 1:N: tail=ERone head=ERoneToMany",
|
|
),
|
|
headFill: z
|
|
.boolean()
|
|
.optional()
|
|
.describe(
|
|
"Fill the head. Meaning-bearing in UML: filled diamond=composition, hollow=aggregation",
|
|
),
|
|
tailFill: z.boolean().optional(),
|
|
step: z
|
|
.number()
|
|
.optional()
|
|
.describe("Step number, shown as an 'N. ' prefix"),
|
|
}),
|
|
z.object({
|
|
op: z.literal("unlink"),
|
|
source: z.string(),
|
|
target: z.string(),
|
|
step: z
|
|
.number()
|
|
.optional()
|
|
.describe(
|
|
"Remove only the edge with this step number; omit to remove every edge between the two",
|
|
),
|
|
}),
|
|
z.object({
|
|
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>
|
|
|
|
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[]
|
|
}
|
|
|
|
/**
|
|
* The pool cell an add operation declared, if any.
|
|
*
|
|
* `lane` alone is enough — a step in a lane with no column given goes to column 0 — so the
|
|
* cell is recorded whenever either is present rather than requiring both.
|
|
*/
|
|
function cellOf(op: { lane?: number; col?: number }): {
|
|
cell?: { lane: number; col: number }
|
|
} {
|
|
if (op.lane == null && op.col == null) return {}
|
|
return {
|
|
cell: {
|
|
lane: Math.max(0, Math.round(op.lane ?? 0)),
|
|
col: Math.max(0, Math.round(op.col ?? 0)),
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Are these two nodes participants of the same sequence diagram? */
|
|
function sameSequence(tree: DiagramTree, a: string, b: string): boolean {
|
|
for (const n of walkTree(tree)) {
|
|
if (n.kind !== "sequence") continue
|
|
const ids = new Set(n.children.map((c) => c.id))
|
|
if (ids.has(a) && ids.has(b)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Insert into a child list, after a named sibling or at the end. */
|
|
function insert(
|
|
list: DiagramNode[],
|
|
node: DiagramNode,
|
|
after: string | undefined,
|
|
): void {
|
|
if (after) {
|
|
const i = list.findIndex((c) => c.id === after)
|
|
if (i >= 0) {
|
|
list.splice(i + 1, 0, node)
|
|
return
|
|
}
|
|
}
|
|
list.push(node)
|
|
}
|
|
|
|
/** Detach a node from wherever it currently sits. Returns it, or null if not found. */
|
|
function detach(tree: DiagramTree, id: string): DiagramNode | null {
|
|
const rootIdx = tree.roots.findIndex((r) => r.id === id)
|
|
if (rootIdx >= 0) return tree.roots.splice(rootIdx, 1)[0]
|
|
const parent = findParent(tree, id)
|
|
if (!parent) return null
|
|
const i = parent.children.findIndex((c) => c.id === id)
|
|
return i >= 0 ? parent.children.splice(i, 1)[0] : null
|
|
}
|
|
|
|
/** Would making `id` a descendant of `parentId` create a cycle? */
|
|
function wouldCycle(tree: DiagramTree, id: string, parentId: string): boolean {
|
|
if (id === parentId) return true
|
|
const node = findNode(tree, id)
|
|
if (!node || !isContainer(node)) return false
|
|
for (const d of walkTree({ ...tree, roots: [node] }))
|
|
if (d.id === parentId) return true
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Resolve where a new or moved node goes. Returns the child list to insert into, or an
|
|
* error string.
|
|
*/
|
|
function targetList(
|
|
tree: DiagramTree,
|
|
parentId: string | undefined,
|
|
): DiagramNode[] | string {
|
|
if (!parentId) return tree.roots
|
|
const p = findNode(tree, parentId)
|
|
if (!p) return `No node with id "${parentId}"`
|
|
if (!isContainer(p))
|
|
return `"${parentId}" is a ${p.kind}, not a container — it cannot hold children`
|
|
return p.children
|
|
}
|
|
|
|
/**
|
|
* Apply operations to a tree, in order.
|
|
*
|
|
* The input tree is deep-copied first: a partially-applied batch must not leave the
|
|
* caller's tree half-mutated when a later operation fails.
|
|
*/
|
|
export function applyOperations(
|
|
input: DiagramTree,
|
|
ops: Operation[],
|
|
): 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
|
|
|
|
// add_graph is a macro: the layered-graph pass (graph.ts) decides which layer each
|
|
// node belongs to and who stands beside whom, and emits ordinary container/box/link
|
|
// operations. Expanding it HERE — rather than treating graphs as a special page-level
|
|
// tool — is what lets a graph sit inside a poster column or an architecture zone and
|
|
// still participate in the outer flexbox like any other node.
|
|
const expanded: Operation[] = []
|
|
for (const op of ops) {
|
|
if (op.op !== "add_graph") {
|
|
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,
|
|
// The graph's own id namespaces the synthetic layer containers, so two
|
|
// graphs on one page cannot collide on `__layer0`.
|
|
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)
|
|
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]
|
|
if (root?.op === "add_container") {
|
|
if (op.label) root.label = op.label
|
|
if (op.after) root.after = op.after
|
|
}
|
|
}
|
|
expanded.push(...g.operations)
|
|
}
|
|
|
|
for (const op of expanded) {
|
|
switch (op.op) {
|
|
case "add_icon":
|
|
case "add_box":
|
|
case "add_container":
|
|
case "add_grid":
|
|
case "add_pool":
|
|
case "add_sequence":
|
|
case "add_radial": {
|
|
if (exists(op.id)) {
|
|
errors.push(`${op.op}: id "${op.id}" is already taken`)
|
|
break
|
|
}
|
|
const list = targetList(tree, op.parent)
|
|
if (typeof list === "string") {
|
|
errors.push(`${op.op}: ${list}`)
|
|
break
|
|
}
|
|
let node: DiagramNode
|
|
if (op.op === "add_icon")
|
|
node = {
|
|
kind: "icon",
|
|
id: op.id,
|
|
name: op.name,
|
|
label: op.label ?? "",
|
|
...cellOf(op),
|
|
}
|
|
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,
|
|
label: op.label,
|
|
...(op.shape && op.shape !== "box"
|
|
? { shape: op.shape }
|
|
: {}),
|
|
...(op.fill ? { fill: op.fill } : {}),
|
|
...(op.stroke ? { stroke: op.stroke } : {}),
|
|
...(op.role ? { role: op.role } : {}),
|
|
...(op.group ? { group: op.group } : {}),
|
|
...(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") {
|
|
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 ?? tw?.gap ?? 20,
|
|
children: [],
|
|
...(op.role ? { role: op.role } : {}),
|
|
...(op.group ? { group: op.group } : {}),
|
|
...(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")
|
|
node = {
|
|
kind: "grid",
|
|
id: op.id,
|
|
gname: null,
|
|
label: op.label,
|
|
cols: Math.max(1, op.cols),
|
|
gap: op.gap ?? 14,
|
|
children: [],
|
|
}
|
|
else if (op.op === "add_pool") {
|
|
if (op.lanes.length === 0) {
|
|
errors.push(
|
|
`add_pool: "${op.id}" needs at least one lane — a swimlane diagram with no roles has nothing to divide`,
|
|
)
|
|
break
|
|
}
|
|
node = {
|
|
kind: "pool",
|
|
id: op.id,
|
|
label: op.label,
|
|
lanes: op.lanes,
|
|
phases: op.phases ?? [],
|
|
orientation: op.orientation ?? "horizontal",
|
|
gap: op.gap ?? 40,
|
|
children: [],
|
|
}
|
|
} else if (op.op === "add_sequence")
|
|
node = {
|
|
kind: "sequence",
|
|
id: op.id,
|
|
label: op.label,
|
|
gap: op.gap ?? 60,
|
|
step: Math.max(24, op.step ?? 44),
|
|
children: [],
|
|
}
|
|
else
|
|
node = {
|
|
kind: "radial",
|
|
id: op.id,
|
|
label: op.label,
|
|
spread: op.spread ?? "radial",
|
|
gap: op.gap ?? 40,
|
|
children: [],
|
|
}
|
|
insert(list, node, op.after)
|
|
break
|
|
}
|
|
|
|
case "remove": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node) {
|
|
errors.push(`remove: no node with id "${op.id}"`)
|
|
break
|
|
}
|
|
// Collect the subtree's ids first — edges touching any of them go too,
|
|
// otherwise draw.io renders an arrow pointing at nothing.
|
|
const doomed = new Set<string>()
|
|
for (const d of walkTree({ ...tree, roots: [node] }))
|
|
doomed.add(d.id)
|
|
detach(tree, op.id)
|
|
tree.links = tree.links.filter(
|
|
(l) => !doomed.has(l.source) && !doomed.has(l.target),
|
|
)
|
|
break
|
|
}
|
|
|
|
case "move": {
|
|
if (!exists(op.id)) {
|
|
errors.push(`move: no node with id "${op.id}"`)
|
|
break
|
|
}
|
|
if (op.parent && !exists(op.parent)) {
|
|
errors.push(`move: no node with id "${op.parent}"`)
|
|
break
|
|
}
|
|
if (op.parent && wouldCycle(tree, op.id, op.parent)) {
|
|
errors.push(
|
|
`move: cannot move "${op.id}" into "${op.parent}" — that is inside itself`,
|
|
)
|
|
break
|
|
}
|
|
const list = targetList(tree, op.parent)
|
|
if (typeof list === "string") {
|
|
errors.push(`move: ${list}`)
|
|
break
|
|
}
|
|
const node = detach(tree, op.id)
|
|
if (!node) {
|
|
errors.push(`move: could not detach "${op.id}"`)
|
|
break
|
|
}
|
|
insert(list, node, op.after)
|
|
break
|
|
}
|
|
|
|
case "set_label": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node) {
|
|
errors.push(`set_label: no node with id "${op.id}"`)
|
|
break
|
|
}
|
|
if (node.kind === "title") {
|
|
errors.push(
|
|
`set_label: use set_title to change the page title`,
|
|
)
|
|
break
|
|
}
|
|
node.label = op.label
|
|
break
|
|
}
|
|
|
|
case "set_shape": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node || node.kind !== "box") {
|
|
errors.push(`set_shape: "${op.id}" is not a box`)
|
|
break
|
|
}
|
|
// The verbatim style is last render's composition with the OLD shape
|
|
// baked in; keeping it would override the new declaration entirely.
|
|
if (op.shape === "box") delete node.shape
|
|
else node.shape = op.shape
|
|
delete node.style
|
|
delete node.w
|
|
delete node.h
|
|
break
|
|
}
|
|
|
|
case "set_role": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node || (node.kind !== "box" && node.kind !== "group")) {
|
|
errors.push(
|
|
`set_role: "${op.id}" is not a box or container`,
|
|
)
|
|
break
|
|
}
|
|
if (op.role === "body") delete node.role
|
|
else node.role = op.role
|
|
delete node.style
|
|
break
|
|
}
|
|
|
|
case "set_group": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node || (node.kind !== "box" && node.kind !== "group")) {
|
|
errors.push(
|
|
`set_group: "${op.id}" is not a box or container`,
|
|
)
|
|
break
|
|
}
|
|
if (op.group === "") delete node.group
|
|
else node.group = op.group
|
|
delete node.style
|
|
break
|
|
}
|
|
|
|
case "set_dir": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node || !isContainer(node)) {
|
|
errors.push(`set_dir: "${op.id}" is not a container`)
|
|
break
|
|
}
|
|
if (node.kind !== "group") {
|
|
// A grid, pool, sequence or radial container arranges its children by its
|
|
// own rule; "row or column" is not a property they have.
|
|
errors.push(
|
|
node.kind === "grid"
|
|
? `set_dir: "${op.id}" is a grid — change its column count instead`
|
|
: `set_dir: "${op.id}" is a ${node.kind}, which arranges its children by its own rule and has no row/column direction`,
|
|
)
|
|
break
|
|
}
|
|
node.dir = op.dir
|
|
break
|
|
}
|
|
|
|
case "set_gap": {
|
|
const node = findNode(tree, op.id)
|
|
if (!node || !isContainer(node)) {
|
|
errors.push(`set_gap: "${op.id}" is not a container`)
|
|
break
|
|
}
|
|
;(node as ContainerNode).gap = Math.max(0, op.gap)
|
|
break
|
|
}
|
|
|
|
case "link": {
|
|
if (!exists(op.source)) {
|
|
errors.push(`link: no node with id "${op.source}"`)
|
|
break
|
|
}
|
|
if (!exists(op.target)) {
|
|
errors.push(`link: no node with id "${op.target}"`)
|
|
break
|
|
}
|
|
// A second arrow between the same pair WITHOUT an id is a mistake — two
|
|
// identical lines on top of each other. With an id it is a parallel
|
|
// relationship (an ER diagram's "places" and "cancels" between the same
|
|
// two entities), addressable separately. Sequence messages are exempt
|
|
// as before: their identity is the step, not the endpoints.
|
|
const conversation = sameSequence(tree, op.source, op.target)
|
|
const dup =
|
|
!conversation &&
|
|
!op.id &&
|
|
tree.links.some(
|
|
(l) => l.source === op.source && l.target === op.target,
|
|
)
|
|
if (dup) {
|
|
errors.push(
|
|
`link: "${op.source}" → "${op.target}" already exists — give this one an id to draw a second, parallel relationship`,
|
|
)
|
|
break
|
|
}
|
|
if (op.id && tree.links.some((l) => l.id === op.id)) {
|
|
errors.push(`link: edge id "${op.id}" is already taken`)
|
|
break
|
|
}
|
|
// Arrowhead tokens reach the style string; the same charset gate as
|
|
// shapes keeps `block;dashed=1` from smuggling style keys in.
|
|
const badHead = [op.head, op.tail].find(
|
|
(v) => v !== undefined && !/^[a-zA-Z0-9]+$/.test(v),
|
|
)
|
|
if (badHead !== undefined) {
|
|
errors.push(
|
|
`link: arrowhead "${badHead}" contains characters that are not allowed`,
|
|
)
|
|
break
|
|
}
|
|
const link: LinkSpec = { source: op.source, target: op.target }
|
|
if (op.id) link.id = op.id
|
|
if (op.label) link.label = op.label
|
|
if (op.dashed) link.dashed = true
|
|
if (op.bold) link.bold = true
|
|
if (op.head !== undefined) {
|
|
link.head = op.head
|
|
link.headFill = op.headFill ?? false
|
|
}
|
|
if (op.tail !== undefined) {
|
|
link.tail = op.tail
|
|
link.tailFill = op.tailFill ?? false
|
|
}
|
|
if (op.step != null) link.step = op.step
|
|
tree.links.push(link)
|
|
break
|
|
}
|
|
|
|
case "unlink": {
|
|
const before = tree.links.length
|
|
// With a step given, remove only that message: two participants of a sequence
|
|
// diagram can exchange several, and dropping all of them would delete messages
|
|
// the caller did not ask about.
|
|
tree.links = tree.links.filter(
|
|
(l) =>
|
|
!(
|
|
l.source === op.source &&
|
|
l.target === op.target &&
|
|
(op.step == null || l.step === op.step)
|
|
),
|
|
)
|
|
if (tree.links.length === before)
|
|
errors.push(
|
|
op.step == null
|
|
? `unlink: no edge from "${op.source}" to "${op.target}"`
|
|
: `unlink: no edge from "${op.source}" to "${op.target}" with step ${op.step}`,
|
|
)
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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. */
|
|
export function collectNames(
|
|
tree: DiagramTree,
|
|
): { id: string; name: string; kind: "icon" | "group" }[] {
|
|
const out: { id: string; name: string; kind: "icon" | "group" }[] = []
|
|
for (const n of walkTree(tree)) {
|
|
if (n.kind === "icon" && n.name)
|
|
out.push({ id: n.id, name: n.name, kind: "icon" })
|
|
else if ((n.kind === "group" || n.kind === "grid") && n.gname)
|
|
out.push({ id: n.id, name: n.gname, kind: "group" })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** How a container arranges its children, in one short phrase for the outline. */
|
|
function containerMeta(n: ContainerNode): string {
|
|
switch (n.kind) {
|
|
case "grid":
|
|
return `grid cols=${n.cols}`
|
|
case "pool":
|
|
return `pool lanes=[${n.lanes.join(" | ")}]${
|
|
n.phases.length ? ` phases=[${n.phases.join(" | ")}]` : ""
|
|
}${n.orientation === "vertical" ? " vertical" : ""}`
|
|
case "sequence":
|
|
return "sequence"
|
|
case "radial":
|
|
return `radial ${n.spread}`
|
|
default:
|
|
return n.dir
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A compact text outline of the tree, for showing the model what is on the canvas.
|
|
*
|
|
* Sending the tree as JSON would cost several times more for the same information, and
|
|
* the model does not need coordinates — it needs to know what exists and how it nests so
|
|
* it can name ids in the next operation.
|
|
*/
|
|
export function outline(tree: DiagramTree): string {
|
|
const lines: string[] = []
|
|
if (tree.title) lines.push(`title: ${tree.title}`)
|
|
const walk = (n: DiagramNode, depth: number) => {
|
|
const pad = " ".repeat(depth)
|
|
// A node inside a pool reports its cell: that is how the model knows which lane a
|
|
// step ended up in, which is exactly what it needs to move one.
|
|
const at = (x: DiagramNode) =>
|
|
(x.kind === "icon" || x.kind === "box") && x.cell
|
|
? ` @lane${x.cell.lane},col${x.cell.col}`
|
|
: ""
|
|
if (n.kind === "icon")
|
|
lines.push(
|
|
`${pad}${n.id}: icon ${n.name}${n.label ? ` "${n.label}"` : ""}${at(n)}`,
|
|
)
|
|
else if (n.kind === "box")
|
|
lines.push(
|
|
`${pad}${n.id}: box${n.shape ? ` ${n.shape}` : ""} "${n.label}"${at(n)}`,
|
|
)
|
|
else if (n.kind === "title") lines.push(`${pad}${n.id}: title`)
|
|
else {
|
|
lines.push(
|
|
`${pad}${n.id}: ${containerMeta(n)}${n.label ? ` "${n.label}"` : " (wrapper)"}`,
|
|
)
|
|
for (const c of n.children) walk(c, depth + 1)
|
|
}
|
|
}
|
|
for (const r of tree.roots) walk(r, 0)
|
|
for (const l of tree.links) {
|
|
const bits = [l.label, l.dashed ? "dashed" : null]
|
|
.filter(Boolean)
|
|
.join(", ")
|
|
lines.push(`link ${l.source} -> ${l.target}${bits ? ` (${bits})` : ""}`)
|
|
}
|
|
if (tree.foreign.length)
|
|
lines.push(
|
|
`${tree.foreign.length} cell(s) kept as-is: ${tree.foreign.map((f) => f.id).join(", ")}`,
|
|
)
|
|
return lines.join("\n")
|
|
}
|