mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
Fixes diagram not restoring after refresh. The issue was: 1. DrawIoEmbed rendered with canPersist=false 2. Session restore loaded diagram into iframe 3. canPersist changed to true, causing iframe remount 4. New iframe loaded empty, losing the diagram Now we wait for isIndexedDBUsable() to complete before rendering, avoiding the remount entirely.
283 lines
12 KiB
TypeScript
283 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 { 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<"min" | "sketch">("min")
|
|
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 (savedUi === "min" || savedUi === "sketch") {
|
|
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)
|
|
// Only suppress modified state when persistence is available
|
|
if (canPersist) {
|
|
drawioRef.current?.status({ message: "", modified: false })
|
|
}
|
|
},
|
|
[canPersist, drawioRef, 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 = () => {
|
|
const newUi = drawioUi === "min" ? "sketch" : "min"
|
|
localStorage.setItem("drawio-theme", newUi)
|
|
setDrawioUi(newUi)
|
|
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,
|
|
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}
|
|
onToggleDrawioUi={handleDrawioUiChange}
|
|
darkMode={darkMode}
|
|
onToggleDarkMode={handleDarkModeChange}
|
|
isMobile={isMobile}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
)
|
|
}
|