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:
dayuan.jiang
2026-10-06 16:17:11 +09:00
parent 18a20aa1ca
commit 23d5c83b0d
105 changed files with 12499 additions and 7770 deletions
+54
View File
@@ -0,0 +1,54 @@
import { create } from "zustand"
export interface SelectedCell {
id: string
label: string
isEdge: boolean
}
/** Rectangle relative to the draw.io iframe's top-left corner */
export interface CanvasRect {
x: number
y: number
width: number
height: number
}
export interface CanvasPage {
id: string
name: string
}
interface CanvasState {
/** The page can call the draw.io editor directly (same-origin iframe) */
hasEditor: boolean
canUndo: boolean
canRedo: boolean
/** Zoom in percent */
zoom: number
pages: CanvasPage[]
currentPageId: string | null
selection: SelectedCell[]
selectionRect: CanvasRect | null
/** Freehand drawing is on */
isFreehand: boolean
/** The format (style) panel is open */
isFormatOpen: boolean
isShapesOpen: boolean
set: (partial: Partial<Omit<CanvasState, "set">>) => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
hasEditor: false,
canUndo: false,
canRedo: false,
zoom: 100,
pages: [],
currentPageId: null,
selection: [],
selectionRect: null,
isFreehand: false,
isFormatOpen: false,
isShapesOpen: false,
set: (partial) => set(partial),
}))
+159
View File
@@ -0,0 +1,159 @@
import { create } from "zustand"
import { STORAGE_KEYS } from "@/lib/storage"
export type ThemePreference = "light" | "dark" | "system"
export type SendShortcut = "enter" | "ctrl-enter"
export const PANEL_MIN_WIDTH = 320
export const PANEL_MAX_WIDTH = 560
export const PANEL_DEFAULT_WIDTH = 384
interface SettingsState {
hydrated: boolean
theme: ThemePreference
/** Resolved from `theme` and the system setting */
isDark: boolean
sendShortcut: SendShortcut
minimalStyle: boolean
vlmValidationEnabled: boolean
customSystemMessage: string
maxOutputTokens: string
panelWidth: number
hydrate: () => void
setTheme: (theme: ThemePreference) => void
setSendShortcut: (value: SendShortcut) => void
setMinimalStyle: (value: boolean) => void
setVlmValidationEnabled: (value: boolean) => void
setCustomSystemMessage: (value: string) => void
setMaxOutputTokens: (value: string) => void
setPanelWidth: (value: number) => void
}
function read(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function write(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key)
else localStorage.setItem(key, value)
} catch {
// Storage can be unavailable (private mode, quota)
}
}
function systemPrefersDark(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
)
}
function applyDarkClass(isDark: boolean) {
document.documentElement.classList.toggle("dark", isDark)
}
export function clampPanelWidth(width: number): number {
return Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, width))
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
hydrated: false,
theme: "system",
isDark: false,
sendShortcut: "ctrl-enter",
minimalStyle: false,
vlmValidationEnabled: false,
customSystemMessage: "",
maxOutputTokens: "",
panelWidth: PANEL_DEFAULT_WIDTH,
hydrate: () => {
if (get().hydrated) return
// Stored as "true"/"false"; missing means follow the system
const storedDark = read(STORAGE_KEYS.darkMode)
const theme: ThemePreference =
storedDark === "true"
? "dark"
: storedDark === "false"
? "light"
: "system"
const isDark =
theme === "system" ? systemPrefersDark() : theme === "dark"
const width = Number(read(STORAGE_KEYS.panelWidth))
set({
hydrated: true,
theme,
isDark,
sendShortcut:
read(STORAGE_KEYS.sendShortcut) === "enter"
? "enter"
: "ctrl-enter",
minimalStyle: read(STORAGE_KEYS.minimalStyle) === "true",
vlmValidationEnabled:
read(STORAGE_KEYS.vlmValidationEnabled) === "true",
customSystemMessage: read(STORAGE_KEYS.customSystemMessage) ?? "",
maxOutputTokens: read(STORAGE_KEYS.maxOutputTokens) ?? "",
panelWidth: width ? clampPanelWidth(width) : PANEL_DEFAULT_WIDTH,
})
applyDarkClass(isDark)
// Follow system changes while the preference is "system"
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) => {
if (get().theme !== "system") return
set({ isDark: event.matches })
applyDarkClass(event.matches)
})
},
setTheme: (theme) => {
write(
STORAGE_KEYS.darkMode,
theme === "system" ? null : String(theme === "dark"),
)
const isDark =
theme === "system" ? systemPrefersDark() : theme === "dark"
set({ theme, isDark })
applyDarkClass(isDark)
},
setSendShortcut: (value) => {
write(STORAGE_KEYS.sendShortcut, value)
set({ sendShortcut: value })
},
setMinimalStyle: (value) => {
write(STORAGE_KEYS.minimalStyle, String(value))
set({ minimalStyle: value })
},
setVlmValidationEnabled: (value) => {
write(STORAGE_KEYS.vlmValidationEnabled, String(value))
set({ vlmValidationEnabled: value })
},
setCustomSystemMessage: (value) => {
write(STORAGE_KEYS.customSystemMessage, value)
set({ customSystemMessage: value })
},
setMaxOutputTokens: (value) => {
const digitsOnly = value.replace(/\D/g, "")
write(STORAGE_KEYS.maxOutputTokens, digitsOnly)
set({ maxOutputTokens: digitsOnly })
},
setPanelWidth: (value) => {
const width = clampPanelWidth(Math.round(value))
write(STORAGE_KEYS.panelWidth, String(width))
set({ panelWidth: width })
},
}))
/**
* Inline script for <head>: applies the dark class before the first paint so
* dark mode does not flash white while the page loads.
*/
export const THEME_INIT_SCRIPT = `(function(){try{var v=localStorage.getItem(${JSON.stringify(
STORAGE_KEYS.darkMode,
)});var d=v==="true"||(v===null&&window.matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("dark")}catch(e){}})()`
+67
View File
@@ -0,0 +1,67 @@
import { create } from "zustand"
export type SettingsTab = "models" | "appearance" | "advanced" | "about"
export type MobileView = "canvas" | "chat"
interface UiState {
/** Desktop: the floating chat panel is open */
panelOpen: boolean
/** Phones: which half of the app is visible */
mobileView: MobileView
settingsOpen: boolean
settingsTab: SettingsTab
/** Version shown in the compare dialog, or null when closed */
compareVersionId: string | null
sessionsOpen: boolean
saveDialogOpen: boolean
/** Bumped to ask the composer to take focus */
focusComposerToken: number
/** Bumped when templates are saved, so template lists reload */
templatesVersion: number
setPanelOpen: (open: boolean) => void
togglePanel: () => void
setMobileView: (view: MobileView) => void
openSettings: (tab?: SettingsTab) => void
setSettingsOpen: (open: boolean) => void
setSettingsTab: (tab: SettingsTab) => void
openCompare: (versionId: string) => void
closeCompare: () => void
setSessionsOpen: (open: boolean) => void
setSaveDialogOpen: (open: boolean) => void
focusComposer: () => void
bumpTemplates: () => void
}
export const useUiStore = create<UiState>((set) => ({
panelOpen: true,
mobileView: "chat",
settingsOpen: false,
settingsTab: "models",
compareVersionId: null,
sessionsOpen: false,
saveDialogOpen: false,
focusComposerToken: 0,
templatesVersion: 0,
setPanelOpen: (open) => set({ panelOpen: open }),
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
setMobileView: (view) => set({ mobileView: view }),
openSettings: (tab) =>
set((state) => ({
settingsOpen: true,
settingsTab: tab ?? state.settingsTab,
})),
setSettingsOpen: (open) => set({ settingsOpen: open }),
setSettingsTab: (tab) => set({ settingsTab: tab }),
openCompare: (versionId) => set({ compareVersionId: versionId }),
closeCompare: () => set({ compareVersionId: null }),
setSessionsOpen: (open) => set({ sessionsOpen: open }),
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
focusComposer: () =>
set((state) => ({
panelOpen: true,
mobileView: "chat",
focusComposerToken: state.focusComposerToken + 1,
})),
bumpTemplates: () =>
set((state) => ({ templatesVersion: state.templatesVersion + 1 })),
}))
+155
View File
@@ -0,0 +1,155 @@
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
createdAt: number
/** 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" | "createdAt">) => 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) => {
const flagged = versions.some((v) => v.onCanvas || v.undone)
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,
// Sessions saved before the flags existed: the latest version
onCanvasVersionId: flagged
? (versions.find((v) => v.onCanvas)?.id ?? null)
: (versions.at(-1)?.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, createdAt: Date.now() },
].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,
createdAt: 0,
turnIndex: -1,
summary: { ...EMPTY_SUMMARY },
}))
}