Files
next-ai-draw-io/lib/diagram-diff.ts
T
dayuan.jiang 366d9ee4ff feat(ui): canvas-first redesign with a self-hosted draw.io
The canvas now fills the window and the chat floats on its right. draw.io is
served from our own origin (public/drawio, downloaded at build time and
checked against the release's SHA-256), so the app can drive the editor
directly:

- every AI change becomes a version card with a thumbnail; compare, restore
  or undo it, and Ctrl+Z on the canvas takes an AI change back in one step
- shapes the AI just changed are highlighted on the canvas
- selected shapes appear as a chip and are sent to the model
- the app's own canvas toolbar, shape library and page tabs replace
  draw.io's chrome
- new start screen, session rename and switcher, open .drawio files
- settings split into Models & keys, Appearance, Advanced and About
- phones switch between canvas and chat
- dark mode keeps the diagram on a white sheet and no longer flashes

With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) the app falls back to
draw.io's own toolbar and the features above that need same origin are off.

The chat logic moved from the 1500-line chat panel into a chat engine plus
small UI components; zustand holds canvas, versions, settings and UI state.
2026-10-06 22:01:09 +09:00

274 lines
9.1 KiB
TypeScript

import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts"
/** What changed on the first 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
}
/** A page's mxGraphModel element, inflated when the page is compressed */
function modelOfPage(diagram: Element): Element | null {
const model = diagram.querySelector("mxGraphModel")
if (model) return model
const inflated = decompressPageContent(diagram.textContent || "")
if (!inflated) return null
const inner = new DOMParser().parseFromString(inflated, "text/xml")
return inner.querySelector("mxGraphModel")
}
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
}
/** The first page's mxGraphModel element, whatever wrapper the XML has */
function firstPageModel(xml: string): Element | null {
const doc = parse(xml)
if (!doc) return null
const diagram = doc.querySelector("diagram")
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")
const NUMBERS = new Set(["x", "y", "width", "height"])
/** A style as draw.io reads it: named styles in order, a later key wins */
function normalStyle(style: string): string {
const named: string[] = []
const keys = new Map<string, string>()
for (const entry of style.split(";").map((e) => e.trim())) {
if (!entry) continue
const eq = entry.indexOf("=")
if (eq < 0) named.push(entry)
else keys.set(entry.slice(0, eq), entry.slice(eq + 1))
}
const pairs = [...keys].map(([k, v]) => `${k}=${v}`).sort()
return [...named, ...pairs].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 and style
* entries differently, and draw.io drops values that are the default
*/
function signatureOf(node: Element): string {
const defaults = DEFAULT_VALUES[node.tagName] ?? {}
const attrs = Array.from(node.attributes)
.flatMap((a) => {
let value = a.value
if (a.name === "style") value = normalStyle(value)
else if (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()
const children = Array.from(node.children).map(signatureOf)
return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}</>`
}
/** 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 the first page */
function collectCells(xml: string): Map<string, CellInfo> {
const cells = new Map<string, CellInfo>()
for (const { id, cell, node } of cellsIn(firstPageModel(xml))) {
if (id === "0" || id === "1") continue
cells.set(id, {
isEdge: cell.getAttribute("edge") === "1",
signature: signatureOf(node),
})
}
return cells
}
export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff {
const before = collectCells(beforeXml)
const after = collectCells(afterXml)
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.
const PAGE_DEFAULTS: Record<string, string[]> = {
background: ["none"],
backgroundImage: [],
extFonts: [],
math: ["0"],
shadow: ["0"],
page: ["1"],
pageScale: ["1"],
// Letter or A4, after the browser's language
pageWidth: ["850", "827"],
pageHeight: ["1100", "1169"],
grid: ["1"],
gridSize: ["10"],
guides: ["1"],
tooltips: ["1"],
connect: ["1"],
arrows: ["1"],
fold: ["1"],
}
function samePageSettings(a: Element, b: Element): boolean {
const names = new Set(
[...Array.from(a.attributes), ...Array.from(b.attributes)].map(
(attr) => attr.name,
),
)
for (const name of names) {
if (VIEW_ATTRIBUTES.has(name)) continue
const va = a.getAttribute(name)
const vb = b.getAttribute(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
}
/**
* Whether two documents hold the same pages (names, cells, their order)
* and page settings
*/
export function isSameDocument(a: string, b: string): boolean {
const pagesOf = (xml: string) => {
const doc = parse(xml)
if (!doc) return null
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 pagesA = pagesOf(a)
const pagesB = pagesOf(b)
if (!pagesA || !pagesB) return a === b
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)
})
}