feat(mcp-server): load .drawio.svg, change summary, XML reference by topic, preview options, truncation message (#982)

* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram

* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram

* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic

* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page

Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.

* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts

create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.

* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message

* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation

- Truncation check: a closing tag such as </mxCell/> that the auto-fix
  repairs is no cut, and input without any cell keeps the validator's
  message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
  sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no &nbsp; or merged words from <br>),
  a fast path for equal XML, and "the order of the cells changed" as
  the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
This commit is contained in:
Dayuan Jiang
2026-10-11 20:40:05 +09:00
committed by GitHub
parent cb066a4825
commit 8ab837679e
19 changed files with 949 additions and 146 deletions
+5
View File
@@ -52,6 +52,8 @@ Containers and swimlanes: children use the container id as parent and coordinate
${indent(SWIMLANE_EXAMPLE)}
Large diagrams: if the diagram will not fit one call, send the first cells with create_new_diagram and add the rest with edit_diagram add operations (one per cell, same id rules). A call rejected because the XML ends inside an unfinished cell drew nothing: send all of its cells again, in smaller parts if needed.
## Layout
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
@@ -92,6 +94,9 @@ ${indent(STYLE_CLASS_EXAMPLE)}
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
- Animated connectors: add flowAnimation=1 to the edge style.
- Edge options: dashed=1; strokeWidth=2; edgeStyle=elbowEdgeStyle;elbow=vertical (or horizontal); jumpStyle=arc;jumpSize=10 where edges cross.
For tables, layers or groups, call get_drawing_guide with topic=tables, layers or groups.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
+24
View File
@@ -0,0 +1,24 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
/** draw.io's name for a locale: lowercase, "zh-tw" for Traditional and "zh" for Simplified Chinese */
export function toDrawioLang(locale: string): string {
const lang = locale.toLowerCase()
if (lang === "zh-hant") return "zh-tw"
return lang === "zh-hans" ? "zh" : lang
}
+113
View File
@@ -142,3 +142,116 @@ export function markPageSeen(
? liveXml
: lastSeenXml
}
const CELL_TAGS = new Set(["mxCell", "UserObject", "object"])
/** The cells of a page by id: the direct children of its <root>. */
function pageCells(page: Element): Map<string, Element> {
const cells = new Map<string, Element>()
for (const el of Array.from(page.querySelector("root")?.children ?? [])) {
const id = el.getAttribute("id")
if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el)
}
return cells
}
/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */
function describeCell(id: string, cell: Element): string {
// getAttributeNode gives the decoded value (linkedom's getAttribute
// escapes it again); an HTML label then still holds tags and entities
const label = (
(cell.getAttributeNode("value") ?? cell.getAttributeNode("label"))
?.value ?? ""
)
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/\s+/g, " ")
.trim()
if (!label) return id
return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")`
}
/** At most 20 entries, then "and N more". */
function listCells(cells: string[]): string {
const shown = cells.slice(0, 20).join(", ")
return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown
}
/**
* One paragraph naming what the user changed between the XML the model last
* saw and the live store, for stale rejections and get_diagram. Pages are
* paired by index (draw.io regenerates diagram ids on re-serialisation) and
* cells by id. Returns "" when there is nothing to compare or the two sides
* have the same content.
*/
export function describeChanges(lastSeenXml: string, liveXml: string): string {
if (!lastSeenXml || lastSeenXml === liveXml) return ""
const parse = (xml: string) => {
const normalized = normalizeToMxfile(xml)
return normalized ? parseMxfile(normalized) : null
}
const before = parse(lastSeenXml)
const live = parse(liveXml)
if (!before || !live) return ""
// Same rule as checkEditGate: a bare <mxGraphModel> carries no page name
const includeNames =
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
if (
contentFingerprint(lastSeenXml, includeNames) ===
contentFingerprint(liveXml, includeNames)
)
return ""
const pagesBefore = Array.from(before.querySelectorAll("diagram"))
const pagesLive = Array.from(live.querySelectorAll("diagram"))
const pageCount = Math.max(pagesBefore.length, pagesLive.length)
const clauses: string[] = []
const renames: string[] = []
for (let i = 0; i < pageCount; i++) {
const pageBefore = pagesBefore[i]
const pageLive = pagesLive[i]
if (!pageBefore || !pageLive) {
clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`)
continue
}
const cellsBefore = pageCells(pageBefore)
const cellsLive = pageCells(pageLive)
const modified: string[] = []
const added: string[] = []
const removed: string[] = []
for (const [id, cell] of cellsLive) {
const old = cellsBefore.get(id)
if (!old) added.push(describeCell(id, cell))
else if (canonicalizeElement(old) !== canonicalizeElement(cell))
modified.push(describeCell(id, cell))
}
for (const [id, cell] of cellsBefore) {
if (!cellsLive.has(id)) removed.push(describeCell(id, cell))
}
// Name the page only when the document has several
const where = pageCount > 1 ? `page ${i + 1} ` : ""
if (modified.length)
clauses.push(`${where}modified ${listCells(modified)}`)
if (added.length) clauses.push(`${where}added ${listCells(added)}`)
if (removed.length)
clauses.push(`${where}removed ${listCells(removed)}`)
if (includeNames) {
const name = pageLive.getAttribute("name") ?? ""
if (name !== (pageBefore.getAttribute("name") ?? ""))
renames.push(`Page ${i + 1} renamed to "${name}".`)
}
}
// The fingerprint also covers the cell order (z-order) and anything in
// <root> that is not a cell
if (!clauses.length && !renames.length)
clauses.push("the order of the cells changed")
const intro = "Since you last saw it, the user changed the diagram"
const body = clauses.length
? `${intro}: ${clauses.join("; ")}.`
: `${intro}.`
return [body, ...renames].join(" ")
}
+29
View File
@@ -44,6 +44,7 @@ function readBody(
})
}
import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts"
import { contentFingerprint } from "./edit-gate.ts"
import {
addHistory,
@@ -813,6 +814,33 @@ function loadPreviewTemplate(): string {
const scriptJson = (value: string) =>
JSON.stringify(value).replace(/</g, "\\u003c")
/**
* The configurable tail of the draw.io iframe query: dark mode, and the
* language and theme when the host config fixes them (DRAWIO_DARK,
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
* draw.io hides its Extras > Language / Theme submenu once they are given.
*/
export function drawioEmbedParams(
env: NodeJS.ProcessEnv = process.env,
): string {
const params = new URLSearchParams()
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
if (["1", "true", "dark"].includes(dark)) {
params.set("dark", "1")
} else if (["0", "false", "light"].includes(dark)) {
params.set("dark", "0")
} else {
// draw.io takes ui=dark as dark mode only when no dark parameter is
// present, and this page always sends one
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
}
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
if (isDrawioTheme(ui)) params.set("ui", ui)
return params.toString()
}
function getHtmlPage(sessionId: string): string {
return loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
@@ -822,6 +850,7 @@ function getHtmlPage(sessionId: string): string {
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
}
+56 -13
View File
@@ -32,7 +32,12 @@ import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
import { editDiagram, targetPageXml } from "./edit-diagram.ts"
import { checkEditGate, contentFingerprint, markPageSeen } from "./edit-gate.ts"
import {
checkEditGate,
contentFingerprint,
describeChanges,
markPageSeen,
} from "./edit-gate.ts"
import { createExclusive } from "./exclusive.ts"
import {
addHistory,
@@ -64,6 +69,7 @@ import {
prepareNewDiagram,
reservedIdError,
takeStyleDefinitions,
truncatedCellError,
} from "./new-diagram.ts"
import {
addPageToDoc,
@@ -84,6 +90,7 @@ import {
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
import { XML_REFERENCE } from "./xml-reference.ts"
import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
@@ -163,10 +170,10 @@ After drawing a complex diagram, call screenshot_diagram once to see it, and fix
Tools:
- create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full <mxfile> for several pages.
- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry.
- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts.
- get_diagram: read the current XML, including the user's manual edits.
- screenshot_diagram: see the rendered diagram as an image.
- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths.
- load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths.
- list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=<id>.
- list_pages, add_page, rename_page, delete_page: manage pages (tabs).
- restore_version: undo; go back to an earlier version of the diagram.`
@@ -272,11 +279,26 @@ server.registerTool(
title: "Get drawing guide",
description:
"Return the drawing guide: XML format, layout, edge routing, style and editing rules. " +
"start_session already returns it; call this only if the guide is no longer in your context.",
inputSchema: {},
"start_session already returns it; call this only if the guide is no longer in your context. " +
"With topic, returns a short XML reference for tables, layers or groups.",
inputSchema: {
topic: z
.enum(["tables", "layers", "groups"])
.optional()
.describe(
"Return only the XML reference for this topic instead of the whole guide.",
),
},
annotations: { readOnlyHint: true, openWorldHint: false },
},
async () => ({ content: [{ type: "text", text: guideText() }] }),
async ({ topic }) => ({
content: [
{
type: "text",
text: topic ? XML_REFERENCE[topic] : guideText(),
},
],
}),
)
// Tool: get_shape_library
@@ -610,12 +632,13 @@ To clear the canvas to one blank page, send only the two root cells <mxCell id="
registerWriteTool(
"load_diagram",
{
title: "Load .drawio file",
title: "Load diagram file",
description:
"Load a .drawio diagram into the current session, REPLACING the entire diagram (all pages). " +
"Provide ONE of two mutually exclusive sources: 'path' (the server reads the file from disk — you do NOT need to read the file yourself or pass its XML through create_new_diagram) " +
"or 'xml' (the raw file content you already have — from another tool, a repository read, or an API response — so no temporary file needs to be written first). " +
"Both accept plain XML and draw.io's compressed save format.\n\n" +
"Both accept plain XML and draw.io's compressed save format. " +
".drawio.svg (Editable SVG, the format export_diagram writes) is accepted too; .png files (also draw.io's editable PNG) and plain .svg files cannot be loaded.\n\n" +
"After loading from 'path' (or from compressed 'xml'), call get_diagram before edit_diagram — you haven't seen the file's cell IDs yet. " +
"Plain-XML 'xml' content you supplied yourself is already known and can be edited immediately.",
inputSchema: {
@@ -623,7 +646,7 @@ registerWriteTool(
.string()
.optional()
.describe(
"Path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project. Mutually exclusive with 'xml'.",
"Path to the .drawio or .drawio.svg file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project. Mutually exclusive with 'xml'.",
),
xml: z
.string()
@@ -897,6 +920,8 @@ registerWriteTool(
// seen it and can retry without a get_diagram round-trip,
// unless other pages changed too.
const liveXml = browserState?.xml || currentSession.xml
// What the model saw, before markPageSeen records the live state
const before = currentSession.lastSeenXml
currentSession.lastSeenXml = markPageSeen(
currentSession.lastSeenXml,
liveXml,
@@ -906,6 +931,11 @@ registerWriteTool(
gate.reason === "stale"
? "The diagram changed in the browser since you last saw it (e.g. manual user edits). No changes were made."
: "You have not seen this diagram yet, so no changes were made."
// Per-cell summary of the user's changes, when they can be compared
const summary =
gate.reason === "stale"
? describeChanges(before, liveXml)
: ""
const next =
currentSession.lastSeenXml === liveXml
? "Build your operations on this XML and retry."
@@ -914,7 +944,7 @@ registerWriteTool(
content: [
{
type: "text",
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\n${next}`,
text: `Error: ${reason}\n\n${summary ? `${summary}\n\n` : ""}Current XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\n${next}`,
},
],
isError: true,
@@ -1070,6 +1100,12 @@ server.registerTool(
// store value — the gate's fast path is plain string equality
// against the store, with a structural comparison as fallback.
const liveXml = browserState?.xml || session.xml
// What the user changed since the model last looked, computed
// before lastSeenXml is overwritten below
const changes = session.lastSeenXml
? describeChanges(session.lastSeenXml, liveXml)
: ""
const changesNote = changes ? `\n\nNote: ${changes}` : ""
const doc = parseMxfile(session.xml)
const pages = doc ? listPagesFromDoc(doc) : []
const pageList = pages.length
@@ -1083,7 +1119,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}`,
text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}${changesNote}`,
},
],
}
@@ -1120,7 +1156,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Page ${projection.index} ("${projection.name}"):\n\n${foldCells(projection.xml)}\n\n${pageList}${staleNote}${otherPagesNote}`,
text: `Page ${projection.index} ("${projection.name}"):\n\n${foldCells(projection.xml)}\n\n${pageList}${staleNote}${changesNote}${otherPagesNote}`,
},
],
}
@@ -1344,7 +1380,7 @@ server.registerTool(
{
title: "Export diagram",
description:
"Export the current diagram to a file. Supports .drawio (XML), .png, .svg, and .drawio.svg (an SVG with the diagram embedded, which draw.io can open and edit again). " +
"Export the current diagram to a file. Supports .drawio (XML), .png, .svg, and .drawio.svg (an SVG with the diagram embedded, which draw.io and load_diagram can open again). " +
"The format is auto-detected from the file extension, or can be specified explicitly.\n\n" +
"Multi-page behaviour:\n" +
"- .drawio with NO page selector: writes the full <mxfile> (all pages).\n" +
@@ -1821,6 +1857,13 @@ registerWriteTool(
isError: true,
}
}
const truncated = startXml && truncatedCellError(startXml)
if (truncated) {
return {
content: [{ type: "text", text: `Error: ${truncated}` }],
isError: true,
}
}
let cleanXml: string | undefined =
startXml && wrapCellsInModel(startXml)
if (cleanXml) {
+35 -3
View File
@@ -43,11 +43,27 @@ export function decompressPageContent(compressed: string): string | null {
}
}
/**
* Read the diagram XML a .drawio.svg (Editable SVG) carries in its root
* element's content attribute. Returns undefined when the text is not an
* SVG, and "" when the SVG has no embedded diagram.
*
* Uses getAttributeNode().value rather than getAttribute(): linkedom's
* getAttribute re-escapes <, > and & in XML documents, which would turn
* the embedded "<mxfile" into "&lt;mxfile".
*/
export function extractSvgEmbeddedXml(text: string): string | undefined {
const doc = new DOMParser().parseFromString(text, "text/xml")
const root = doc.documentElement
if (root?.tagName !== "svg") return undefined
return (root.getAttributeNode("content")?.value ?? "").trim()
}
/**
* Parse the content of a .drawio file into the canonical session shape:
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
* any compressed pages.
* bare <mxGraphModel> (wrapped into a one-page mxfile), a .drawio.svg with
* the diagram embedded, and decompresses any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
let trimmed = content.trim()
@@ -62,9 +78,25 @@ export function parseDrawioFileContent(content: string): LoadResult {
trimmed = normalized
}
if (!isMxFile(trimmed)) {
const embedded = extractSvgEmbeddedXml(trimmed)
if (embedded === undefined) {
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element, or a .drawio.svg with the diagram embedded.",
}
}
if (!embedded) {
return {
ok: false,
error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.",
}
}
if (isMxFile(embedded) || isMxGraphModel(embedded)) {
return parseDrawioFileContent(embedded)
}
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
error: "The SVG's embedded content is not draw.io XML.",
}
}
const doc = parseMxfile(trimmed)
+71 -1
View File
@@ -12,7 +12,7 @@ import {
} from "./style-classes.ts"
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
import { validateAndFixXml } from "./xml-validation.ts"
import { repairQuoteBeforeSlash, validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram =
| { ok: true; xml: string; fixes: string[] }
@@ -53,6 +53,74 @@ export function reservedIdError(input: string): string | null {
return null
}
/**
* Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
// Named style definitions before the cells are not cells: output cut off
// right after them is incomplete. A compact cell whose last quote is
// missing is complete; prepareNewDiagram repairs it.
const trimmed = repairQuoteBeforeSlash(
readStyleClasses(xml || "").xml,
).trim()
if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>)
const lastSelfClose = trimmed.lastIndexOf("/>")
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
// No valid ending found at all
if (lastValidEnd === -1) return false
// If the last mxCell has no </mxCell> after it, it must be self-closing.
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
// and the output was cut off before the cell was closed.
const lastCellStart = trimmed.lastIndexOf("<mxCell")
if (
lastCellStart > lastMxCellClose &&
// (quoted values may hold a raw "<", which the auto-fix escapes)
!/^<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*\/>/.test(
trimmed.slice(lastCellStart),
)
) {
return false
}
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = trimmed.slice(lastValidEnd + endOffset)
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
}
/**
* Output that stops inside an mxCell would be wrapped and rejected with an
* error about wrapper tags the model never sent. Bare cells only. Returns the
* error for the model, or null.
*/
export function truncatedCellError(input: string): string | null {
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
// Comments, and closing tags such as </mxCell/> that the auto-fix
// repairs, are no cut
const stripped = input
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "</$1>")
// No cell at all: the validator's message fits better
if (!/<mxCell\b/.test(stripped) || isMxCellXmlComplete(stripped))
return null
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
}
/**
* The named style definitions are taken out first (style-classes.ts). Bare
* cells then get the wrapper and root cells, since the strict parser rejects
@@ -96,6 +164,8 @@ export function prepareNewDiagram(
if (styleError) return { ok: false, error: styleError }
const reserved = reservedIdError(cells)
if (reserved) return { ok: false, error: reserved }
const truncated = truncatedCellError(cells)
if (truncated) return { ok: false, error: truncated }
let xml = wrapCellsInModel(cells)
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) xml = fixed
+1 -1
View File
@@ -40,7 +40,7 @@
</button>
</div>
</div>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=auto" title="draw.io editor"></iframe>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
</div>
<div id="notice" role="status"></div>
<div id="history-modal">
+71
View File
@@ -0,0 +1,71 @@
/**
* Short XML references returned by get_drawing_guide with a topic. Each
* snippet is the bare mxCell elements of one page, as create_new_diagram
* accepts them, and is checked by tests/xml-reference.test.ts.
*/
import { indent } from "./xml-examples.ts"
const TABLE_ROW_STYLE =
"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;"
const TABLE_CELL_STYLE =
"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;"
const TABLES_EXAMPLE = `<mxCell id="table1" style="shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="240" height="80" as="geometry"/>
</mxCell>
<mxCell id="row1" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
<mxGeometry y="0" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell11" value="Name" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell12" value="Role" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="row2" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
<mxGeometry y="40" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell21" value="Alice" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell22" value="Admin" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
</mxCell>`
const LAYERS_EXAMPLE = `<mxCell id="L2" value="Layer 2" parent="0"/>
<mxCell id="2" value="On the default layer" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="On Layer 2" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="L2">
<mxGeometry x="200" y="40" width="120" height="60" as="geometry"/>
</mxCell>`
const GROUPS_EXAMPLE = `<mxCell id="group1" style="group" vertex="1" connectable="0" parent="1">
<mxGeometry x="40" y="40" width="260" height="60" as="geometry"/>
</mxCell>
<mxCell id="2" value="A" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
<mxGeometry x="0" y="0" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
<mxGeometry x="140" y="0" width="120" height="60" as="geometry"/>
</mxCell>`
export const XML_REFERENCE: Record<"tables" | "layers" | "groups", string> = {
tables: `## Tables
A table is three levels of cells, as draw.io's sidebar inserts them: the table container (style "shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;", parent="1", geometry wide enough for all columns), one row per table row (style as below, parent = table id, width = table width, height = row height, y = row offset), and one cell per column inside each row (style as below, parent = row id, x = column offset, y = 0, width = column width, height = row height; the cell text is the value). A header row can use fontStyle=1 on its cells. Example with 2 rows and 2 columns:
${indent(TABLES_EXAMPLE)}
`,
layers: `## Layers
A layer is the one case of a cell with parent="0" and no vertex or edge attribute; id="1" is the default layer. Add a layer cell and give shapes parent="<layer id>" to put them on it:
${indent(LAYERS_EXAMPLE)}
`,
groups: `## Groups
A group is a container with style="group" (parent="1", geometry enclosing its children). Children use parent="<group id>" and coordinates relative to the group; moving the group moves them:
${indent(GROUPS_EXAMPLE)}
`,
}