mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): flex knobs (grow/align/pad) + inline rich-text labels
The expressiveness gap between engine output and hand-written XML came down to two missing capabilities, both generic: - Block layout inside a box: nested containers already existed, but there was no way to split space by weight, pin a child to an edge, or tighten padding. Added grow (flex-grow over the parent's leftover flow-axis space, TeX's glue), align (start/center/end/stretch on the cross axis) and pad (per-group interior padding). All three round-trip via dai_grow/dai_align/ dai_pad markers. - Inline rich text: labels already render HTML (html=1 on every style, esc() entities decode back), but the measure pass counted markup as text. The visibleText() strip makes autoBoxSize measure what draw.io draws: <br> is a line, other inline tags are invisible. Graphviz (HTML-like table labels), D2 (grid containers + markdown-in-shape) and TeX (box+glue) converged on exactly this design: nested boxes for block structure, proportional glue, a small inline set for text — never full HTML. Prompts teach the composition with a comparison-card recipe; verified by rebuilding the CoT poster end to end in the real editor.
This commit is contained in:
@@ -43,6 +43,23 @@ import { isContainer } from "./types"
|
||||
export const ICON_SIZE = 48
|
||||
/** Interior padding of a container. */
|
||||
const PAD = 24
|
||||
|
||||
/** A group's interior padding: its own `pad` when declared, the default otherwise. */
|
||||
function padOf(n: ContainerNode): number {
|
||||
return n.kind === "group" && n.pad != null ? Math.max(0, n.pad) : PAD
|
||||
}
|
||||
|
||||
/** The flex-grow weight a node declared, 0 when none. */
|
||||
function growOf(n: DiagramNode): number {
|
||||
const g = (n.kind === "box" || n.kind === "group") && n.grow
|
||||
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"
|
||||
}
|
||||
/** 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. */
|
||||
const HEADER = 36
|
||||
@@ -172,6 +189,20 @@ export interface Placed {
|
||||
*/
|
||||
export type LayoutLinks = { source: string; target: string; step?: number }[]
|
||||
|
||||
/**
|
||||
* Reduce a label to the text draw.io will actually lay out.
|
||||
*
|
||||
* Labels may carry inline HTML (every style has `html=1`): a <br> is a line break, any
|
||||
* other tag is invisible markup around visible text. Measuring the raw string counted
|
||||
* `<font color="#B85450">` as thirty characters of text, making rich boxes twice as
|
||||
* wide as their content.
|
||||
*/
|
||||
function visibleText(label: string): string {
|
||||
return String(label ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/?(?:b|i|u|s|sub|sup|font|span|div)(?:\s[^<>]*)?>/gi, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Intrinsic size of a text box: widest wrapped line by line count.
|
||||
*
|
||||
@@ -184,7 +215,7 @@ export function autoBoxSize(
|
||||
): { w: number; h: number } {
|
||||
const r = roleMetrics(role)
|
||||
const maxW = Math.round(260 * Math.max(1, r.charScale))
|
||||
const explicit = String(label ?? "").split("\n")
|
||||
const explicit = visibleText(label).split("\n")
|
||||
const longest = Math.max(1, ...explicit.map((l) => l.length))
|
||||
const w = Math.min(
|
||||
maxW,
|
||||
@@ -531,6 +562,7 @@ function measure(
|
||||
}
|
||||
|
||||
// group: row or col
|
||||
const pad = padOf(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,
|
||||
@@ -541,13 +573,13 @@ function measure(
|
||||
for (const k of kids)
|
||||
if (k.node.kind === "group") k.rect.h = Math.max(k.rect.h, tallest)
|
||||
const w =
|
||||
PAD * 2 +
|
||||
pad * 2 +
|
||||
kids.reduce((s, k) => s + k.rect.w, 0) +
|
||||
gap * Math.max(0, kids.length - 1)
|
||||
const h = head + PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.h))
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -556,15 +588,15 @@ 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)
|
||||
const w = PAD * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
|
||||
const w = pad * 2 + Math.max(0, ...kids.map((k) => k.rect.w))
|
||||
const h =
|
||||
head +
|
||||
PAD * 2 +
|
||||
pad * 2 +
|
||||
kids.reduce((s, k) => s + k.rect.h, 0) +
|
||||
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.max(w, titleFloor(n.label, pad)), h },
|
||||
children: kids,
|
||||
}
|
||||
}
|
||||
@@ -585,10 +617,11 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
if (!isContainer(n)) return
|
||||
|
||||
const head = headerFor(n)
|
||||
const innerX = p.rect.x + PAD
|
||||
const innerTop = p.rect.y + head + PAD
|
||||
const innerW = p.rect.w - PAD * 2
|
||||
const innerH = p.rect.h - head - PAD * 2
|
||||
const pad = n.kind === "group" ? padOf(n) : PAD
|
||||
const innerX = p.rect.x + pad
|
||||
const innerTop = p.rect.y + head + pad
|
||||
const innerW = p.rect.w - pad * 2
|
||||
const innerH = p.rect.h - head - pad * 2
|
||||
const kids = p.children
|
||||
|
||||
if (n.kind === "grid") {
|
||||
@@ -654,9 +687,27 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
const content = sizes.reduce((s, v) => s + v, 0)
|
||||
const extent = alongRow ? innerW : innerH
|
||||
const k = kids.length
|
||||
const slack = Math.max(0, extent - content - n.gap * (k - 1))
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
const gap = k > 1 ? n.gap + Math.min(n.gap, slack / (k - 1)) : n.gap
|
||||
const span = content + gap * Math.max(0, k - 1)
|
||||
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 : innerTop) + Math.max(0, (extent - span) / 2)
|
||||
|
||||
for (const kid of kids) {
|
||||
@@ -664,15 +715,23 @@ function place(p: Placed, x: number, y: number, links: LayoutLinks): void {
|
||||
// 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 stretches =
|
||||
kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch
|
||||
a === "stretch" ||
|
||||
(kn.kind === "box" && kn.role && roleMetrics(kn.role).stretch)
|
||||
// Cross-axis position: centred unless the child asked for an edge.
|
||||
const cross = (room: number, size: number): number => {
|
||||
if (a === "start") return 0
|
||||
if (a === "end") return Math.max(0, room - size)
|
||||
return (room - size) / 2
|
||||
}
|
||||
if (alongRow) {
|
||||
if (stretches) kid.rect.h = innerH
|
||||
place(kid, cur, innerTop + (innerH - kid.rect.h) / 2, links)
|
||||
place(kid, cur, innerTop + cross(innerH, kid.rect.h), links)
|
||||
cur += kid.rect.w + gap
|
||||
} else {
|
||||
if (stretches) kid.rect.w = innerW
|
||||
place(kid, innerX + (innerW - kid.rect.w) / 2, cur, links)
|
||||
place(kid, innerX + cross(innerW, kid.rect.w), cur, links)
|
||||
cur += kid.rect.h + gap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ export const MARKER = {
|
||||
role: "dai_role",
|
||||
/** The node's semantic zone, whose hue ramp colours it. */
|
||||
group: "dai_group",
|
||||
/** Share of the parent's leftover flow-axis space — flex-grow. */
|
||||
grow: "dai_grow",
|
||||
/** Cross-axis position within the parent: "start" | "center" | "end". */
|
||||
align: "dai_align",
|
||||
/** A container's interior padding, px. */
|
||||
pad: "dai_pad",
|
||||
/**
|
||||
* 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
|
||||
@@ -382,6 +388,28 @@ export function stampGroup(style: string, group: string): string {
|
||||
return append(style, MARKER.group, encodeURIComponent(group))
|
||||
}
|
||||
|
||||
type FlexAlign = "start" | "center" | "end" | "stretch"
|
||||
|
||||
/** 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 },
|
||||
): 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.pad != null) s = append(s, MARKER.pad, Math.round(opts.pad))
|
||||
return s
|
||||
}
|
||||
|
||||
/** Read the align marker back. Anything unrecognised means the default (center). */
|
||||
export function readAlign(style: string): Exclude<FlexAlign, "center"> | null {
|
||||
const v = readMarker(style, MARKER.align)
|
||||
return v === "start" || v === "end" || v === "stretch" ? v : null
|
||||
}
|
||||
|
||||
/** Strip every `dai_*` marker — for exporting a clean file, or comparing styles. */
|
||||
export function stripMarkers(style: string): string {
|
||||
return style
|
||||
|
||||
@@ -98,6 +98,18 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
.describe(
|
||||
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
|
||||
),
|
||||
grow: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Flex-grow weight: this box takes that share of the parent's leftover space along its stacking axis. Omit for natural size",
|
||||
),
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Cross-axis position in the parent: start/end pin to an edge, stretch fills the axis (a divider or highlight bar spanning its card). Default center",
|
||||
),
|
||||
lane: z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -148,6 +160,24 @@ export const OperationSchema = z.discriminatedUnion("op", [
|
||||
"Group stencil name, e.g. 'group_vpc'; omit for a plain frame",
|
||||
),
|
||||
gap: z.number().optional(),
|
||||
pad: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Interior padding px (default 24). Small values make tight cards; nest containers for internal structure",
|
||||
),
|
||||
grow: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Flex-grow weight: this container takes that share of the parent's leftover space. E.g. two columns with grow 2 and 1 split the width 2:1",
|
||||
),
|
||||
align: z
|
||||
.enum(["start", "center", "end", "stretch"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Cross-axis position in the parent: start/end pin to an edge, stretch fills. Default center",
|
||||
),
|
||||
after: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
@@ -414,6 +444,10 @@ 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 }
|
||||
: {}),
|
||||
...cellOf(op),
|
||||
}
|
||||
else if (op.op === "add_container")
|
||||
@@ -427,6 +461,11 @@ export function applyOperations(
|
||||
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) } : {}),
|
||||
}
|
||||
else if (op.op === "add_grid")
|
||||
node = {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isPinned,
|
||||
MARKER,
|
||||
type NodeKind,
|
||||
readAlign,
|
||||
readCell,
|
||||
readDir,
|
||||
readIntMarker,
|
||||
@@ -360,6 +361,19 @@ function zoneOf(style: string): string | undefined {
|
||||
return v ? decodeURIComponent(v) : undefined
|
||||
}
|
||||
|
||||
/** The flex fields stamped on a cell, as sparse properties ready to spread. */
|
||||
function flexOf(style: string): {
|
||||
grow?: number
|
||||
align?: "start" | "end" | "stretch"
|
||||
} {
|
||||
const grow = readIntMarker(style, MARKER.grow)
|
||||
const align = readAlign(style)
|
||||
return {
|
||||
...(grow !== null && grow > 0 ? { grow } : {}),
|
||||
...(align ? { align } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function boxShape(style: string): BoxShape | undefined {
|
||||
const shape = styleValue(style, "shape")
|
||||
if (shape === "parallelogram") return "data"
|
||||
@@ -1070,6 +1084,7 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
shape: boxShape(c.style),
|
||||
role: roleOf(c.style),
|
||||
group: zoneOf(c.style),
|
||||
...flexOf(c.style),
|
||||
// A lifeline's style is chrome the renderer rebuilds, so keeping it verbatim
|
||||
// would re-emit a lifeline that no longer matches the new message count.
|
||||
style: lifeline ? undefined : c.style,
|
||||
@@ -1215,12 +1230,15 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
|
||||
}
|
||||
return node
|
||||
}
|
||||
const markedPad = readIntMarker(c.style, MARKER.pad)
|
||||
const node: GroupNode = {
|
||||
kind: "group",
|
||||
...common,
|
||||
dir: dir === "col" ? "col" : "row",
|
||||
role: roleOf(c.style),
|
||||
group: zoneOf(c.style),
|
||||
...flexOf(c.style),
|
||||
...(markedPad !== null ? { pad: markedPad } : {}),
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
isInvisible,
|
||||
stampCell,
|
||||
stampContainer,
|
||||
stampFlex,
|
||||
stampGroup,
|
||||
stampLane,
|
||||
stampLeaf,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
stampSequence,
|
||||
} from "./markers"
|
||||
import { type RoutedEdge, routeEdges } from "./route"
|
||||
import { hueOf, NEUTRAL, type Role, themedStyle } from "./theme"
|
||||
import { hueOf, NEUTRAL, themedStyle } from "./theme"
|
||||
import {
|
||||
type BoxShape,
|
||||
type DiagramNode,
|
||||
@@ -58,6 +59,14 @@ export function esc(s: string): string {
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
// NOTE ON RICH TEXT: a label may carry inline HTML — <b>, <i>, <font color>, <br>,
|
||||
// <span> — and needs no special handling here. `esc()` writes it into the value
|
||||
// attribute as entities, the XML parser decodes them back, and because every style
|
||||
// carries `html=1` draw.io renders the tags. That is exactly how hand-written rich
|
||||
// labels have always worked; the editor sanitises HTML labels itself. The one place
|
||||
// tags DO need handling is the measure pass (layout.ts autoBoxSize), which must not
|
||||
// count markup as text.
|
||||
|
||||
/** Resolve a catalog name to a style. Injected so the engine does not own the catalog. */
|
||||
export type StyleResolver = (
|
||||
name: string,
|
||||
@@ -177,6 +186,7 @@ function styleFor(
|
||||
let stamped = stampLeaf(base, "box")
|
||||
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 })
|
||||
return n.cell ? stampCell(stamped, n.cell) : stamped
|
||||
}
|
||||
|
||||
@@ -218,6 +228,8 @@ function styleFor(
|
||||
}
|
||||
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 })
|
||||
return stampContainer(base, {
|
||||
kind: n.kind,
|
||||
dir: n.kind === "grid" ? "grid" : n.dir,
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface PoolCell {
|
||||
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"
|
||||
|
||||
/**
|
||||
* The outline a flowchart box is drawn with.
|
||||
*
|
||||
@@ -73,6 +79,10 @@ export interface BoxNode {
|
||||
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
|
||||
/** Flowchart outline. Absent means a plain rectangle. */
|
||||
shape?: BoxShape
|
||||
style?: string
|
||||
@@ -109,6 +119,12 @@ export interface GroupNode {
|
||||
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
|
||||
/** Interior padding, px. Absent = the default (24). */
|
||||
pad?: number
|
||||
style?: string
|
||||
pinned?: boolean
|
||||
rect?: Rect
|
||||
|
||||
Reference in New Issue
Block a user