mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-01 17:10:24 +08:00
* feat: add all Draw.io themes to settings panel Add all available Draw.io themes (kennedy, atlas, dark, min, sketch, simple) to the settings panel dropdown. Previously only min and sketch were available as a toggle button. Changes: - Replace the Draw.io style toggle button with a dropdown selector - Expand theme type from "min" | "sketch" to include all 6 themes - Update localStorage validation to accept all themes - Update handler from toggle to direct theme selection Closes #499 * fix: localize theme labels, tighten DrawioTheme typing, sync dark param - Move DRAWIO_THEMES + DrawioTheme to lib/drawio-themes.ts; reuse in page.tsx, chat-panel.tsx and settings-dialog.tsx instead of `string` - Localize theme dropdown labels (Dark/Minimal/Sketch/Simple) in en/zh/ja/zh-Hant; keep proper-noun themes (Kennedy/Atlas) as-is - Drop trailing colon from drawioStyleDescription and remove dead switchTo/minimal/sketch keys in all 4 dictionaries - Auto-sync drawio dark URL param when ui="dark" is selected - Add aria-label to drawio-style SelectTrigger * fix: use kennedy as default theme and label it "Default" --------- Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
"use client"
|
|
import { usePathname, useRouter } from "next/navigation"
|
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react"
|
|
import { DrawIoEmbed } from "react-drawio"
|
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
|
import ChatPanel from "@/components/chat-panel"
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable"
|
|
import { useDiagram } from "@/contexts/diagram-context"
|
|
import { type DrawioTheme, isDrawioTheme } from "@/lib/drawio-themes"
|
|
import { i18n, type Locale } from "@/lib/i18n/config"
|
|
import { isIndexedDBUsable } from "@/lib/session-storage"
|
|
|
|
export default function Home() {
|
|
const {
|
|
drawioRef,
|
|
handleDiagramExport,
|
|
handleDiagramAutoSave,
|
|
onDrawioLoad,
|
|
resetDrawioReady,
|
|
} = useDiagram()
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
|
const currentLang = (pathname.split("/")[1] || i18n.defaultLocale) as Locale
|
|
const [isMobile, setIsMobile] = useState(false)
|
|
const [isChatVisible, setIsChatVisible] = useState(true)
|
|
const [drawioUi, setDrawioUi] = useState<DrawioTheme>("kennedy")
|
|
const [darkMode, setDarkMode] = useState(false)
|
|
const [isLoaded, setIsLoaded] = useState(false)
|
|
const [isDrawioReady, setIsDrawioReady] = useState(false)
|
|
const [isElectron, setIsElectron] = useState(false)
|
|
const [canPersist, setCanPersist] = useState(false)
|
|
const [canPersistChecked, setCanPersistChecked] = useState(false)
|
|
const [drawioBaseUrl, setDrawioBaseUrl] = useState(
|
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
|
|
)
|
|
|
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
|
const isMobileRef = useRef(false)
|
|
|
|
// Load preferences from localStorage after mount
|
|
useEffect(() => {
|
|
// Restore saved locale and redirect if needed
|
|
const savedLocale = localStorage.getItem("next-ai-draw-io-locale")
|
|
if (savedLocale && i18n.locales.includes(savedLocale as Locale)) {
|
|
const pathParts = pathname.split("/").filter(Boolean)
|
|
const currentLocale = pathParts[0]
|
|
if (currentLocale !== savedLocale) {
|
|
pathParts[0] = savedLocale
|
|
router.replace(`/${pathParts.join("/")}`)
|
|
return // Wait for redirect
|
|
}
|
|
}
|
|
|
|
const savedUi = localStorage.getItem("drawio-theme")
|
|
if (isDrawioTheme(savedUi)) {
|
|
setDrawioUi(savedUi)
|
|
}
|
|
|
|
const savedDarkMode = localStorage.getItem("next-ai-draw-io-dark-mode")
|
|
if (savedDarkMode !== null) {
|
|
const isDark = savedDarkMode === "true"
|
|
setDarkMode(isDark)
|
|
document.documentElement.classList.toggle("dark", isDark)
|
|
} else {
|
|
const prefersDark = window.matchMedia(
|
|
"(prefers-color-scheme: dark)",
|
|
).matches
|
|
setDarkMode(prefersDark)
|
|
document.documentElement.classList.toggle("dark", prefersDark)
|
|
}
|
|
|
|
// Detect Electron and use bundled draw.io files for offline use
|
|
// Note: react-drawio uses `new URL(baseUrl)` so we need absolute URL
|
|
// Include /index.html because Next.js doesn't auto-serve index.html for directories
|
|
const electronDetected =
|
|
!process.env.NEXT_PUBLIC_DRAWIO_BASE_URL &&
|
|
!!(window as unknown as { electronAPI?: unknown }).electronAPI
|
|
if (electronDetected) {
|
|
setIsElectron(true)
|
|
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
|
|
}
|
|
|
|
void (async () => {
|
|
const usable = await isIndexedDBUsable()
|
|
setCanPersist(usable)
|
|
setCanPersistChecked(true)
|
|
})()
|
|
setIsLoaded(true)
|
|
}, [pathname, router])
|
|
|
|
const handleDrawioLoad = useCallback(() => {
|
|
setIsDrawioReady(true)
|
|
onDrawioLoad()
|
|
}, [onDrawioLoad])
|
|
|
|
const handleDrawioAutoSave = useCallback(
|
|
(data: { xml?: string }) => {
|
|
handleDiagramAutoSave(data)
|
|
},
|
|
[handleDiagramAutoSave],
|
|
)
|
|
|
|
const handleDarkModeChange = () => {
|
|
const newValue = !darkMode
|
|
setDarkMode(newValue)
|
|
localStorage.setItem("next-ai-draw-io-dark-mode", String(newValue))
|
|
document.documentElement.classList.toggle("dark", newValue)
|
|
setIsDrawioReady(false)
|
|
resetDrawioReady()
|
|
}
|
|
|
|
const handleDrawioUiChange = (theme: DrawioTheme) => {
|
|
localStorage.setItem("drawio-theme", theme)
|
|
setDrawioUi(theme)
|
|
setIsDrawioReady(false)
|
|
resetDrawioReady()
|
|
}
|
|
|
|
// Check mobile - reset draw.io before crossing breakpoint
|
|
const isInitialRenderRef = useRef(true)
|
|
useEffect(() => {
|
|
const checkMobile = () => {
|
|
const newIsMobile = window.innerWidth < 768
|
|
if (
|
|
!isInitialRenderRef.current &&
|
|
newIsMobile !== isMobileRef.current
|
|
) {
|
|
setIsDrawioReady(false)
|
|
resetDrawioReady()
|
|
}
|
|
isMobileRef.current = newIsMobile
|
|
isInitialRenderRef.current = false
|
|
setIsMobile(newIsMobile)
|
|
}
|
|
|
|
checkMobile()
|
|
window.addEventListener("resize", checkMobile)
|
|
return () => window.removeEventListener("resize", checkMobile)
|
|
}, [resetDrawioReady])
|
|
|
|
const toggleChatPanel = () => {
|
|
const panel = chatPanelRef.current
|
|
if (panel) {
|
|
if (panel.isCollapsed()) {
|
|
panel.expand()
|
|
setIsChatVisible(true)
|
|
} else {
|
|
panel.collapse()
|
|
setIsChatVisible(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyboard shortcut for toggling chat panel
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === "b") {
|
|
event.preventDefault()
|
|
toggleChatPanel()
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handleKeyDown)
|
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="h-screen bg-background relative overflow-hidden">
|
|
<ResizablePanelGroup
|
|
id="main-panel-group"
|
|
direction={isMobile ? "vertical" : "horizontal"}
|
|
className="h-full"
|
|
>
|
|
<ResizablePanel
|
|
id="drawio-panel"
|
|
defaultSize={isMobile ? 50 : 67}
|
|
minSize={20}
|
|
>
|
|
<div
|
|
className={`h-full relative ${
|
|
isMobile ? "p-1" : "p-2"
|
|
}`}
|
|
>
|
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
|
{isLoaded && canPersistChecked && (
|
|
<div
|
|
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
|
|
>
|
|
<DrawIoEmbed
|
|
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
|
|
ref={drawioRef}
|
|
autosave
|
|
onAutoSave={handleDrawioAutoSave}
|
|
onExport={handleDiagramExport}
|
|
onLoad={handleDrawioLoad}
|
|
baseUrl={drawioBaseUrl}
|
|
configuration={
|
|
canPersist
|
|
? { confirmExit: false }
|
|
: undefined
|
|
}
|
|
urlParameters={{
|
|
ui: drawioUi,
|
|
spin: false,
|
|
libraries: false,
|
|
// Disable modified tracking only when persistence is available
|
|
...(canPersist && {
|
|
modified: false,
|
|
keepmodified: false,
|
|
}),
|
|
saveAndExit: false,
|
|
noSaveBtn: true,
|
|
noExitBtn: true,
|
|
dark:
|
|
darkMode || drawioUi === "dark",
|
|
lang: currentLang,
|
|
// Enable offline mode in Electron to disable external service calls
|
|
...(isElectron && {
|
|
offline: true,
|
|
}),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{(!isLoaded || !isDrawioReady) && (
|
|
<div className="h-full w-full bg-background flex items-center justify-center">
|
|
<span className="text-muted-foreground">
|
|
Draw.io panel is loading...
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* Chat Panel */}
|
|
<ResizablePanel
|
|
key={isMobile ? "mobile" : "desktop"}
|
|
id="chat-panel"
|
|
ref={chatPanelRef}
|
|
defaultSize={isMobile ? 50 : 33}
|
|
minSize={isMobile ? 20 : 15}
|
|
maxSize={isMobile ? 80 : 50}
|
|
collapsible={!isMobile}
|
|
collapsedSize={isMobile ? 0 : 3}
|
|
onCollapse={() => setIsChatVisible(false)}
|
|
onExpand={() => setIsChatVisible(true)}
|
|
>
|
|
<div className={`h-full ${isMobile ? "p-1" : "py-2 pr-2"}`}>
|
|
<Suspense
|
|
fallback={
|
|
<div className="h-full bg-card rounded-xl border border-border/30 flex items-center justify-center text-muted-foreground">
|
|
Loading chat...
|
|
</div>
|
|
}
|
|
>
|
|
<ChatPanel
|
|
isVisible={isChatVisible}
|
|
onToggleVisibility={toggleChatPanel}
|
|
drawioUi={drawioUi}
|
|
onDrawioUiChange={handleDrawioUiChange}
|
|
darkMode={darkMode}
|
|
onToggleDarkMode={handleDarkModeChange}
|
|
isMobile={isMobile}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
)
|
|
}
|