Compare commits

...

4 Commits

Author SHA1 Message Date
dayuan.jiang
cc6f09615d fix: restore diagram when chartXML changes after iframe ready
The previous logic only restored on iframe ready, but chartXML might
be set AFTER the iframe is ready (session loaded after iframe).

Now we track the last restored XML and load whenever chartXML changes
to a new real diagram while iframe is ready.
2026-01-27 10:10:43 +09:00
dayuan.jiang
be20c09c89 fix: wait for canPersist check before rendering DrawIoEmbed
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.
2026-01-27 09:56:33 +09:00
dayuan.jiang
76487fb7ea fix: include canPersist in DrawIoEmbed key for proper remount
Address review comments: canPersist is set async after mount, so DrawIoEmbed
needs to remount when it resolves to apply correct configuration and URL params.
2026-01-27 09:38:35 +09:00
dayuan.jiang
c5de1a16ad fix(electron): prevent beforeunload prompt by using autosave
- Enable draw.io autosave and handle autosave events to update chartXML
- Clear modified state after autosave to avoid beforeunload prompts
- Disable confirmExit in draw.io configuration
- Set modified=false and keepmodified=false URL parameters
- Fix session save condition to also save when only diagram exists
- Fix: Do not close shared IndexedDB connection in isIndexedDBUsable()
2026-01-27 09:23:03 +09:00
4 changed files with 86 additions and 37 deletions

View File

