mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge
The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes, the declarative layer allowed six. Opening it, with the failure modes from design review handled: - shapes.ts: a ~20-entry curated catalog (full style fragment incl. the matching perimeter — required or edges connect to the bounding box; a text-scale factor verified in the real editor — the same sentence overflows a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style figures). Any other token passes through verbatim: draw.io degrades unknown shapes to rectangles safely (verified). Injection-capable tokens (;/=) are rejected outright. - Pass-through emits a WARNING with a nearest-catalog hint (bounded edit distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently rectangular node forever. set_shape/set_role/set_group operations make the fix possible without remove+re-add (which would drop links). - mergeStyle(): style fragments merge per-key (later wins, bare shape classes displace each other) instead of string concatenation. This is what makes shape and theme composable by rule — shape owns geometry keys, theme owns colour/type keys, and an overlap resolves by order instead of emitting contradictory duplicates. - dai_shape marker carries the declared token through the round trip: appearance-based reverse mapping cannot distinguish aliases (diamond vs decision) or a rotated queue from a cylinder. - dai_auto marker separates engine-measured size from user-fixed size: parsed boxes no longer freeze the first layout's numbers, so changing a label re-measures. Pinned nodes keep everything, as before. - draw_graph's closed shape enum opened to match (first instance of the schema-drift problem the review predicted). 14 new tests: merge ownership, injection rejection, near-match hints, alias-preserving round trip, re-measure on label change, mxgraph.* tokens staying boxes with role/group intact. 556 unit tests green; acceptance diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in the real editor.
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
} from "./layout"
|
||||
import {
|
||||
isInvisible,
|
||||
stampAuto,
|
||||
stampCell,
|
||||
stampContainer,
|
||||
stampFlex,
|
||||
@@ -34,11 +35,12 @@ import {
|
||||
stampRadial,
|
||||
stampRole,
|
||||
stampSequence,
|
||||
stampShape,
|
||||
} from "./markers"
|
||||
import { type RoutedEdge, routeEdges } from "./route"
|
||||
import { mergeStyle, resolveShape } from "./shapes"
|
||||
import { hueOf, NEUTRAL, themedStyle } from "./theme"
|
||||
import {
|
||||
type BoxShape,
|
||||
type DiagramNode,
|
||||
type DiagramTree,
|
||||
isContainer,
|
||||
@@ -88,27 +90,6 @@ const TITLE_STYLE =
|
||||
const EDGE_STYLE =
|
||||
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
|
||||
|
||||
/**
|
||||
* Flowchart outlines, as mxGraph draws them.
|
||||
*
|
||||
* All six are core mxGraph shapes, not stencils from a shape library, so they render
|
||||
* without the catalog and without any extra dependency. The notation is conventional: a
|
||||
* reader takes a diamond to mean a branch and a stadium to mean a start or end point, so
|
||||
* drawing every step as the same rectangle loses information the shape was carrying.
|
||||
*/
|
||||
const BOX_SHAPES: Record<BoxShape, string> = {
|
||||
box: "rounded=0;",
|
||||
round: "rounded=1;arcSize=12;",
|
||||
/** Decision — a diamond. */
|
||||
decision: "rhombus;",
|
||||
/** Start or end — a stadium. draw.io draws `rounded=1` at arcSize 50 as a full stadium. */
|
||||
terminator: "rounded=1;arcSize=50;",
|
||||
/** Input or output — a parallelogram. */
|
||||
data: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
|
||||
/** A document or report — a rectangle with a wavy bottom edge. */
|
||||
document: "shape=document;boundedLbl=1;",
|
||||
}
|
||||
|
||||
// ---- swimlane pool chrome ----
|
||||
|
||||
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
|
||||
@@ -172,21 +153,34 @@ function styleFor(
|
||||
}
|
||||
|
||||
if (n.kind === "box") {
|
||||
let base = n.style ?? FALLBACK_BOX
|
||||
if (!n.style) {
|
||||
// Order matters, later keys win in draw.io: outline, then the theme's
|
||||
// composition of role x hue, then explicit colours on top of everything.
|
||||
if (n.shape) base += BOX_SHAPES[n.shape]
|
||||
if (n.role || n.group)
|
||||
base += themedStyle(n.role ?? "body", hue(n.group), "leaf")
|
||||
if (n.fill) base += `fillColor=${n.fill};`
|
||||
if (n.stroke) base += `strokeColor=${n.stroke};`
|
||||
if (n.bold) base += "fontStyle=1;"
|
||||
let base: string
|
||||
if (n.style) {
|
||||
base = n.style
|
||||
} else {
|
||||
// Structured merge, ownership by fragment order: the fallback's neutral
|
||||
// look, the shape's geometry keys, the theme's colour/type keys, explicit
|
||||
// colours last. Each key ends up in the style exactly once — a theme that
|
||||
// says rounded=1 cannot leave a contradictory duplicate on a rhombus.
|
||||
const shape = n.shape ? resolveShape(n.shape) : null
|
||||
base = mergeStyle(
|
||||
FALLBACK_BOX,
|
||||
shape?.spec.style,
|
||||
n.role || n.group
|
||||
? themedStyle(n.role ?? "body", hue(n.group), "leaf")
|
||||
: undefined,
|
||||
n.fill ? `fillColor=${n.fill};` : undefined,
|
||||
n.stroke ? `strokeColor=${n.stroke};` : undefined,
|
||||
n.bold ? "fontStyle=1;" : undefined,
|
||||
)
|
||||
}
|
||||
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 })
|
||||
// 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)
|
||||
return n.cell ? stampCell(stamped, n.cell) : stamped
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user