mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
Four more Tailwind classes, all four verified against draw.io's own source in
public/drawio rather than against a prose reference — which is how three earlier
exclusions turned out to be wrong:
rounded-* mxShape.js:1172-1189 — absoluteArcSize=1 switches arcSize to
absolute pixels and halves it, so the same class is the same
corner on every box. Previously excluded as 'percentage only'
shadow-sm..xl mxShape.js:505-535 — getShadowStyle reads five independent
params, not one flag, so Tailwind's offset+blur rungs map one
to one. Previously excluded as 'six sizes collapse to one'
line-through mxConstants.js:2054 FONT_STRIKETHROUGH: 8, read at both
mxText.js:723 and :1040. The bitmask has four bits, not three
border-none the only one of the four that adds something previously
inexpressible: a fill with no outline
Also fixes an edge style growing 76 characters per re-layout, without bound. The
router recomputes ports on every pass, and appending them to a style recovered
from the canvas — which already carried the previous pass's eight port keys —
grew the string forever. draw.io resolves duplicates last-wins so the arrow
always looked right; a byte-identity check is what caught it.
Two traps found while wiring the readback, both the same shape: a value the
THEME emits being recorded as one the model asked for. strokeColor=none from a
filled or ghost role, and rounded=0 from the fallback style. Either one would
outlive a set_role, since that clears style but keeps text.
Deliberately not included, with reasons in tw.ts: per-side borders and per-corner
radius (both would take the shape slot, and what a node IS matters more than
which of its edges show), per-side padding (draw.io's keys pad the label, not the
room left for children), text-shadow (a bare flag with no offset or blur),
opacity (Tailwind's is any integer, not a scale), tracking/uppercase/leading
(absent from draw.io — zero grep hits, not merely coarse).
615 tests, 90 new. Browser-verified: four shadow rungs visibly differ, radius is
real pixels, terminator + rounded-lg becomes a small-cornered rounded rect while
an untouched terminator stays a stadium.
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
/**
|
||
* The declarative node tree the layout engine works on.
|
||
*
|
||
* The model never writes coordinates. It declares nesting and direction; the engine
|
||
* computes every x/y/width/height. The tree is not persisted anywhere — it is
|
||
* re-derived from the canvas XML whenever it is needed (see parse.ts), so the canvas
|
||
* stays the single source of truth and a user's manual edits are an input, never
|
||
* something to be reconciled against a second copy of the state.
|
||
*/
|
||
|
||
import type { Direction } from "./markers"
|
||
import type { Role } from "./theme"
|
||
|
||
export type { Direction } from "./markers"
|
||
export type { Role } from "./theme"
|
||
|
||
/**
|
||
* Which cell of a swimlane pool a node sits in.
|
||
*
|
||
* `lane` indexes the role band, `col` the position along the flow. Cells are sparse:
|
||
* nothing has to fill lane 1 column 3 for lane 2 column 3 to exist.
|
||
*/
|
||
export interface PoolCell {
|
||
lane: number
|
||
col: number
|
||
}
|
||
|
||
/**
|
||
* Cross-axis behaviour of a child inside a row/col group, CSS's align-items per child:
|
||
* pin to either edge, centre (the default), or stretch to fill the axis.
|
||
*/
|
||
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.
|
||
*
|
||
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
|
||
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
|
||
* token passes through verbatim and degrades to a rectangle if the editor does not
|
||
* know it. See shapes.ts.
|
||
*/
|
||
export type BoxShape = string
|
||
|
||
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
|
||
export interface IconNode {
|
||
kind: "icon"
|
||
id: string
|
||
/** Catalog name, e.g. "s3" or "azure_virtual_machine". Resolved to a style by the catalog. */
|
||
name: string
|
||
label: string
|
||
/** Glyph size in px. Defaults to the diagram's icon size. */
|
||
size?: number
|
||
/** Verbatim style, when recovered from XML. Preferred over re-resolving `name`. */
|
||
style?: string
|
||
/** User froze this node's position — the engine must not move it. */
|
||
pinned?: boolean
|
||
/** Absolute geometry, when recovered from XML. Only meaningful for a pinned node. */
|
||
rect?: Rect
|
||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||
cell?: PoolCell
|
||
}
|
||
|
||
/** A plain labelled rectangle, for things the catalog has no icon for. */
|
||
export interface BoxNode {
|
||
kind: "box"
|
||
id: string
|
||
label: string
|
||
w?: number
|
||
h?: number
|
||
fill?: string
|
||
stroke?: string
|
||
bold?: boolean
|
||
/** What this node IS in the information hierarchy; the theme decides how that looks. */
|
||
role?: Role
|
||
/** Semantic zone name; every node sharing a group gets the same hue ramp. */
|
||
group?: string
|
||
/** Share of the parent's leftover flow-axis space, like flex-grow. 0/absent = natural size. */
|
||
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
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
/** Position within a `pool` parent. Ignored elsewhere. */
|
||
cell?: PoolCell
|
||
}
|
||
|
||
/** A page title. At most one per diagram; laid out outside the tree flow. */
|
||
export interface TitleNode {
|
||
kind: "title"
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
/**
|
||
* A container that stacks its children in one direction.
|
||
*
|
||
* `gname` is the catalog group stencil (group_vpc, group_region, …). When null the
|
||
* container renders as a plain frame — a labelled rectangle with a border.
|
||
*/
|
||
export interface GroupNode {
|
||
kind: "group"
|
||
id: string
|
||
gname: string | null
|
||
label: string
|
||
dir: Extract<Direction, "row" | "col">
|
||
gap: number
|
||
children: DiagramNode[]
|
||
fill?: string
|
||
stroke?: string
|
||
/** Section role; a themed panel for its children. */
|
||
role?: Role
|
||
/** Semantic zone name; the panel takes this hue's tint. */
|
||
group?: string
|
||
/** Share of the parent's leftover flow-axis space, like flex-grow. */
|
||
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
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
}
|
||
|
||
/** A container that packs its children into a fixed number of columns. */
|
||
export interface GridNode {
|
||
kind: "grid"
|
||
id: string
|
||
gname: string | null
|
||
label: string
|
||
cols: number
|
||
gap: number
|
||
children: DiagramNode[]
|
||
fill?: string
|
||
stroke?: string
|
||
style?: string
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
}
|
||
|
||
/**
|
||
* A swimlane pool: a sparse grid of (lane, column) cells.
|
||
*
|
||
* `lanes` names the role bands. Each child declares which cell it occupies, and empty
|
||
* cells stay empty — that is the whole point of a swimlane diagram, where a step belongs
|
||
* to exactly one role and the columns show the order things happen in.
|
||
*
|
||
* `phases` is an optional band of milestone labels above the columns.
|
||
*/
|
||
export interface PoolNode {
|
||
kind: "pool"
|
||
id: string
|
||
label: string
|
||
/** Role names, one per band. */
|
||
lanes: string[]
|
||
/** Milestone labels spanning the columns. Empty means no milestone band. */
|
||
phases: string[]
|
||
/** "horizontal": lanes stack downwards, flow left to right. "vertical": the mirror. */
|
||
orientation: "horizontal" | "vertical"
|
||
gap: number
|
||
children: DiagramNode[]
|
||
style?: string
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
}
|
||
|
||
/**
|
||
* A sequence diagram: participants across the top, lifelines hanging below them.
|
||
*
|
||
* Children are the participant heads, in left-to-right order. The messages are ordinary
|
||
* links whose `step` gives the vertical order — so the same `link` operation that draws
|
||
* an arrow in a flowchart draws a message here.
|
||
*
|
||
* The engine emits the lifelines as separate cells; they are not nodes, because nothing
|
||
* ever attaches to a lifeline directly.
|
||
*/
|
||
export interface SequenceNode {
|
||
kind: "sequence"
|
||
id: string
|
||
label: string
|
||
/** Horizontal distance between participant centres. */
|
||
gap: number
|
||
/** Vertical distance between consecutive messages. */
|
||
step: number
|
||
children: DiagramNode[]
|
||
style?: string
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
}
|
||
|
||
/**
|
||
* A mind map or org chart: a root with branches radiating from it.
|
||
*
|
||
* Children are a FLAT list of every node in the map. The hierarchy comes from the links —
|
||
* an arrow from A to B means B is a branch of A — not from nesting.
|
||
*
|
||
* That is not a shortcut, it is the only thing that works: a branch of a mind map is a
|
||
* labelled box that also has sub-branches, and a box cannot hold children. Reading the
|
||
* hierarchy from the arrows also matches what the diagram means, since in a mind map or an
|
||
* org chart the arrows ARE the structure.
|
||
*
|
||
* `spread: "radial"` fans branches out on both sides of the centre, which is what a mind
|
||
* map wants. `spread: "down"` puts every branch below the centre, which is what an org
|
||
* chart wants: a reporting line only reads correctly downwards.
|
||
*/
|
||
export interface RadialNode {
|
||
kind: "radial"
|
||
id: string
|
||
label: string
|
||
spread: "radial" | "down"
|
||
/** Distance from a parent's edge to its children. */
|
||
gap: number
|
||
children: DiagramNode[]
|
||
style?: string
|
||
pinned?: boolean
|
||
rect?: Rect
|
||
}
|
||
|
||
export type ContainerNode =
|
||
| GroupNode
|
||
| GridNode
|
||
| PoolNode
|
||
| SequenceNode
|
||
| RadialNode
|
||
export type LeafNode = IconNode | BoxNode | TitleNode
|
||
export type DiagramNode = ContainerNode | LeafNode
|
||
|
||
export interface Rect {
|
||
x: number
|
||
y: number
|
||
w: number
|
||
h: number
|
||
}
|
||
|
||
/** An arrow. Routing is the engine's business; the model only says what connects to what. */
|
||
export interface LinkSpec {
|
||
/** Cell id, so an existing edge can be addressed by later operations. */
|
||
id?: string
|
||
source: string
|
||
target: string
|
||
label?: string
|
||
/** Dashed line — replication, sync, policy, lineage. */
|
||
dashed?: boolean
|
||
/**
|
||
* A bold arrow: the relationship IS the point — a transformation, the main flow.
|
||
* Thick and coloured, a visual element rather than a hairline connector.
|
||
*/
|
||
bold?: boolean
|
||
/**
|
||
* Arrowhead at the target / at the source. draw.io endArrow/startArrow tokens:
|
||
* block, open, diamond, diamondThin, oval, cross, ERone, ERmany, ERoneToMany,
|
||
* ERzeroToMany, ERzeroToOne, none… Unset means the default (classic at the target,
|
||
* nothing at the source). `headFill`/`tailFill` distinguish UML composition
|
||
* (filled diamond) from aggregation (hollow) — conventions where fill IS meaning.
|
||
*/
|
||
head?: string
|
||
tail?: string
|
||
headFill?: boolean
|
||
tailFill?: boolean
|
||
/** Step number, rendered as an "N. " prefix on the label. */
|
||
step?: number
|
||
/** Verbatim style, when recovered from XML. */
|
||
style?: string
|
||
}
|
||
|
||
/** A whole diagram page: the node forest plus its arrows. */
|
||
export interface DiagramTree {
|
||
/** Top-level nodes, in layout order. */
|
||
roots: DiagramNode[]
|
||
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
|
||
* a re-layout never destroys work the engine does not understand.
|
||
*/
|
||
foreign: ForeignCell[]
|
||
}
|
||
|
||
/** A cell carried through the round-trip without interpretation. */
|
||
export interface ForeignCell {
|
||
id: string
|
||
/** The cell's own serialised XML, verbatim. */
|
||
xml: string
|
||
/** Parent id at parse time, so it can be re-attached. */
|
||
parent: string
|
||
}
|
||
|
||
export function isContainer(n: DiagramNode): n is ContainerNode {
|
||
return (
|
||
n.kind === "group" ||
|
||
n.kind === "grid" ||
|
||
n.kind === "pool" ||
|
||
n.kind === "sequence" ||
|
||
n.kind === "radial"
|
||
)
|
||
}
|
||
|
||
export function isLeaf(n: DiagramNode): n is LeafNode {
|
||
return !isContainer(n)
|
||
}
|
||
|
||
/** Depth-first walk over a node and its descendants. */
|
||
export function* walk(n: DiagramNode): Generator<DiagramNode> {
|
||
yield n
|
||
if (isContainer(n)) for (const c of n.children) yield* walk(c)
|
||
}
|
||
|
||
/** Every node in a tree, in document order. */
|
||
export function* walkTree(t: DiagramTree): Generator<DiagramNode> {
|
||
for (const r of t.roots) yield* walk(r)
|
||
}
|
||
|
||
/** Find a node by id, or null. */
|
||
export function findNode(t: DiagramTree, id: string): DiagramNode | null {
|
||
for (const n of walkTree(t)) if (n.id === id) return n
|
||
return null
|
||
}
|
||
|
||
/** The container holding `id`, or null when it is a root or absent. */
|
||
export function findParent(t: DiagramTree, id: string): ContainerNode | null {
|
||
for (const n of walkTree(t)) {
|
||
if (!isContainer(n)) continue
|
||
if (n.children.some((c) => c.id === id)) return n
|
||
}
|
||
return null
|
||
}
|