mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
feat(ui): canvas-first redesign with a self-hosted draw.io (#957)
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 - draw.io's own simple UI: its toolbar, menus (auto layout, Mermaid, CSV import) and page tabs; the shape library starts closed - new start screen, session rename and switcher, open .drawio files - settings split into Models & keys, Appearance, Advanced and About - Bedrock in the model settings also takes an API key instead of access keys - phones switch between canvas and chat - dark mode uses draw.io's own dark mode, with neutral greys around it With an external draw.io (NEXT_PUBLIC_DRAWIO_BASE_URL) 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,45 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
export interface SelectedCell {
|
||||
id: string
|
||||
label: string
|
||||
isEdge: boolean
|
||||
}
|
||||
|
||||
/** Rectangle relative to the draw.io iframe's top-left corner */
|
||||
interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface CanvasPage {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface CanvasState {
|
||||
/** The page can call the draw.io editor directly (same-origin iframe) */
|
||||
hasEditor: boolean
|
||||
pages: CanvasPage[]
|
||||
currentPageId: string | null
|
||||
selection: SelectedCell[]
|
||||
selectionRect: CanvasRect | null
|
||||
/** Freehand drawing is on */
|
||||
isFreehand: boolean
|
||||
/** One of draw.io's menus or dialogs is open over the canvas */
|
||||
isDrawioPopupOpen: boolean
|
||||
set: (partial: Partial<Omit<CanvasState, "set">>) => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hasEditor: false,
|
||||
pages: [],
|
||||
currentPageId: null,
|
||||
selection: [],
|
||||
selectionRect: null,
|
||||
isFreehand: false,
|
||||
isDrawioPopupOpen: false,
|
||||
set: (partial) => set(partial),
|
||||
}))
|
||||
@@ -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){}})()`
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
/** The start screen was put away (drawing by hand, an opened file) */
|
||||
heroDismissed: 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
|
||||
saveDialogOpen: boolean
|
||||
/** Bumped to ask the composer to take focus */
|
||||
focusComposerToken: number
|
||||
setPanelOpen: (open: boolean) => void
|
||||
setHeroDismissed: (dismissed: 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
|
||||
setSaveDialogOpen: (open: boolean) => void
|
||||
focusComposer: () => void
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
panelOpen: true,
|
||||
heroDismissed: false,
|
||||
mobileView: "chat",
|
||||
settingsOpen: false,
|
||||
settingsTab: "models",
|
||||
compareVersionId: null,
|
||||
saveDialogOpen: false,
|
||||
focusComposerToken: 0,
|
||||
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||
setHeroDismissed: (dismissed) => set({ heroDismissed: dismissed }),
|
||||
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 }),
|
||||
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
|
||||
focusComposer: () =>
|
||||
set((state) => ({
|
||||
panelOpen: true,
|
||||
mobileView: "chat",
|
||||
focusComposerToken: state.focusComposerToken + 1,
|
||||
})),
|
||||
}))
|
||||
@@ -0,0 +1,156 @@
|
||||
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
|
||||
/** Shown as v{number}; it stays when older versions are dropped */
|
||||
number: number
|
||||
/** 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" | "number">) => 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 saved before they had numbers count from 1.
|
||||
versions: versions.map((v, i) => ({
|
||||
...v,
|
||||
number: v.number ?? i + 1,
|
||||
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 number = (state.versions.at(-1)?.number ?? 0) + 1
|
||||
const next = [...older, { ...version, id, number }].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}`,
|
||||
number: index + 1,
|
||||
xml: entry.xml,
|
||||
svg: entry.svg,
|
||||
turnIndex: -1,
|
||||
summary: { ...EMPTY_SUMMARY },
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user