mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
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.
215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
/**
|
|
* The engine's entry point: one call takes the current canvas XML plus a list of
|
|
* structural operations and returns new canvas XML.
|
|
*
|
|
* current XML → parse → apply operations → check names → layout → render → new XML
|
|
*
|
|
* The tree is not stored anywhere between calls. It is re-derived from the canvas every
|
|
* time, so a user's manual edits — moving a shape into a different frame, recolouring a
|
|
* box, adding an annotation — are simply part of the input to the next layout. There is
|
|
* no second copy of the state, and therefore nothing to reconcile.
|
|
*/
|
|
|
|
import { checkNames, resolveStyle } from "./catalog"
|
|
import {
|
|
type GraphEdge,
|
|
type GraphNode,
|
|
type GraphOptions,
|
|
graphToOperations,
|
|
} from "./graph"
|
|
import {
|
|
applyOperations,
|
|
collectNames,
|
|
type Operation,
|
|
outline,
|
|
} from "./operations"
|
|
import { parseDiagram } from "./parse"
|
|
import { renderDiagram } from "./render"
|
|
import { nearestShape, resolveShape } from "./shapes"
|
|
import { type DiagramTree, walkTree } from "./types"
|
|
|
|
export interface RestructureResult {
|
|
/** New canvas XML, or null when the request could not be carried out. */
|
|
xml: string | null
|
|
/** Compact outline of the resulting structure, for the model to read back. */
|
|
outline: string
|
|
/** Operations that could not be applied, and invented stencil names. */
|
|
errors: string[]
|
|
/** Non-fatal notes: pages skipped, structure that could not be read cleanly. */
|
|
warnings: string[]
|
|
}
|
|
|
|
export interface RestructureOptions {
|
|
/** Which page of a multi-page document to work on. */
|
|
pageIndex?: number
|
|
/** Diagram-wide icon glyph size. */
|
|
iconSize?: number
|
|
}
|
|
|
|
/**
|
|
* Apply structural operations to whatever is on the canvas.
|
|
*
|
|
* `currentXml` may be empty — that is how a diagram gets built from scratch.
|
|
*
|
|
* An invented stencil name is a hard error, not a silent fallback: draw.io renders an
|
|
* unknown `resIcon` as a blank square, so a diagram that "worked" would be quietly
|
|
* missing icons. The error carries suggestions from the catalog so the model can fix it
|
|
* in one more turn.
|
|
*/
|
|
export function restructureDiagram(
|
|
currentXml: string,
|
|
ops: Operation[],
|
|
opts: RestructureOptions = {},
|
|
): RestructureResult {
|
|
const warnings: string[] = []
|
|
|
|
let tree: DiagramTree
|
|
if (currentXml.trim()) {
|
|
const parsed = parseDiagram(currentXml, opts.pageIndex ?? 0)
|
|
tree = parsed.tree
|
|
warnings.push(...parsed.warnings)
|
|
} else {
|
|
tree = { roots: [], links: [], foreign: [] }
|
|
}
|
|
|
|
const applied = applyOperations(tree, ops)
|
|
const errors = [...applied.errors]
|
|
|
|
// Catch invented names before rendering, so the model gets a correctable error
|
|
// instead of a diagram with blank squares in it.
|
|
for (const bad of checkNames(collectNames(applied.tree))) {
|
|
const hint = bad.suggestions.length
|
|
? ` Did you mean: ${bad.suggestions.join(", ")}?`
|
|
: ""
|
|
errors.push(
|
|
`"${bad.name}" (node ${bad.id}) is not in the stencil catalog.${hint}`,
|
|
)
|
|
}
|
|
|
|
// Shape tokens: an injection-capable token is an error; an unknown-but-safe one
|
|
// passes through (draw.io degrades it to a rectangle) but gets a warning, so a typo
|
|
// is a one-turn fix instead of a silently rectangular "cyclinder" forever.
|
|
for (const n of walkTree(applied.tree)) {
|
|
if (n.kind !== "box" || !n.shape || n.shape === "box") continue
|
|
const resolved = resolveShape(n.shape)
|
|
if (!resolved) {
|
|
errors.push(
|
|
`shape "${n.shape}" (node ${n.id}) contains characters that are not allowed in a shape token.`,
|
|
)
|
|
} else if (resolved.passthrough) {
|
|
const near = nearestShape(n.shape)
|
|
warnings.push(
|
|
`shape "${n.shape}" (node ${n.id}) is not in the engine's catalog — passed through to draw.io, which renders unknown shapes as rectangles.${near ? ` Did you mean "${near}"?` : ""}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0)
|
|
return { xml: null, outline: outline(applied.tree), errors, warnings }
|
|
|
|
const rendered = renderDiagram(applied.tree, {
|
|
resolveStyle,
|
|
iconSize: opts.iconSize,
|
|
})
|
|
if (rendered.danglingLinks.length)
|
|
warnings.push(
|
|
`Dropped edge(s) pointing at missing nodes: ${rendered.danglingLinks.join(", ")}.`,
|
|
)
|
|
|
|
return {
|
|
xml: rendered.xml,
|
|
outline: outline(applied.tree),
|
|
errors: [],
|
|
warnings,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
pageIndex = 0,
|
|
): { outline: string; warnings: string[]; needsAdoption: boolean } {
|
|
if (!currentXml.trim())
|
|
return { outline: "(empty canvas)", warnings: [], needsAdoption: false }
|
|
const { tree, warnings, needsAdoption } = parseDiagram(
|
|
currentXml,
|
|
pageIndex,
|
|
)
|
|
return { outline: outline(tree), warnings, needsAdoption }
|
|
}
|
|
|
|
export { CATALOG_SIZE, lookupStencil, searchStencils } from "./catalog"
|
|
export {
|
|
type GraphEdge,
|
|
type GraphNode,
|
|
type GraphOptions,
|
|
graphToOperations,
|
|
} from "./graph"
|
|
export { type Operation, OperationSchema } from "./operations"
|
|
export { parseDiagram } from "./parse"
|
|
export { renderDiagram } from "./render"
|
|
export type { DiagramNode, DiagramTree } from "./types"
|