@@ -11,10 +11,16 @@ import {
} from "@/components/ui/resizable" } from "@/components/ui/resizable"
import { useDiagram } from "@/contexts/diagram-context" import { useDiagram } from "@/contexts/diagram-context"
import { i18n, type Locale } from "@/lib/i18n/config" import { i18n, type Locale } from "@/lib/i18n/config"
import { isIndexedDBUsable } from "@/lib/session-storage"
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } = const {
useDiagram() drawioRef,
handleDiagramExport,
handleDiagramAutoSave,
onDrawioLoad,
resetDrawioReady,
} = useDiagram()
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Extract current language from pathname (e.g., "/zh/about" → "zh") // Extract current language from pathname (e.g., "/zh/about" → "zh")
@@ -26,6 +32,8 @@ export default function Home() {
const [isLoaded, setIsLoaded] = useState(false) const [isLoaded, setIsLoaded] = useState(false)
const [isDrawioReady, setIsDrawioReady] = useState(false) const [isDrawioReady, setIsDrawioReady] = useState(false)
const [isElectron, setIsElectron] = useState(false) const [isElectron, setIsElectron] = useState(false)
const [canPersist, setCanPersist] = useState(false)
const [canPersistChecked, setCanPersistChecked] = useState(false)
const [drawioBaseUrl, setDrawioBaseUrl] = useState( const [drawioBaseUrl, setDrawioBaseUrl] = useState(
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net", process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net",
) )
@@ -76,6 +84,11 @@ export default function Home() {
setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`) setDrawioBaseUrl(`${window.location.origin}/drawio/index.html`)
} }
void (async () => {
const usable = await isIndexedDBUsable()
setCanPersist(usable)
setCanPersistChecked(true)
})()
setIsLoaded(true) setIsLoaded(true)
}, [pathname, router]) }, [pathname, router])
@@ -84,6 +97,17 @@ export default function Home() {
onDrawioLoad() onDrawioLoad()
}, [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 handleDarkModeChange = () => {
const newValue = !darkMode const newValue = !darkMode
setDarkMode(newValue) setDarkMode(newValue)
@@ -167,20 +191,32 @@ export default function Home() {
}`} }`}
> >
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative"> <div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
{isLoaded && ( {isLoaded && canPersistChecked && (
<div <div
className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`} className={`h-full w-full ${isDrawioReady ? "" : "invisible absolute inset-0"}`}
> >
<DrawIoEmbed <DrawIoEmbed
key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`} key={`${drawioUi}-${darkMode}-${currentLang}-${isElectron}`}
ref={drawioRef} ref={drawioRef}
autosave
onAutoSave={handleDrawioAutoSave}
onExport={handleDiagramExport} onExport={handleDiagramExport}
onLoad={handleDrawioLoad} onLoad={handleDrawioLoad}
baseUrl={drawioBaseUrl} baseUrl={drawioBaseUrl}
configuration={
canPersist
? { confirmExit: false }
: undefined
}
urlParameters={{ urlParameters={{
ui: drawioUi, ui: drawioUi,
spin: false, spin: false,
libraries: false, libraries: false,
// Disable modified tracking only when persistence is available
...(canPersist && {
modified: false,
keepmodified: false,
}),
saveAndExit: false, saveAndExit: false,
noSaveBtn: true, noSaveBtn: true,
noExitBtn: true, noExitBtn: true,

View File

@@ -137,26 +137,22 @@ export default function ChatPanel({
const onFetchChart = (saveToHistory = true) => { const onFetchChart = (saveToHistory = true) => {
return Promise.race([ return Promise.race([
new Promise<string>((resolve) => { new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) { resolverRef.current = resolve
resolverRef.current = resolve
}
if (saveToHistory) { if (saveToHistory) {
onExport() onExport()
} else { } else {
handleExportWithoutHistory() handleExportWithoutHistory()
} }
}), }),
new Promise<string>((_, reject) => new Promise<string>((_, reject) => {
setTimeout( const currentResolver = resolverRef.current
() => setTimeout(() => {
reject( if (resolverRef.current === currentResolver) {
new Error( resolverRef.current = null
"Chart export timed out after 10 seconds", }
), reject(new Error("Chart export timed out after 10 seconds"))
), }, 10000)
10000, }),
),
),
]) ])
} }
@@ -677,7 +673,7 @@ export default function ChatPanel({
// Debounce: save after 1 second of no changes // Debounce: save after 1 second of no changes
localStorageDebounceRef.current = setTimeout(async () => { localStorageDebounceRef.current = setTimeout(async () => {
try { try {
if (messages.length > 0) { if (messages.length > 0 || hasDiagramNow) {
const sessionData = await buildSessionData({ const sessionData = await buildSessionData({
// Only capture thumbnail if there was a diagram AND this isn't a no-diagram session // Only capture thumbnail if there was a diagram AND this isn't a no-diagram session
withThumbnail: hasDiagramNow && !isNodiagramSession, withThumbnail: hasDiagramNow && !isNodiagramSession,
@@ -699,6 +695,7 @@ export default function ChatPanel({
} }
} }
}, [ }, [
chartXML,
messages, messages,
status, status,
sessionIsAvailable, sessionIsAvailable,
@@ -729,7 +726,8 @@ export default function ChatPanel({
const handleVisibilityChange = async () => { const handleVisibilityChange = async () => {
if ( if (
document.visibilityState === "hidden" && document.visibilityState === "hidden" &&
messagesRef.current.length > 0 (messagesRef.current.length > 0 ||
isRealDiagram(chartXMLRef.current))
) { ) {
try { try {
// Attempt to save session - browser may not wait for completion // Attempt to save session - browser may not wait for completion

View File

@@ -20,9 +20,10 @@ interface DiagramContextType {
loadDiagram: (chart: string, skipValidation?: boolean) => string | null loadDiagram: (chart: string, skipValidation?: boolean) => string | null
handleExport: () => void handleExport: () => void
handleExportWithoutHistory: () => void handleExportWithoutHistory: () => void
resolverRef: React.Ref<((value: string) => void) | null> resolverRef: React.MutableRefObject<((value: string) => void) | null>
drawioRef: React.Ref<DrawIoEmbedRef | null> drawioRef: React.MutableRefObject<DrawIoEmbedRef | null>
handleDiagramExport: (data: any) => void handleDiagramExport: (data: any) => void
handleDiagramAutoSave: (data: { xml?: string }) => void
clearDiagram: () => void clearDiagram: () => void
saveDiagramToFile: ( saveDiagramToFile: (
filename: string, filename: string,
@@ -56,8 +57,6 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
const pngResolverRef = useRef<((value: string) => void) | null>(null) const pngResolverRef = useRef<((value: string) => void) | null>(null)
// Track if we're expecting an export for history (user-initiated) // Track if we're expecting an export for history (user-initiated)
const expectHistoryExportRef = useRef<boolean>(false) const expectHistoryExportRef = useRef<boolean>(false)
// Track if diagram has been restored after DrawIO remount (e.g., theme change)
const hasDiagramRestoredRef = useRef<boolean>(false)
// Track latest chartXML for restoration after remount // Track latest chartXML for restoration after remount
const chartXMLRef = useRef<string>("") const chartXMLRef = useRef<string>("")
@@ -79,22 +78,19 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
}, [chartXML]) }, [chartXML])
// Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change) // Restore diagram when DrawIO becomes ready after remount (e.g., theme/UI change)
// Also restore when chartXML changes while DrawIO is ready (e.g., session loaded after iframe ready)
const lastRestoredXmlRef = useRef<string>("")
useEffect(() => { useEffect(() => {
// Reset restore flag when DrawIO is not ready (preparing for next restore cycle) if (!isDrawioReady || !drawioRef.current) return
if (!isDrawioReady) { // Only load if we have a real diagram and it's different from what we already loaded
hasDiagramRestoredRef.current = false if (
return isRealDiagram(chartXML) &&
chartXML !== lastRestoredXmlRef.current
) {
lastRestoredXmlRef.current = chartXML
drawioRef.current.load({ xml: chartXML })
} }
// Only restore once per ready cycle }, [isDrawioReady, chartXML])
if (hasDiagramRestoredRef.current) return
hasDiagramRestoredRef.current = true
// Restore diagram from ref if we have one
const xmlToRestore = chartXMLRef.current
if (isRealDiagram(xmlToRestore) && drawioRef.current) {
drawioRef.current.load({ xml: xmlToRestore })
}
}, [isDrawioReady])
// Track if we're expecting an export for file save (stores raw export data) // Track if we're expecting an export for file save (stores raw export data)
const saveResolverRef = useRef<{ const saveResolverRef = useRef<{
@@ -267,6 +263,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
} }
} }
const handleDiagramAutoSave = (data: { xml?: string }) => {
if (!data?.xml) return
setChartXML(data.xml)
}
const clearDiagram = () => { const clearDiagram = () => {
const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>` const emptyDiagram = `<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
// Skip validation for trusted internal template (loadDiagram also sets chartXML) // Skip validation for trusted internal template (loadDiagram also sets chartXML)
@@ -391,6 +392,7 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
resolverRef, resolverRef,
drawioRef, drawioRef,
handleDiagramExport, handleDiagramExport,
handleDiagramAutoSave,
clearDiagram, clearDiagram,
saveDiagramToFile, saveDiagramToFile,
getThumbnailSvg, getThumbnailSvg,

View File

@@ -75,6 +75,19 @@ export function isIndexedDBAvailable(): boolean {
} }
} }
// Check if IndexedDB is actually usable (not just present).
// Note: Do NOT close the db here - getDB() returns a shared singleton connection
// that other code depends on.
export async function isIndexedDBUsable(): Promise<boolean> {
if (!isIndexedDBAvailable()) return false
try {
await getDB()
return true
} catch {
return false
}
}
// CRUD Operations // CRUD Operations
export async function getAllSessionMetadata(): Promise<SessionMetadata[]> { export async function getAllSessionMetadata(): Promise<SessionMetadata[]> {
if (!isIndexedDBAvailable()) return [] if (!isIndexedDBAvailable()) return []