mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-03 10:00:22 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user