mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
The canvas components will be reused by the MCP server's browser shell, which has no chat and no Next.js. They now read everything they need from small contexts and the canvas store instead of the chat engine: - components/canvas/versions-context.tsx: VersionsProvider and useVersionsContext give the version cards, the strip and the compare dialog a VersionsSource (versions, onCanvasId, undoneId, isBusy, canUndo, canRedo, restore, undo, redo). The chat fills it from the versions store and the engine in components/chat/chat-versions.tsx. - stores/canvas-store.ts: isBusy and busyReason; the chat engine sets them while a turn runs, SelectionAsk and the version UI read them. - components/canvas/version-card.tsx: the visual version card and the thumbnail, out of tool-activity.tsx; the chat's card composes it and adds its "Show XML" link and code panel. - compare-dialog.tsx and version-strip.tsx move to components/canvas; the strip takes the minimum number of versions to show as a prop, the chat panel computes it from the cards it has. - components/canvas/locale-context.tsx: LocaleProvider and useLocale, fed by the [lang] layout; CanvasStage no longer uses next/navigation. The overlay (SelectionAsk) is a slot and the wait for the saved language is a prop, so the shell can leave both out. - lib/version-text.ts: describeChanges is now describeChangeSummary, so it can be imported next to the MCP core's describeChanges.
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { modelOfPage, pageElement } from "@/lib/diagram-pages"
|
|
|
|
/** What changed on one page between two versions of a diagram */
|
|
export interface ChangeSummary {
|
|
shapesAdded: number
|
|
shapesRemoved: number
|
|
shapesChanged: number
|
|
edgesAdded: number
|
|
edgesRemoved: number
|
|
edgesChanged: number
|
|
}
|
|
|
|
export interface DiagramDiff {
|
|
summary: ChangeSummary
|
|
/** Ids of cells that were added or changed (for highlighting) */
|
|
touchedIds: string[]
|
|
/** The diagram before had no shapes or connectors */
|
|
fromScratch: boolean
|
|
}
|
|
|
|
export const EMPTY_SUMMARY: ChangeSummary = {
|
|
shapesAdded: 0,
|
|
shapesRemoved: 0,
|
|
shapesChanged: 0,
|
|
edgesAdded: 0,
|
|
edgesRemoved: 0,
|
|
edgesChanged: 0,
|
|
}
|
|
|
|
interface CellInfo {
|
|
isEdge: boolean
|
|
signature: string
|
|
}
|
|
|
|
function parse(xml: string): Document | null {
|
|
if (!xml?.trim()) return null
|
|
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
|
return doc.querySelector("parsererror") ? null : doc
|
|
}
|
|
|
|
/**
|
|
* A page's mxGraphModel element, whatever wrapper the XML has: the page
|
|
* with this id, else the first
|
|
*/
|
|
function pageModel(xml: string, pageId?: string | null): Element | null {
|
|
const doc = parse(xml)
|
|
if (!doc) return null
|
|
const diagram = pageElement(doc, pageId)
|
|
if (diagram) return modelOfPage(diagram)
|
|
return doc.querySelector("mxGraphModel") ?? doc.documentElement
|
|
}
|
|
|
|
// Values draw.io leaves out when it saves: the model may write them
|
|
const DEFAULT_VALUES: Record<string, Record<string, string>> = {
|
|
mxCell: {
|
|
vertex: "0",
|
|
edge: "0",
|
|
connectable: "1",
|
|
visible: "1",
|
|
collapsed: "0",
|
|
style: "",
|
|
},
|
|
mxGeometry: { x: "0", y: "0", width: "0", height: "0", relative: "0" },
|
|
mxPoint: { x: "0", y: "0" },
|
|
}
|
|
// Numbers draw.io writes back in their shortest form ("40.0" is "40"), on
|
|
// the elements that hold a position or size (a wrapper's "x" is the
|
|
// user's own data, shown as written)
|
|
const NUMBERS = new Set(["x", "y", "width", "height"])
|
|
const GEOMETRY = new Set(["mxGeometry", "mxPoint", "mxRectangle"])
|
|
|
|
/**
|
|
* A style as draw.io reads it (mxStylesheet.getCellStyle): entries apply in
|
|
* order and empty ones do nothing; a leading ";" leaves the default out
|
|
*/
|
|
function normalStyle(style: string): string {
|
|
const entries = style.split(";").filter((entry) => entry.trim() !== "")
|
|
return (style.startsWith(";") ? ";" : "") + entries.join(";")
|
|
}
|
|
|
|
/**
|
|
* What a cell is, independent of how the XML was written: the same cell
|
|
* saved by draw.io and written by the model can order attributes
|
|
* differently, and draw.io drops values that are the default. Style
|
|
* entries keep their order, which counts.
|
|
*/
|
|
function signatureOf(node: Element): string {
|
|
const defaults = DEFAULT_VALUES[node.tagName] ?? {}
|
|
const attrs = Array.from(node.attributes)
|
|
.flatMap((a) => {
|
|
let value = a.value
|
|
// A cell's style (a wrapper's "style" is the user's own data)
|
|
if (a.name === "style" && node.tagName === "mxCell") {
|
|
value = normalStyle(value)
|
|
} else if (
|
|
GEOMETRY.has(node.tagName) &&
|
|
NUMBERS.has(a.name) &&
|
|
value.trim() !== ""
|
|
) {
|
|
const n = Number(value)
|
|
if (Number.isFinite(n)) value = String(n)
|
|
}
|
|
if (defaults[a.name] === value) return []
|
|
return [[a.name, value]]
|
|
})
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
// As JSON: a value can hold any text, also "name=value" pairs
|
|
return JSON.stringify([
|
|
node.tagName,
|
|
attrs,
|
|
Array.from(node.children).map(signatureOf),
|
|
])
|
|
}
|
|
|
|
/** The cells of a page, layers included, with the element that holds each */
|
|
function cellsIn(model: Element | null) {
|
|
if (!model) return []
|
|
return Array.from(model.getElementsByTagName("mxCell")).flatMap((cell) => {
|
|
// Cells with links or custom data are wrapped; the wrapper holds the id
|
|
const wrapper = cell.parentElement
|
|
const isWrapped =
|
|
wrapper?.tagName === "object" || wrapper?.tagName === "UserObject"
|
|
const id = cell.getAttribute("id") || wrapper?.getAttribute("id")
|
|
if (!id) return []
|
|
return [{ id, cell, node: isWrapped && wrapper ? wrapper : cell }]
|
|
})
|
|
}
|
|
|
|
/** Shapes and connectors of one page (not the root and its layers) */
|
|
function collectCells(
|
|
xml: string,
|
|
pageId?: string | null,
|
|
): Map<string, CellInfo> {
|
|
const cells = new Map<string, CellInfo>()
|
|
for (const { id, cell, node } of cellsIn(pageModel(xml, pageId))) {
|
|
if (id === "0" || cell.getAttribute("parent") === "0") continue
|
|
cells.set(id, {
|
|
isEdge: cell.getAttribute("edge") === "1",
|
|
signature: signatureOf(node),
|
|
})
|
|
}
|
|
return cells
|
|
}
|
|
|
|
/** What changed on one page (the first when no id is given) */
|
|
export function diffDiagrams(
|
|
beforeXml: string,
|
|
afterXml: string,
|
|
pageId?: string | null,
|
|
): DiagramDiff {
|
|
const before = collectCells(beforeXml, pageId)
|
|
const after = collectCells(afterXml, pageId)
|
|
const summary: ChangeSummary = { ...EMPTY_SUMMARY }
|
|
const touchedIds: string[] = []
|
|
|
|
for (const [id, cell] of after) {
|
|
const old = before.get(id)
|
|
if (!old) {
|
|
touchedIds.push(id)
|
|
if (cell.isEdge) summary.edgesAdded++
|
|
else summary.shapesAdded++
|
|
} else if (old.signature !== cell.signature) {
|
|
touchedIds.push(id)
|
|
if (cell.isEdge) summary.edgesChanged++
|
|
else summary.shapesChanged++
|
|
}
|
|
}
|
|
for (const [id, cell] of before) {
|
|
if (after.has(id)) continue
|
|
if (cell.isEdge) summary.edgesRemoved++
|
|
else summary.shapesRemoved++
|
|
}
|
|
return { summary, touchedIds, fromScratch: before.size === 0 }
|
|
}
|
|
|
|
// Where the view was scrolled to, saved with the page; not a change
|
|
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
|
|
|
|
// A page setting written on one side only is draw.io's default being
|
|
// filled in when the other side has that default; an empty list: any value
|
|
// is a change. Settings not listed are not compared then, like the paper
|
|
// size: a page without it keeps the canvas's (draw.io's readGraphState).
|
|
const PAGE_DEFAULTS: Record<string, string[]> = {
|
|
background: ["none"],
|
|
backgroundImage: [],
|
|
extFonts: [],
|
|
math: ["0"],
|
|
shadow: ["0"],
|
|
page: ["1"],
|
|
pageScale: ["1"],
|
|
grid: ["1"],
|
|
gridSize: ["10"],
|
|
guides: ["1"],
|
|
tooltips: ["1"],
|
|
connect: ["1"],
|
|
arrows: ["1"],
|
|
fold: ["1"],
|
|
}
|
|
|
|
// Settings draw.io reads as numbers (Editor.readGraphState): "10.0" is 10
|
|
const PAGE_NUMBERS = new Set([
|
|
"gridSize",
|
|
"pageScale",
|
|
"pageWidth",
|
|
"pageHeight",
|
|
])
|
|
|
|
function samePageSettings(a: Element, b: Element): boolean {
|
|
const names = new Set(
|
|
[...Array.from(a.attributes), ...Array.from(b.attributes)].map(
|
|
(attr) => attr.name,
|
|
),
|
|
)
|
|
const read = (page: Element, name: string) => {
|
|
const value = page.getAttribute(name)
|
|
if (value === null || !PAGE_NUMBERS.has(name) || !value.trim()) {
|
|
return value
|
|
}
|
|
const n = Number(value)
|
|
return Number.isFinite(n) ? String(n) : value
|
|
}
|
|
for (const name of names) {
|
|
if (VIEW_ATTRIBUTES.has(name)) continue
|
|
const va = read(a, name)
|
|
const vb = read(b, name)
|
|
if (va !== null && vb !== null) {
|
|
if (va !== vb) return false
|
|
continue
|
|
}
|
|
const defaults = PAGE_DEFAULTS[name]
|
|
if (defaults && !defaults.includes((va ?? vb) as string)) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Every cell of a page, layers included, and the order of each parent's
|
|
* children (which shape is in front)
|
|
*/
|
|
function contentOf(model: Element | null) {
|
|
const cells = new Map<string, string>()
|
|
const children = new Map<string, string[]>()
|
|
for (const { id, cell, node } of cellsIn(model)) {
|
|
cells.set(id, signatureOf(node))
|
|
const parent = cell.getAttribute("parent") ?? ""
|
|
children.set(parent, [...(children.get(parent) ?? []), id])
|
|
}
|
|
return { cells, children }
|
|
}
|
|
|
|
function sameContent(a: Element | null, b: Element | null): boolean {
|
|
const ca = contentOf(a)
|
|
const cb = contentOf(b)
|
|
if (ca.cells.size !== cb.cells.size) return false
|
|
for (const [id, signature] of ca.cells) {
|
|
if (cb.cells.get(id) !== signature) return false
|
|
}
|
|
for (const [parent, ids] of ca.children) {
|
|
if (cb.children.get(parent)?.join("\n") !== ids.join("\n")) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* File variables (%name% placeholders) as draw.io reads them, from JSON:
|
|
* the same names and values in any order; none is the same as "{}"
|
|
*/
|
|
export function sameFileVars(a: string | null, b: string | null): boolean {
|
|
if (a === b) return true
|
|
try {
|
|
// By name: names are unique, and a pair's text can look like another's
|
|
const sorted = (vars: string | null) =>
|
|
JSON.stringify(
|
|
Object.entries(JSON.parse(vars ?? "{}")).sort(([a], [b]) =>
|
|
a < b ? -1 : a > b ? 1 : 0,
|
|
),
|
|
)
|
|
return sorted(a) === sorted(b)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether two documents hold the same pages (names, cells, their order),
|
|
* page settings and file variables. This is the web app's one rule for "is
|
|
* this version on the canvas" (versions, compare, one-step commits). The
|
|
* MCP server's edit gate has its own (contentFingerprint): it compares the
|
|
* cells as written, and leaves page settings and file variables out.
|
|
*/
|
|
export function isSameDocument(a: string, b: string): boolean {
|
|
const pagesOf = (doc: Document) => {
|
|
const diagrams = Array.from(doc.getElementsByTagName("diagram"))
|
|
if (diagrams.length === 0) {
|
|
return [{ name: null, model: doc.querySelector("mxGraphModel") }]
|
|
}
|
|
return diagrams.map((d) => ({
|
|
name: d.getAttribute("name"),
|
|
model: modelOfPage(d),
|
|
}))
|
|
}
|
|
const docA = parse(a)
|
|
const docB = parse(b)
|
|
if (!docA || !docB) return a === b
|
|
const varsOf = (doc: Document) =>
|
|
doc.documentElement.nodeName === "mxfile"
|
|
? doc.documentElement.getAttribute("vars")
|
|
: null
|
|
if (!sameFileVars(varsOf(docA), varsOf(docB))) return false
|
|
const pagesA = pagesOf(docA)
|
|
const pagesB = pagesOf(docB)
|
|
if (pagesA.length !== pagesB.length) return false
|
|
return pagesA.every((pageA, i) => {
|
|
const pageB = pagesB[i]
|
|
if (pageA.name !== null && pageB.name !== null) {
|
|
if (pageA.name !== pageB.name) return false
|
|
}
|
|
if (pageA.model && pageB.model) {
|
|
if (!samePageSettings(pageA.model, pageB.model)) return false
|
|
}
|
|
return sameContent(pageA.model, pageB.model)
|
|
})
|
|
}
|