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:
dayuan.jiang
2026-08-11 09:09:45 +09:00
parent 8687e8f04b
commit 0b03e15336
25 changed files with 3669 additions and 1234 deletions

View File

@@ -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. */