Files
next-ai-draw-io/stores/versions-store.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

146 lines
5.3 KiB
TypeScript

import { nanoid } from "nanoid"
import { create } from "zustand"
import type { ChangeSummary } from "@/lib/diagram-diff"
import { EMPTY_SUMMARY } from "@/lib/diagram-diff"
/** One diagram state produced by an AI change, shown as a card in the chat */
export interface DiagramVersion {
id: string
/** Full multi-page document after the change */
xml: string
/** Diagram before the change; kept only on the latest version */
beforeXml?: string
/** SVG data URL thumbnail; "" when none could be made */
svg?: string
/** Index of the user message that started this turn */
turnIndex: number
toolCallId?: string
summary: ChangeSummary
/** Drawn on an empty canvas (the card shows totals, not changes) */
fromScratch?: boolean
/** Saved with the session: this version is what the canvas shows */
onCanvas?: boolean
/** Saved with the session: this version's change was undone */
undone?: boolean
}
// The two flags live on the versions so they are saved with the session
function withFlags(
versions: DiagramVersion[],
onCanvasId: string | null,
undoneId: string | null,
): DiagramVersion[] {
return versions.map(({ onCanvas, undone, ...rest }) => ({
...rest,
...(rest.id === onCanvasId && { onCanvas: true }),
...(rest.id === undoneId && { undone: true }),
}))
}
// Versions are stored with the session; keep the list bounded
export const MAX_VERSIONS = 20
interface VersionsState {
versions: DiagramVersion[]
/** The change on the latest version was undone with its card's button */
undoneVersionId: string | null
/** The version the canvas shows (null after an undo or hand edits) */
onCanvasVersionId: string | null
setVersions: (versions: DiagramVersion[]) => void
addVersion: (version: Omit<DiagramVersion, "id">) => string
updateVersion: (id: string, patch: Partial<DiagramVersion>) => void
/** Drop versions from turns that are being re-run (edit or regenerate) */
removeFromTurn: (turnIndex: number) => void
/** The change of this version was undone; null: no undone change */
setUndone: (id: string | null) => void
/** This version is on the canvas again (restored or redone) */
setOnCanvas: (id: string) => void
/** The canvas was changed by hand (undo included): no version is on it */
clearCanvasFlags: () => void
clear: () => void
}
export const useVersionsStore = create<VersionsState>((set) => ({
versions: [],
undoneVersionId: null,
onCanvasVersionId: null,
setVersions: (versions) =>
set({
// A missing thumbnail could not be made: no "rendering" forever
versions: versions.map((v) => ({ ...v, svg: v.svg ?? "" })),
undoneVersionId: versions.find((v) => v.undone)?.id ?? null,
onCanvasVersionId: versions.find((v) => v.onCanvas)?.id ?? null,
}),
addVersion: (version) => {
const id = nanoid(10)
set((state) => {
// Only the newest version keeps its "before" XML
const older = state.versions.map(({ beforeXml, ...rest }) => rest)
const next = [...older, { ...version, id }].slice(-MAX_VERSIONS)
return {
versions: withFlags(next, id, null),
undoneVersionId: null,
onCanvasVersionId: id,
}
})
return id
},
updateVersion: (id, patch) =>
set((state) => ({
versions: state.versions.map((v) =>
v.id === id ? { ...v, ...patch } : v,
),
})),
removeFromTurn: (turnIndex) =>
set((state) => {
const versions = state.versions.filter(
(v) => v.turnIndex < turnIndex,
)
// The canvas goes back to the snapshot before that turn, which
// can hold hand edits made after the latest kept version
return {
versions: withFlags(versions, null, null),
undoneVersionId: null,
onCanvasVersionId: null,
}
}),
setUndone: (id) =>
set((state) => ({
versions: withFlags(state.versions, null, id),
undoneVersionId: id,
onCanvasVersionId: null,
})),
setOnCanvas: (id) =>
set((state) => ({
versions: withFlags(state.versions, id, null),
undoneVersionId: null,
onCanvasVersionId: id,
})),
clearCanvasFlags: () =>
set((state) =>
state.onCanvasVersionId === null && state.undoneVersionId === null
? state
: {
versions: withFlags(state.versions, null, null),
undoneVersionId: null,
onCanvasVersionId: null,
},
),
clear: () =>
set({ versions: [], undoneVersionId: null, onCanvasVersionId: null }),
}))
/** Convert the old `diagramHistory` list ({svg, xml}) saved by earlier versions */
export function versionsFromLegacyHistory(
history: { svg: string; xml: string }[] | undefined,
): DiagramVersion[] {
if (!history?.length) return []
return history.slice(-MAX_VERSIONS).map((entry, index) => ({
id: `legacy-${index}`,
xml: entry.xml,
svg: entry.svg,
turnIndex: -1,
summary: { ...EMPTY_SUMMARY },
}))
}