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,229 @@
|
||||
import pako from "pako"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { diffDiagrams, EMPTY_SUMMARY, isSameDocument } from "@/lib/diagram-diff"
|
||||
|
||||
const doc = (cells: string) =>
|
||||
`<mxfile><diagram name="Page-1" id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
||||
|
||||
const box = (id: string, value = id) =>
|
||||
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||
const edge = (id: string, source: string, target: string) =>
|
||||
`<mxCell id="${id}" edge="1" parent="1" source="${source}" target="${target}"><mxGeometry relative="1" as="geometry"/></mxCell>`
|
||||
|
||||
const pageOf = (name: string, cells: string, attrs = "") =>
|
||||
`<diagram name="${name}" id="${name}"><mxGraphModel${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
const file = (...pages: string[]) => `<mxfile>${pages.join("")}</mxfile>`
|
||||
const packedPage = (name: string, cells: string) => {
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel>`
|
||||
const packed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(model)),
|
||||
).toString("base64")
|
||||
return `<diagram name="${name}" id="${name}">${packed}</diagram>`
|
||||
}
|
||||
|
||||
describe("diffDiagrams", () => {
|
||||
it("counts added shapes and connectors on an empty canvas", () => {
|
||||
const { summary, touchedIds } = diffDiagrams(
|
||||
"",
|
||||
doc(box("a") + box("b") + edge("e", "a", "b")),
|
||||
)
|
||||
expect(summary.shapesAdded).toBe(2)
|
||||
expect(summary.edgesAdded).toBe(1)
|
||||
expect(touchedIds.sort()).toEqual(["a", "b", "e"])
|
||||
})
|
||||
|
||||
it("separates added, changed and removed cells", () => {
|
||||
const before = doc(box("a") + box("b") + edge("e", "a", "b"))
|
||||
const after = doc(box("a", "renamed") + box("c") + edge("e", "a", "c"))
|
||||
const { summary, touchedIds } = diffDiagrams(before, after)
|
||||
expect(summary).toEqual({
|
||||
shapesAdded: 1,
|
||||
shapesRemoved: 1,
|
||||
shapesChanged: 1,
|
||||
edgesAdded: 0,
|
||||
edgesRemoved: 0,
|
||||
edgesChanged: 1,
|
||||
})
|
||||
expect(touchedIds.sort()).toEqual(["a", "c", "e"])
|
||||
})
|
||||
|
||||
it("ignores how the same cell is written", () => {
|
||||
// As the model writes it, and as draw.io saves it
|
||||
const model = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" style="rounded=1;html=1;" vertex="1" parent="1"><mxGeometry x="10" y="20" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel>`
|
||||
const saved = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" parent="1" style="html=1;rounded=1" value="A" vertex="1"><mxGeometry as="geometry" height="40" width="80" x="10" y="20"/></mxCell></root></mxGraphModel>`
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
})
|
||||
|
||||
it("reports nothing for identical diagrams", () => {
|
||||
const xml = doc(box("a"))
|
||||
const { summary, touchedIds } = diffDiagrams(xml, xml)
|
||||
expect(summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(touchedIds).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores values draw.io drops when it saves", () => {
|
||||
// The model writes defaults and long numbers; draw.io leaves them out
|
||||
const model = doc(
|
||||
`<mxCell id="a" value="A" style="" vertex="1" connectable="1" parent="1"><mxGeometry x="0" y="0.0" width="80.0" height="40" relative="0" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint x="0" y="0" as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
const saved = doc(
|
||||
`<mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell><mxCell id="e" edge="1" parent="1" source="a" target="a"><mxGeometry relative="1" as="geometry"><mxPoint as="offset"/></mxGeometry></mxCell>`,
|
||||
)
|
||||
expect(diffDiagrams(model, saved).summary).toEqual(EMPTY_SUMMARY)
|
||||
expect(isSameDocument(model, saved)).toBe(true)
|
||||
})
|
||||
|
||||
it("reads styles as draw.io does: a later key wins", () => {
|
||||
const styled = (style: string) =>
|
||||
doc(
|
||||
`<mxCell id="a" style="${style}" vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell>`,
|
||||
)
|
||||
const blueThenRed = styled("fillColor=#00f;fillColor=#f00")
|
||||
expect(
|
||||
isSameDocument(
|
||||
styled("fillColor=#f00;fillColor=#00f"),
|
||||
blueThenRed,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
styled("fillColor=#f00;rounded=1"),
|
||||
blueThenRed.replace(
|
||||
"fillColor=#00f;fillColor=#f00",
|
||||
"rounded=1;fillColor=#f00",
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("reads the id from <UserObject> wrappers", () => {
|
||||
const wrapped = `<UserObject label="Linked" link="https://example.com" id="u1"><mxCell vertex="1" parent="1"><mxGeometry width="80" height="40" as="geometry"/></mxCell></UserObject>`
|
||||
const { summary, touchedIds } = diffDiagrams(doc(""), doc(wrapped))
|
||||
expect(summary.shapesAdded).toBe(1)
|
||||
expect(touchedIds).toEqual(["u1"])
|
||||
})
|
||||
|
||||
it("only looks at the first page", () => {
|
||||
const twoPages = file(pageOf("p1", box("a")), pageOf("p2", box("z")))
|
||||
expect(diffDiagrams("", twoPages).summary.shapesAdded).toBe(1)
|
||||
})
|
||||
|
||||
it("reads compressed pages", () => {
|
||||
const xml = file(packedPage("p1", box("a") + box("b")))
|
||||
expect(diffDiagrams("", xml).summary.shapesAdded).toBe(2)
|
||||
expect(diffDiagrams(xml, xml).fromScratch).toBe(false)
|
||||
})
|
||||
|
||||
it("treats unparsable XML as empty", () => {
|
||||
expect(
|
||||
diffDiagrams("<mxfile><diagram>", doc(box("a"))).fromScratch,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSameDocument", () => {
|
||||
const a = file(pageOf("One", box("a")), pageOf("Two", box("b")))
|
||||
|
||||
it("is true for the same pages written differently", () => {
|
||||
const b = file(
|
||||
pageOf("One", box("a"), ' dx="300" dy="120" grid="1"'),
|
||||
packedPage("Two", box("b")),
|
||||
)
|
||||
expect(isSameDocument(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it("sees a change on the second page", () => {
|
||||
const b = file(pageOf("One", box("a")), pageOf("Two", box("b", "x")))
|
||||
expect(diffDiagrams(a, b).summary).toEqual(diffDiagrams(a, a).summary)
|
||||
expect(isSameDocument(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it("sees added pages, renamed pages and changed page settings", () => {
|
||||
expect(isSameDocument(a, file(pageOf("One", box("a"))))).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
a,
|
||||
file(pageOf("One", box("a")), pageOf("Renamed", box("b"))),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"), ' background="#ffffff"')),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("compares a setting written on one side with draw.io's default", () => {
|
||||
const withAttrs = (attrs: string) =>
|
||||
file(pageOf("One", box("a"), attrs))
|
||||
const plain = withAttrs("")
|
||||
// Defaults, as draw.io fills them in (paper: Letter or A4)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(' math="0" pageWidth="827" pageHeight="1169"'),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(' pageWidth="850" pageHeight="1100"'),
|
||||
),
|
||||
).toBe(true)
|
||||
// Real changes
|
||||
expect(isSameDocument(plain, withAttrs(' math="1"'))).toBe(false)
|
||||
expect(isSameDocument(plain, withAttrs(' shadow="1"'))).toBe(false)
|
||||
expect(isSameDocument(plain, withAttrs(' pageWidth="1654"'))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
isSameDocument(
|
||||
plain,
|
||||
withAttrs(
|
||||
' backgroundImage="{"src":"x.png"}"',
|
||||
),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a background set on one side", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a"))),
|
||||
file(pageOf("One", box("a"), ' background="#000000"')),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees shapes brought to front or sent back", () => {
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", box("a") + box("b"))),
|
||||
file(pageOf("One", box("b") + box("a"))),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("sees a hidden layer", () => {
|
||||
const hidden = file(pageOf("One", box("a"))).replace(
|
||||
'<mxCell id="1" parent="0"/>',
|
||||
'<mxCell id="1" parent="0" visible="0"/>',
|
||||
)
|
||||
expect(isSameDocument(file(pageOf("One", box("a"))), hidden)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not care where children of other parents are written", () => {
|
||||
const group = (inner: string) =>
|
||||
`<mxCell id="g" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell>${inner}`
|
||||
const child = `<mxCell id="c" vertex="1" parent="g"><mxGeometry as="geometry"/></mxCell>`
|
||||
expect(
|
||||
isSameDocument(
|
||||
file(pageOf("One", group(child) + box("a"))),
|
||||
file(pageOf("One", group("") + box("a") + child)),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user