"use client" import { MessageSquare, PanelRightOpen, Settings2, Shapes } from "lucide-react" import { usePathname, useRouter } from "next/navigation" import { useEffect, useRef, useState } from "react" import { CanvasStage } 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-dialog" 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 { usePanelWidth } from "@/hooks/use-panel-width" import { setAppShortcutHandler } 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 { useSettingsStore } from "@/stores/settings-store" import { useUiStore } from "@/stores/ui-store" import { useVersionsStore } from "@/stores/versions-store" /** File types the "Open .drawio file" picker accepts */ const DIAGRAM_FILE_ACCEPT = ".drawio,.xml,.svg,application/xml,image/svg+xml" 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}`) }, []) } /** 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")}` } /** App name and settings button above the start screen */ function StartBar({ className, nameClassName, }: { className: string nameClassName: string }) { const dict = useDictionary() const openSettings = useUiStore((s) => s.openSettings) return (
Next AI Draw.io openSettings()} data-testid="settings-button" >
) } /** The whole editor: canvas, chat panel, dialogs */ export function Workspace() { const hydrate = useSettingsStore((s) => s.hydrate) useEffect(() => { hydrate() }, [hydrate]) useSavedLocale() return ( ) } function WorkspaceLayout() { const dict = useDictionary() const engine = useChatEngine() const { chartXML, saveDiagramToFile } = useDiagram() const isMobile = useIsMobile() const panelWidth = usePanelWidth() const { panelOpen, togglePanel, setPanelOpen, mobileView, setMobileView, focusComposer, saveDialogOpen, setSaveDialogOpen, } = useUiStore() const heroDismissed = useUiStore((s) => s.heroDismissed) const setHeroDismissed = useUiStore((s) => s.setHeroDismissed) const fileInputRef = useRef(null) // Start screen: nothing said and nothing drawn yet. A new chat brings // it back, an opened file or saved chat puts it away (the chat engine). const showHero = engine.isRestored && engine.messages.length === 0 && !engine.isResending && !isRealDiagram(chartXML) && !heroDismissed // 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 useEffect(() => { const handle = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey)) return false // ⌘⇧B stays with the browser (Chrome's bookmarks bar) if (!event.shiftKey && (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 canvas edge slides with the panel as it opens or closes; a window // resize or a dragged panel edge moves it at once. Set in the render // that changes the edge, so the transition applies to that change. const [sliding, setSliding] = useState(false) const [lastPanelVisible, setLastPanelVisible] = useState(panelVisible) if (lastPanelVisible !== panelVisible) { setLastPanelVisible(panelVisible) setSliding(true) } useEffect(() => { if (!sliding) return const timer = setTimeout(() => setSliding(false), 400) return () => clearTimeout(timer) }, [sliding]) // The panel floats on the right; the canvas ends where it begins const canvasRight = panelVisible ? panelWidth + PANEL_GAP * 2 : 0 return (
{/* One canvas for both layouts: a new one would reload draw.io and lose its undo history */}
{isMobile ? ( mobileView === "chat" && (showHero ? (
{ setHeroDismissed(true) setMobileView("canvas") }} />
) : ( )) ) : ( <> {panelVisible && ( )} {/* Below draw.io's toolbar (50 px), whose right-hand buttons it would cover */} {!showHero && !panelOpen && (
setPanelOpen(true)} data-testid="show-panel" >
)} {showHero && ( <>
)} )}
{isMobile && ( )}
{ // The opened diagram hides the start screen; a file // that is not one leaves it const file = event.target.files?.[0] if (file) engine.openDiagramFile(file) event.target.value = "" }} /> saveDiagramToFile( filename, format, engine.langfuseSessionId, dict.save.savedSuccessfully, ) } defaultFilename={exportFilename(engine.currentTitle)} />
) }