diff --git a/web/src/components/MemoEditor/hooks/useAutoSave.ts b/web/src/components/MemoEditor/hooks/useAutoSave.ts index 556938f1a..3a4bf18ad 100644 --- a/web/src/components/MemoEditor/hooks/useAutoSave.ts +++ b/web/src/components/MemoEditor/hooks/useAutoSave.ts @@ -1,9 +1,42 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { cacheService } from "../services"; -export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => { +export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => { + const latestContentRef = useRef(content); + + useEffect(() => { + latestContentRef.current = content; + }, [content]); + useEffect(() => { + if (!enabled) return; + const key = cacheService.key(username, cacheKey); cacheService.save(key, content); - }, [content, username, cacheKey]); + }, [content, username, cacheKey, enabled]); + + useEffect(() => { + if (!enabled) return; + + const key = cacheService.key(username, cacheKey); + const flushDraft = () => { + cacheService.saveNow(key, latestContentRef.current); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushDraft(); + } + }; + + window.addEventListener("pagehide", flushDraft); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + // Flush on unmount (e.g. editor closes) to ensure the draft is persisted + // before the component is torn down — distinct from the visibility flush above. + flushDraft(); + window.removeEventListener("pagehide", flushDraft); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [username, cacheKey, enabled]); }; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 704a64672..51457b5d4 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorRefActions } from "../Editor"; import { cacheService, memoService } from "../services"; @@ -16,15 +16,25 @@ interface UseMemoInitOptions { export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => { const { actions, dispatch } = useEditorContext(); const initializedRef = useRef(false); + const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { if (initializedRef.current) return; initializedRef.current = true; + const key = cacheService.key(username, cacheKey); + const cachedContent = cacheService.load(key); if (memo) { - dispatch(actions.initMemo(memoService.fromMemo(memo))); + const initialState = memoService.fromMemo(memo); + // Prefer cached draft over the saved memo content when the user had unsaved + // changes (e.g. tab was suspended mid-edit). Uses strict string comparison + // against memo.content — both values come from the same proto serialization + // path, so format is consistent and whitespace differences are intentional. + if (cachedContent.trim() && cachedContent !== memo.content) { + initialState.content = cachedContent; + } + dispatch(actions.initMemo(initialState)); } else { - const cachedContent = cacheService.load(cacheService.key(username, cacheKey)); if (cachedContent) { dispatch(actions.updateContent(cachedContent)); } @@ -36,5 +46,9 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de if (autoFocus) { setTimeout(() => editorRef.current?.focus(), 100); } + + setIsInitialized(true); }, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]); + + return { isInitialized }; }; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 504ac4a23..37f7e7554 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -54,10 +54,10 @@ const MemoEditorImpl: React.FC = ({ // Get default visibility from user settings const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; - useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); // Auto-save content to localStorage - useAutoSave(state.content, currentUser?.name ?? "", cacheKey); + useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized); // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); diff --git a/web/src/components/MemoEditor/services/cacheService.ts b/web/src/components/MemoEditor/services/cacheService.ts index 5c93e3b4f..dde785cd2 100644 --- a/web/src/components/MemoEditor/services/cacheService.ts +++ b/web/src/components/MemoEditor/services/cacheService.ts @@ -26,6 +26,20 @@ export const cacheService = { pendingSaves.set(key, timeoutId); }, + saveNow: (key: string, content: string) => { + const pendingSave = pendingSaves.get(key); + if (pendingSave) { + window.clearTimeout(pendingSave); + pendingSaves.delete(key); + } + + if (content.trim()) { + localStorage.setItem(key, content); + } else { + localStorage.removeItem(key); + } + }, + load(key: string): string { return localStorage.getItem(key) || ""; },