From ae464e88c48deaf2a99ccf309cd3738ace640b8e Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 4 Sep 2026 12:24:01 +0800 Subject: [PATCH] fix(web): remember document outline visibility Default the outline to collapsed and persist the user's last choice across reloads.\n\nRefs #345 --- apps/web/src/components/EditorPane.tsx | 23 +++++++++++--- apps/web/src/lib/app-helpers.test.mjs | 42 ++++++++++++++++++++++++++ apps/web/src/lib/app-helpers.ts | 17 +++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index c7e2d6ee..3747fcf1 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -158,7 +158,9 @@ import { getEditableMemoTitle, getNotebookMoveOptions, readDesktopReadingProtectionPreference, + readEditorOutlineCollapsedPreference, writeDesktopReadingProtectionPreference, + writeEditorOutlineCollapsedPreference, type EditorContentAlignment, type MemoDocumentActionRequest, type ShortcutSettings, @@ -640,7 +642,7 @@ const RichEditorPane = ({ const [markdownSource, setMarkdownSource] = useState(""); const [isMarkdownMode, setIsMarkdownMode] = useState(false); const [mobileToolbarOpen, setMobileToolbarOpen] = useState(false); - const [editorOutlineCollapsed, setEditorOutlineCollapsed] = useState(false); + const [editorOutlineCollapsed, setEditorOutlineCollapsed] = useState(readEditorOutlineCollapsedPreference); const [wechatCopyState, setWechatCopyState] = useState<"idle" | "copying" | "copied" | "error">("idle"); const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<{ status: "copied" | "error"; id: string } | null>(null); const handledSaveAndSyncTokenRef = useRef(saveAndSyncToken); @@ -714,6 +716,19 @@ const RichEditorPane = ({ }); }, []); + const handleEditorOutlineCollapsedChange = useCallback((collapsed: boolean) => { + setEditorOutlineCollapsed(collapsed); + writeEditorOutlineCollapsedPreference(collapsed); + }, []); + + const toggleEditorOutline = useCallback(() => { + setEditorOutlineCollapsed((collapsed) => { + const nextCollapsed = !collapsed; + writeEditorOutlineCollapsedPreference(nextCollapsed); + return nextCollapsed; + }); + }, []); + useEffect(() => { if (!isMobileViewport && mobileDefaultEditRequested && desktopReadingProtection) { setDesktopReadingProtection(false); @@ -3121,8 +3136,8 @@ const RichEditorPane = ({ return; } - setEditorOutlineCollapsed((current) => !current); - }, [editorShortcutBlocked, isMobileViewport, outlineToggleToken, useMarkdownSourceEditor, useMobilePlainTextEditor]); + toggleEditorOutline(); + }, [editorShortcutBlocked, isMobileViewport, outlineToggleToken, toggleEditorOutline, useMarkdownSourceEditor, useMobilePlainTextEditor]); useEffect(() => { if (handledSaveAndSyncTokenRef.current === saveAndSyncToken || saveMutationPending) { @@ -4492,7 +4507,7 @@ const RichEditorPane = ({ scrollContainer={editorScrollContainer} collapsed={editorOutlineCollapsed} shortcutLabel={formatShortcutBinding(shortcutSettings.toggleOutline)} - onCollapsedChange={setEditorOutlineCollapsed} + onCollapsedChange={handleEditorOutlineCollapsedChange} /> )} diff --git a/apps/web/src/lib/app-helpers.test.mjs b/apps/web/src/lib/app-helpers.test.mjs index 8b300104..ff244c09 100644 --- a/apps/web/src/lib/app-helpers.test.mjs +++ b/apps/web/src/lib/app-helpers.test.mjs @@ -3,6 +3,7 @@ import { DEFAULT_SHORTCUT_SETTINGS, DESKTOP_FOCUS_MODE_STORAGE_KEY, DESKTOP_READING_PROTECTION_STORAGE_KEY, + EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY, NOTEBOOK_SORT_STORAGE_KEY, SHORTCUT_SETTINGS_STORAGE_KEY, @@ -13,11 +14,13 @@ import { readNotebookSortPreference, readDesktopFocusModePreference, readDesktopReadingProtectionPreference, + readEditorOutlineCollapsedPreference, readShortcutSettingsPreference, writeEditorContentAlignmentPreference, writeNotebookSortPreference, writeDesktopFocusModePreference, writeDesktopReadingProtectionPreference, + writeEditorOutlineCollapsedPreference, } from "./app-helpers.ts"; const originalWindow = globalThis.window; @@ -130,6 +133,45 @@ describe("desktop reading protection preference", () => { }); }); +describe("editor outline preference", () => { + test("defaults to collapsed and only expands for an explicit false value", () => { + const values = installLocalStorage(); + expect(readEditorOutlineCollapsedPreference()).toBe(true); + + values.set(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, "true"); + expect(readEditorOutlineCollapsedPreference()).toBe(true); + + values.set(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, "false"); + expect(readEditorOutlineCollapsedPreference()).toBe(false); + }); + + test("persists collapsed and expanded states", () => { + const values = installLocalStorage(); + + writeEditorOutlineCollapsedPreference(false); + expect(values.get(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY)).toBe("false"); + + writeEditorOutlineCollapsedPreference(true); + expect(values.get(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY)).toBe("true"); + }); + + test("falls back to collapsed when local storage is unavailable", () => { + globalThis.window = { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }, + }; + + expect(readEditorOutlineCollapsedPreference()).toBe(true); + expect(() => writeEditorOutlineCollapsedPreference(false)).not.toThrow(); + }); +}); + describe("editor content alignment preference", () => { test("defaults to left aligned and persists both supported alignments", () => { const values = installLocalStorage(); diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index d3d2a1ba..03421589 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -131,6 +131,7 @@ export { DEFAULT_MEMO_TITLE }; export const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled"; export const DESKTOP_FOCUS_MODE_STORAGE_KEY = "edgeever.desktopFocusMode"; export const DESKTOP_READING_PROTECTION_STORAGE_KEY = "edgeever.desktopReadingProtection"; +export const EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY = "edgeever.editorOutlineCollapsed"; export const EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY = "edgeever.editorContentAlignment"; export const MEMO_LIST_DENSITY_STORAGE_KEY = "edgeever.memoListDensity"; export const MEMO_LIST_WIDTH_STORAGE_KEY = "edgeever.memoListWidth"; @@ -355,6 +356,22 @@ export const writeDesktopReadingProtectionPreference = (enabled: boolean) => { } }; +export const readEditorOutlineCollapsedPreference = () => { + try { + return window.localStorage.getItem(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY) !== "false"; + } catch { + return true; + } +}; + +export const writeEditorOutlineCollapsedPreference = (collapsed: boolean) => { + try { + window.localStorage.setItem(EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, collapsed ? "true" : "false"); + } catch { + // Local storage can be unavailable in private or restricted browser contexts. + } +}; + export const readEditorContentAlignmentPreference = (): EditorContentAlignment => { try { return window.localStorage.getItem(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY) === "center" ? "center" : "start";