mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Completes the tree → coordinates → XML direction, so the model can declare nesting and never write a coordinate or an mxCell again. layout.ts — measure bottom-up, place top-down, the same shape as flexbox. A container sums its children along the flow axis and adds padding, so "child spills out of its frame" and "siblings overlap" cannot happen by construction rather than being caught afterwards. Slack from sibling equalisation is shared between children instead of left as dead margin, capped at one gap so a stretched frame reads as spaced rather than sparse. render.ts — writes the mxCells, stamping container=1 and the dai_* markers so parse.ts can read the structure back. Edges carry no waypoints: draw.io's own router recomputes the route on every edit, so a user who moves a node never has to re-link an arrow. Cells the parser could not interpret are re-emitted verbatim, so a re-layout never deletes a user's annotations. Phantoms are gone (task #5). The reference project's layout-only wrapper emits no cell, which makes the round-trip lossy by construction — measured on its own build_vpc.mjs, a phantom erased a container's "col" direction for good. An unlabelled frame here emits a real cell with fillColor/strokeColor=none instead: invisible, but present in the XML and therefore recoverable. Two bugs the round-trip test caught, both real: - An icon's cell was being emitted at its measured slot size, which includes room for the label underneath. Parsing read that width back as the glyph size, so the icon grew on every round-trip. The cell is now the glyph square and the label renders outside it via verticalLabelPosition, as the reference does. - An Azure or GCP icon is an embedded base64 image whose style contains no name anywhere, so the catalog name was unrecoverable. Added a dai_name marker. Verified in a real browser (3 Playwright tests, not mocks): engine output renders in draw.io; dragging a shape into a frame makes draw.io rewrite its parent and the engine reads the new structure back; re-laying out from that structure PRESERVES the user's move instead of undoing it, and leaves untouched nodes alone; and the re-laid-out XML still renders. That last point is the whole design: there is no second copy of the state, so a manual edit is an input to the next layout rather than a conflict to reconcile. 304 unit tests + 3 e2e.
206 lines
7.9 KiB
TypeScript
206 lines
7.9 KiB
TypeScript
/**
|
|
* Style markers — how layout structure survives a round-trip through draw.io.
|
|
*
|
|
* The layout engine's tree carries information plain draw.io XML does not: which
|
|
* direction a container stacks its children, the gap between them, and whether the
|
|
* user has pinned a node's position. We encode that as extra `key=value` tokens in
|
|
* the cell's style string.
|
|
*
|
|
* Two behaviours this relies on, both verified in a real browser (Playwright drag
|
|
* against the embedded editor, reading the editor's own autosave payload):
|
|
*
|
|
* 1. draw.io PRESERVES style keys it does not understand. After a user drags a
|
|
* shape and the editor saves, `dai_kind=group;dai_dir=col;dai_gap=22;` came
|
|
* back byte-identical.
|
|
* 2. On a DUPLICATE key, the LAST value wins. A style ending in
|
|
* `container=0;pointerEvents=0;container=1;` behaved as a container: a shape
|
|
* dragged into it was reparented. So we can append a normalising token without
|
|
* first parsing out the old one.
|
|
*
|
|
* (2) matters because the AWS catalog is inconsistent: group_region, group_vpc,
|
|
* group_subnet, group_availability_zone, group_aws_cloud and group_on_premise ship
|
|
* WITHOUT container=1, while group_account, group_aws_cloud_alt, group_vpc2,
|
|
* group_security_group and group_corporate_data_center ship WITH it. Appending
|
|
* unconditionally normalises all of them.
|
|
*/
|
|
|
|
/** Marker keys. Namespaced with `dai_` so they cannot collide with mxGraph keys. */
|
|
export const MARKER = {
|
|
/** Node kind, so the parser does not have to re-guess it from the shape. */
|
|
kind: "dai_kind",
|
|
/** Child stacking direction of a container: "row" | "col" | "grid". */
|
|
dir: "dai_dir",
|
|
/** Gap between children, in px. */
|
|
gap: "dai_gap",
|
|
/** Column count, for grid containers. */
|
|
cols: "dai_cols",
|
|
/** Set by the user to freeze a node's position across re-layouts. */
|
|
pin: "dai_pin",
|
|
/**
|
|
* A catalog icon's name. Needed because an Azure or GCP icon's style is an embedded
|
|
* base64 image with no name anywhere in it, so the style alone cannot identify it.
|
|
*/
|
|
name: "dai_name",
|
|
} as const
|
|
|
|
export type NodeKind = "group" | "grid" | "icon" | "box" | "title"
|
|
export type Direction = "row" | "col" | "grid"
|
|
|
|
/**
|
|
* Tokens that make a shape behave as a container in draw.io: it accepts a shape
|
|
* dragged into it and reparents that shape (setting `parent` and switching the
|
|
* child's geometry to parent-relative).
|
|
*
|
|
* `pointerEvents=0` keeps clicks falling through to the children — without it the
|
|
* frame swallows them and the user cannot select what is inside. `collapsible=0`
|
|
* hides the fold arrow. `recursiveResize=0` stops children from being scaled when
|
|
* the frame is resized, which would fight the layout engine.
|
|
*/
|
|
const CONTAINER_TOKENS =
|
|
"container=1;pointerEvents=0;collapsible=0;recursiveResize=0;"
|
|
|
|
/**
|
|
* A container that groups children for layout but should not be visible.
|
|
*
|
|
* The reference project solves this with a "phantom": a wrapper that participates in
|
|
* layout and then emits NO cell, reparenting its children onto the nearest visible
|
|
* ancestor. That makes the round-trip lossy by construction — the wrapper's direction
|
|
* and grouping are simply absent from the XML, so re-deriving the tree cannot recover
|
|
* them. Measured on the reference project's own build_vpc.mjs: a phantom erased a
|
|
* container's "col" direction, leaving children in a 2-D arrangement that can only be
|
|
* read back as a grid.
|
|
*
|
|
* So we emit a real cell and make it invisible instead. One extra cell per wrapper,
|
|
* in exchange for structure that survives being read back.
|
|
*/
|
|
const INVISIBLE_TOKENS = "fillColor=none;strokeColor=none;"
|
|
|
|
/** Read a marker's raw value out of a style string. Last occurrence wins, as draw.io does. */
|
|
export function readMarker(style: string, key: string): string | null {
|
|
// Scan all matches and keep the last, mirroring draw.io's duplicate-key resolution.
|
|
const re = new RegExp(`(?:^|;)${key}=([^;]*)`, "g")
|
|
let last: string | null = null
|
|
let m = re.exec(style)
|
|
while (m !== null) {
|
|
last = m[1]
|
|
m = re.exec(style)
|
|
}
|
|
return last
|
|
}
|
|
|
|
export function readKind(style: string): NodeKind | null {
|
|
const v = readMarker(style, MARKER.kind)
|
|
if (
|
|
v === "group" ||
|
|
v === "grid" ||
|
|
v === "icon" ||
|
|
v === "box" ||
|
|
v === "title"
|
|
)
|
|
return v
|
|
return null
|
|
}
|
|
|
|
export function readDir(style: string): Direction | null {
|
|
const v = readMarker(style, MARKER.dir)
|
|
if (v === "row" || v === "col" || v === "grid") return v
|
|
return null
|
|
}
|
|
|
|
/** Read a positive integer marker (gap, cols). Returns null when absent or malformed. */
|
|
export function readIntMarker(style: string, key: string): number | null {
|
|
const v = readMarker(style, key)
|
|
if (v === null) return null
|
|
const n = Number(v)
|
|
return Number.isFinite(n) && n >= 0 ? Math.round(n) : null
|
|
}
|
|
|
|
/**
|
|
* Has the user pinned this node? Any value other than "0"/""/"false" counts as
|
|
* pinned, so a user typing `dai_pin=1` (or just `dai_pin=yes`) in draw.io's
|
|
* "Edit Style" dialog gets what they expect.
|
|
*/
|
|
export function isPinned(style: string): boolean {
|
|
const v = readMarker(style, MARKER.pin)
|
|
if (v === null) return false
|
|
const s = v.trim().toLowerCase()
|
|
return s !== "" && s !== "0" && s !== "false"
|
|
}
|
|
|
|
/** Append `key=value;`, ensuring the style ends with a separator first. */
|
|
function append(style: string, key: string, value: string | number): string {
|
|
const base = style.endsWith(";") || style === "" ? style : `${style};`
|
|
return `${base}${key}=${value};`
|
|
}
|
|
|
|
/**
|
|
* Stamp a container's style: make it a real draw.io container and record its
|
|
* layout parameters.
|
|
*
|
|
* Appends rather than rewrites. Duplicate keys are legal and the last one wins, so
|
|
* a catalog style that already says `container=1` is unharmed, and one that says
|
|
* nothing (or `container=0`) is corrected.
|
|
*/
|
|
export function stampContainer(
|
|
style: string,
|
|
opts: {
|
|
kind: "group" | "grid"
|
|
dir: Direction
|
|
gap: number
|
|
cols?: number
|
|
/** Layout-only wrapper: emit a real cell, but draw nothing. */
|
|
invisible?: boolean
|
|
},
|
|
): string {
|
|
let s = style.endsWith(";") || style === "" ? style : `${style};`
|
|
s += CONTAINER_TOKENS
|
|
if (opts.invisible) 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))
|
|
if (opts.kind === "grid" && opts.cols != null)
|
|
s = append(s, MARKER.cols, Math.max(1, Math.round(opts.cols)))
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* Is this an invisible layout wrapper? Both colours set to `none` and no group
|
|
* stencil — a visible frame always has a stroke or a stencil.
|
|
*/
|
|
export function isInvisible(style: string): boolean {
|
|
if (/grIcon=/.test(style)) return false
|
|
const fill = readMarker(style, "fillColor")
|
|
const stroke = readMarker(style, "strokeColor")
|
|
return fill === "none" && stroke === "none"
|
|
}
|
|
|
|
/**
|
|
* Stamp a leaf with its kind, so the parser need not infer it.
|
|
*
|
|
* For an icon, also record the catalog name: an Azure or GCP icon's style is an embedded
|
|
* base64 image with no name in it, so the style alone cannot identify which icon it is.
|
|
*/
|
|
export function stampLeaf(
|
|
style: string,
|
|
kind: "icon" | "box" | "title",
|
|
opts: { name?: string } = {},
|
|
): string {
|
|
const s = append(style, MARKER.kind, kind)
|
|
return opts.name ? append(s, MARKER.name, opts.name) : s
|
|
}
|
|
|
|
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
|
export function stripMarkers(style: string): string {
|
|
return style
|
|
.split(";")
|
|
.filter((tok) => tok !== "" && !tok.startsWith("dai_"))
|
|
.join(";")
|
|
.concat(";")
|
|
.replace(/^;$/, "")
|
|
}
|
|
|
|
/** Does this style carry any engine marker? Used to tell engine output from imported files. */
|
|
export function hasMarkers(style: string): boolean {
|
|
return /(?:^|;)dai_[a-z]+=/.test(style)
|
|
}
|