mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 18:57:47 +08:00
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.
410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react"
|
|
import { usePathname, useRouter } from "next/navigation"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import {
|
|
CanvasStage,
|
|
DIAGRAM_FILE_ACCEPT,
|
|
} from "@/components/canvas/canvas-stage"
|
|
import {
|
|
ChatEngineProvider,
|
|
useChatEngine,
|
|
} from "@/components/chat/chat-engine"
|
|
import { ChatPanel } from "@/components/chat/chat-panel"
|
|
import { CompareDialog } from "@/components/chat/compare-dialog"
|
|
import { LobbyHero } from "@/components/chat/lobby-hero"
|
|
import { SaveDialog } from "@/components/save-dialog"
|
|
import { SettingsDialog } from "@/components/settings/settings-dialog"
|
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
|
import { BrandMark } from "@/components/workspace/brand-mark"
|
|
import { IconButton } from "@/components/workspace/icon-button"
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
import { useDictionary } from "@/hooks/use-dictionary"
|
|
import {
|
|
debugCellIds,
|
|
debugView,
|
|
setAppShortcutHandler,
|
|
toggleShapesPanel,
|
|
} from "@/lib/drawio/editor-bridge"
|
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
import { modKey } from "@/lib/platform"
|
|
import { STORAGE_KEYS } from "@/lib/storage"
|
|
import { cn, isRealDiagram } from "@/lib/utils"
|
|
import { useCanvasStore } from "@/stores/canvas-store"
|
|
import { useSettingsStore } from "@/stores/settings-store"
|
|
import { useUiStore } from "@/stores/ui-store"
|
|
import { useVersionsStore } from "@/stores/versions-store"
|
|
|
|
const MOBILE_BREAKPOINT = 768
|
|
// Gap between the floating panel and the window edge
|
|
const PANEL_GAP = 12
|
|
|
|
function useIsMobile() {
|
|
const [isMobile, setIsMobile] = useState(false)
|
|
useEffect(() => {
|
|
const query = window.matchMedia(
|
|
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
|
|
)
|
|
const update = () => setIsMobile(query.matches)
|
|
update()
|
|
query.addEventListener("change", update)
|
|
return () => query.removeEventListener("change", update)
|
|
}, [])
|
|
return isMobile
|
|
}
|
|
|
|
/** Restore the language picked last time (the URL may say otherwise) */
|
|
function useSavedLocale() {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem(STORAGE_KEYS.locale)
|
|
if (!saved || !i18n.locales.includes(saved as Locale)) return
|
|
const parts = pathname.split("/").filter(Boolean)
|
|
if (parts[0] === saved) return
|
|
parts[0] = saved
|
|
const { search, hash } = window.location
|
|
router.replace(`/${parts.join("/")}${search}${hash}`)
|
|
}, [])
|
|
}
|
|
|
|
// Stores on window in development, for debugging and end-to-end tests
|
|
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
|
;(window as any).__naiStores = {
|
|
canvas: useCanvasStore,
|
|
versions: useVersionsStore,
|
|
ui: useUiStore,
|
|
settings: useSettingsStore,
|
|
cellIds: debugCellIds,
|
|
view: debugView,
|
|
}
|
|
}
|
|
|
|
/** The diagram's title as a file name, or diagram-YYYY-MM-DD without one */
|
|
export function exportFilename(title: string | null): string {
|
|
const name = (title && title !== "New Chat" ? title : "")
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 80)
|
|
// Local date (toISOString would give UTC)
|
|
return name || `diagram-${new Date().toLocaleDateString("sv-SE")}`
|
|
}
|
|
|
|
/** The whole editor: canvas, chat panel, dialogs */
|
|
export function Workspace() {
|
|
const hydrate = useSettingsStore((s) => s.hydrate)
|
|
useEffect(() => {
|
|
hydrate()
|
|
}, [hydrate])
|
|
useSavedLocale()
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={350}>
|
|
<ChatEngineProvider>
|
|
<WorkspaceLayout />
|
|
</ChatEngineProvider>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
function WorkspaceLayout() {
|
|
const dict = useDictionary()
|
|
const engine = useChatEngine()
|
|
const { chartXML, saveDiagramToFile } = useDiagram()
|
|
const isMobile = useIsMobile()
|
|
const panelWidth = useSettingsStore((s) => s.panelWidth)
|
|
const {
|
|
panelOpen,
|
|
togglePanel,
|
|
setPanelOpen,
|
|
mobileView,
|
|
setMobileView,
|
|
openSettings,
|
|
focusComposer,
|
|
saveDialogOpen,
|
|
setSaveDialogOpen,
|
|
} = useUiStore()
|
|
const [heroDismissed, setHeroDismissed] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// Start screen: nothing said and nothing drawn yet
|
|
const showHero =
|
|
engine.isRestored &&
|
|
engine.messages.length === 0 &&
|
|
!isRealDiagram(chartXML) &&
|
|
!heroDismissed
|
|
|
|
// A new conversation brings the start screen back (a new chat gets a
|
|
// new tracing id, also when it was empty before)
|
|
const chatId = engine.langfuseSessionId
|
|
useEffect(() => {
|
|
setHeroDismissed(false)
|
|
}, [chatId])
|
|
const messageCount = engine.messages.length
|
|
const wasEmptyRef = useRef(true)
|
|
useEffect(() => {
|
|
if (messageCount > 0) wasEmptyRef.current = false
|
|
else if (!wasEmptyRef.current) {
|
|
wasEmptyRef.current = true
|
|
setHeroDismissed(false)
|
|
}
|
|
}, [messageCount])
|
|
|
|
// Phones: show the canvas while the AI draws and when a new version
|
|
// lands, so the result is visible; the chat tab gets a dot meanwhile
|
|
const isBusy = engine.isBusy
|
|
const versionCount = useVersionsStore((s) => s.versions.length)
|
|
const [chatUnread, setChatUnread] = useState(false)
|
|
const lastVersionCountRef = useRef(versionCount)
|
|
useEffect(() => {
|
|
if (isMobile && isBusy) setMobileView("canvas")
|
|
}, [isMobile, isBusy, setMobileView])
|
|
useEffect(() => {
|
|
const grew = versionCount > lastVersionCountRef.current
|
|
lastVersionCountRef.current = versionCount
|
|
if (isMobile && grew && engine.isRestored) {
|
|
setMobileView("canvas")
|
|
setChatUnread(true)
|
|
}
|
|
}, [versionCount, isMobile, engine.isRestored, setMobileView])
|
|
useEffect(() => {
|
|
if (mobileView === "chat") setChatUnread(false)
|
|
}, [mobileView])
|
|
|
|
// ⌘B toggles the panel; ⌘/ asks the AI about the selection; ⌘⇧K
|
|
// shows the shape library
|
|
useEffect(() => {
|
|
const handle = (event: KeyboardEvent) => {
|
|
if (!(event.metaKey || event.ctrlKey)) return false
|
|
if (event.shiftKey && (event.key === "k" || event.key === "K")) {
|
|
toggleShapesPanel()
|
|
return true
|
|
}
|
|
if (event.key === "b" || event.key === "B") {
|
|
if (isMobile) return false
|
|
togglePanel()
|
|
return true
|
|
}
|
|
if (event.key === "/") {
|
|
setHeroDismissed(true)
|
|
focusComposer()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (handle(event)) event.preventDefault()
|
|
}
|
|
// draw.io takes focus when it (re)loads, e.g. after a language
|
|
// switch; Escape typed there must still close our dialogs.
|
|
// ⌘B stays with draw.io in the canvas (bold, and ⇧ for to back).
|
|
const handleInFrame = (event: KeyboardEvent) => {
|
|
if (event.key === "b" || event.key === "B") return false
|
|
if (event.key === "Escape") {
|
|
const ui = useUiStore.getState()
|
|
if (
|
|
ui.settingsOpen ||
|
|
ui.compareVersionId ||
|
|
ui.saveDialogOpen
|
|
) {
|
|
ui.setSettingsOpen(false)
|
|
ui.closeCompare()
|
|
ui.setSaveDialogOpen(false)
|
|
return true
|
|
}
|
|
}
|
|
return handle(event)
|
|
}
|
|
window.addEventListener("keydown", onKeyDown)
|
|
setAppShortcutHandler(handleInFrame)
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown)
|
|
setAppShortcutHandler(null)
|
|
}
|
|
}, [isMobile, togglePanel, focusComposer])
|
|
|
|
const openFile = () => fileInputRef.current?.click()
|
|
const dismissHero = () => {
|
|
setHeroDismissed(true)
|
|
setPanelOpen(true)
|
|
}
|
|
|
|
const panelVisible = !isMobile && !showHero && panelOpen
|
|
// The panel floats on the right; the canvas ends where it begins
|
|
const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 overflow-hidden bg-canvas"
|
|
data-testid="workspace"
|
|
>
|
|
{/* One canvas for both layouts: a new one would reload draw.io
|
|
and lose its undo history */}
|
|
<div className="absolute inset-0 flex flex-col">
|
|
<div className="relative min-h-0 flex-1">
|
|
<CanvasStage
|
|
className={
|
|
isMobile
|
|
? cn(
|
|
"inset-0",
|
|
mobileView !== "canvas" && "invisible",
|
|
)
|
|
: "inset-y-0 left-0 transition-[right] duration-300 ease-[var(--ease-out)]"
|
|
}
|
|
style={isMobile ? undefined : { right: canvasRight }}
|
|
compact={isMobile}
|
|
hideOverlays={!isMobile && showHero}
|
|
/>
|
|
{isMobile ? (
|
|
mobileView === "chat" &&
|
|
(showHero ? (
|
|
<div className="absolute inset-0 overflow-y-auto bg-canvas">
|
|
<div className="flex items-center gap-2 px-4 pt-3">
|
|
<BrandMark className="size-6" />
|
|
<span className="mr-auto text-[13px] font-semibold tracking-[-0.01em]">
|
|
Next AI Draw.io
|
|
</span>
|
|
<IconButton
|
|
label={dict.nav.settings}
|
|
onClick={() => openSettings()}
|
|
data-testid="settings-button"
|
|
>
|
|
<Settings2 />
|
|
</IconButton>
|
|
</div>
|
|
<LobbyHero
|
|
compact
|
|
onOpenFile={openFile}
|
|
onDrawYourself={() => {
|
|
setHeroDismissed(true)
|
|
setMobileView("canvas")
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<ChatPanel mobile onOpenFile={openFile} />
|
|
))
|
|
) : (
|
|
<>
|
|
{panelVisible && (
|
|
<ChatPanel onOpenFile={openFile} />
|
|
)}
|
|
|
|
{!showHero && !panelOpen && (
|
|
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 rounded-xl bg-card p-1 shadow-float animate-fade-in">
|
|
<BrandMark className="mx-0.5 size-6" />
|
|
<IconButton
|
|
label={dict.nav.showPanel}
|
|
shortcut={`${modKey}B`}
|
|
onClick={() => setPanelOpen(true)}
|
|
data-testid="show-panel"
|
|
>
|
|
<PanelRightOpen />
|
|
</IconButton>
|
|
</div>
|
|
)}
|
|
|
|
{showHero && (
|
|
<>
|
|
<div className="absolute inset-0 z-10 overflow-y-auto bg-canvas">
|
|
<div className="flex min-h-full items-center">
|
|
<LobbyHero
|
|
onOpenFile={openFile}
|
|
onDrawYourself={dismissHero}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-xl bg-card py-1 pr-1 pl-1.5 shadow-float">
|
|
<BrandMark className="size-6" />
|
|
<span className="pr-1 text-[13px] font-semibold tracking-[-0.01em]">
|
|
Next AI Draw.io
|
|
</span>
|
|
<IconButton
|
|
label={dict.nav.settings}
|
|
onClick={() => openSettings()}
|
|
data-testid="settings-button"
|
|
>
|
|
<Settings2 />
|
|
</IconButton>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{isMobile && (
|
|
<nav className="flex shrink-0 items-center justify-center gap-1 border-t border-border bg-card px-3 pt-1.5 pb-[max(6px,env(safe-area-inset-bottom))]">
|
|
{(
|
|
[
|
|
{
|
|
view: "canvas",
|
|
label: dict.workspace.mobileCanvas,
|
|
icon: <Shapes />,
|
|
},
|
|
{
|
|
view: "chat",
|
|
label: dict.workspace.mobileChat,
|
|
icon: <MessageSquare />,
|
|
},
|
|
] as const
|
|
).map((item) => (
|
|
<button
|
|
key={item.view}
|
|
type="button"
|
|
onClick={() => setMobileView(item.view)}
|
|
aria-pressed={mobileView === item.view}
|
|
className={cn(
|
|
"flex h-10 flex-1 items-center justify-center gap-2 rounded-xl text-[13px] text-muted-foreground [&_svg]:size-4",
|
|
mobileView === item.view &&
|
|
"bg-accent font-medium text-foreground",
|
|
)}
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
{item.view === "chat" &&
|
|
(isBusy || chatUnread) &&
|
|
mobileView !== "chat" && (
|
|
<span className="size-1.5 rounded-full bg-marker" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={DIAGRAM_FILE_ACCEPT}
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0]
|
|
if (file) {
|
|
setHeroDismissed(true)
|
|
engine.openDiagramFile(file)
|
|
}
|
|
event.target.value = ""
|
|
}}
|
|
/>
|
|
|
|
<SettingsDialog />
|
|
<CompareDialog />
|
|
<SaveDialog
|
|
open={saveDialogOpen}
|
|
onOpenChange={setSaveDialogOpen}
|
|
onSave={(filename, format) =>
|
|
saveDiagramToFile(
|
|
filename,
|
|
format,
|
|
engine.langfuseSessionId,
|
|
dict.save.savedSuccessfully,
|
|
)
|
|
}
|
|
defaultFilename={exportFilename(engine.currentTitle)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|