mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 03:59:58 +08:00
The model always got the first page and display_diagram replaced the whole file with one page, so drawing on page 3 of a multi-page file deleted the other pages (#241), and edits went to page 1 while the user looked at another page. Now a turn records the page the user is viewing when the message is sent. That page's model goes to the model (also as previousXml, on regenerate and in edit_diagram error messages), selected shapes are sent on any page, display_diagram and append_diagram replace only that page (a drawing that brings several pages still replaces the file), edit_diagram targets it, and the streaming preview draws on it. Compact cells and default styles take the layer and the edges of that page, in the preview and in editDiagram. The editor bridge replaces the current page in place when the other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page file in one step; when a full load is needed, the user's page is shown again afterwards. Version summaries and the compare dialog count the page the change was made on.
323 lines
10 KiB
TypeScript
323 lines
10 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
|
|
*/
|
|
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)
|
|
})
|
|
}
|