mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 19:49:52 +08:00
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.
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import pako from "pako"
|
||||
|
||||
/** 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[]
|
||||
}
|
||||
|
||||
export const EMPTY_SUMMARY: ChangeSummary = {
|
||||
shapesAdded: 0,
|
||||
shapesRemoved: 0,
|
||||
shapesChanged: 0,
|
||||
edgesAdded: 0,
|
||||
edgesRemoved: 0,
|
||||
edgesChanged: 0,
|
||||
}
|
||||
|
||||
interface CellInfo {
|
||||
isEdge: boolean
|
||||
signature: string
|
||||
}
|
||||
|
||||
/** Inflate a compressed <diagram> payload (base64, raw deflate, URI-encoded) */
|
||||
function inflateDiagram(text: string): string | null {
|
||||
try {
|
||||
const binary = atob(text.trim())
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
const inflated = pako.inflateRaw(bytes, { to: "string" })
|
||||
return decodeURIComponent(inflated)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 = inflateDiagram(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
|
||||
}
|
||||
|
||||
/**
|
||||
* The document with every compressed page inflated in place, as draw.io
|
||||
* saves it after an edit. Returned unchanged when there is nothing to do.
|
||||
*/
|
||||
export function inflatePages(xml: string): string {
|
||||
const doc = parse(xml)
|
||||
if (!doc) return xml
|
||||
let changed = false
|
||||
for (const diagram of Array.from(doc.getElementsByTagName("diagram"))) {
|
||||
if (diagram.querySelector("mxGraphModel")) continue
|
||||
const model = modelOfPage(diagram)
|
||||
if (!model) continue
|
||||
diagram.textContent = ""
|
||||
diagram.appendChild(doc.importNode(model, true))
|
||||
changed = true
|
||||
}
|
||||
return changed ? new XMLSerializer().serializeToString(doc) : xml
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function signatureOf(node: Element): string {
|
||||
const attrs = Array.from(node.attributes)
|
||||
.map((a) => {
|
||||
if (a.name !== "style") return `${a.name}=${a.value}`
|
||||
const entries = a.value
|
||||
.split(";")
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
return `style=${entries.join(";")}`
|
||||
})
|
||||
.sort()
|
||||
const children = Array.from(node.children).map(signatureOf)
|
||||
return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}</>`
|
||||
}
|
||||
|
||||
function collectCells(xml: string): Map<string, CellInfo> {
|
||||
return cellsOf(firstPageModel(xml))
|
||||
}
|
||||
|
||||
function cellsOf(model: Element | null): Map<string, CellInfo> {
|
||||
const cells = new Map<string, CellInfo>()
|
||||
if (!model) return cells
|
||||
for (const cell of Array.from(model.getElementsByTagName("mxCell"))) {
|
||||
// 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 || id === "0" || id === "1") continue
|
||||
const node = isWrapped && wrapper ? wrapper : cell
|
||||
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 }
|
||||
}
|
||||
|
||||
export function isEmptySummary(summary: ChangeSummary): boolean {
|
||||
return Object.values(summary).every((n) => n === 0)
|
||||
}
|
||||
|
||||
/** Count of shapes and connectors on the first page */
|
||||
export function countCells(xml: string): { shapes: number; edges: number } {
|
||||
let shapes = 0
|
||||
let edges = 0
|
||||
for (const cell of collectCells(xml).values()) {
|
||||
if (cell.isEdge) edges++
|
||||
else shapes++
|
||||
}
|
||||
return { shapes, edges }
|
||||
}
|
||||
|
||||
// Where the view was scrolled to, saved with the page; not a change
|
||||
const VIEW_ATTRIBUTES = new Set(["dx", "dy"])
|
||||
|
||||
/**
|
||||
* Whether two documents hold the same pages (names and cells) and page
|
||||
* settings. A setting written on one side only is draw.io's default being
|
||||
* filled in, so only settings both sides have are compared.
|
||||
*/
|
||||
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) {
|
||||
for (const attr of Array.from(pageA.model.attributes)) {
|
||||
if (VIEW_ATTRIBUTES.has(attr.name)) continue
|
||||
const other = pageB.model.getAttribute(attr.name)
|
||||
if (other !== null && other !== attr.value) return false
|
||||
}
|
||||
}
|
||||
const cellsA = cellsOf(pageA.model)
|
||||
const cellsB = cellsOf(pageB.model)
|
||||
if (cellsA.size !== cellsB.size) return false
|
||||
for (const [id, cell] of cellsA) {
|
||||
if (cellsB.get(id)?.signature !== cell.signature) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user