diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 66e6a51c..7237294d 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -68,6 +68,9 @@ import { MIN_MEMO_LIST_WIDTH_PX, MAX_MEMO_LIST_WIDTH_PX, DEFAULT_MEMO_LIST_WIDTH_PX, + MIN_NOTEBOOK_SIDEBAR_WIDTH_PX, + MAX_NOTEBOOK_SIDEBAR_WIDTH_PX, + DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX, EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, isTextEntryTarget, getSearchShortcutScope, @@ -500,11 +503,14 @@ export const WorkspaceApp = ({ imageCompressionEnabled, memoListWidth, notebookSidebarCollapsed, + notebookSidebarWidth, resetMemoListWidth, + resetNotebookSidebarWidth, setDesktopFocusMode, setImageCompressionEnabled, setMemoListWidth, setNotebookSidebarCollapsed, + setNotebookSidebarWidth, setShortcutSettings, shortcutSettings, } = useWorkspacePreferences(); @@ -2960,6 +2966,64 @@ export const WorkspaceApp = ({ window.addEventListener("pointerup", handlePointerUp); }; + const handleNotebookSidebarResizePointerDown = (event: ReactPointerEvent) => { + if (!isDesktopViewport() || desktopNotebookSidebarCollapsed) { + return; + } + + event.preventDefault(); + event.currentTarget.focus({ preventScroll: true }); + const startX = event.clientX; + const startWidth = notebookSidebarWidth; + + const handlePointerMove = (moveEvent: PointerEvent) => { + setNotebookSidebarWidth(startWidth + moveEvent.clientX - startX); + }; + + const handlePointerUp = () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + }; + + const handleNotebookSidebarResizeKeyDown = (event: ReactKeyboardEvent) => { + if (!isDesktopViewport() || desktopNotebookSidebarCollapsed) { + return; + } + + const step = event.shiftKey ? 48 : 16; + let nextWidth: number | null = null; + + if (event.key === "ArrowLeft") { + nextWidth = notebookSidebarWidth - step; + } else if (event.key === "ArrowRight") { + nextWidth = notebookSidebarWidth + step; + } else if (event.key === "Home") { + nextWidth = MIN_NOTEBOOK_SIDEBAR_WIDTH_PX; + } else if (event.key === "End") { + nextWidth = MAX_NOTEBOOK_SIDEBAR_WIDTH_PX; + } else if (event.key === "Enter" || event.key === " ") { + nextWidth = DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX; + } + + if (nextWidth === null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setNotebookSidebarWidth(nextWidth); + }; + const handleResetMemoListWidth = () => { resetMemoListWidth(); }; @@ -3064,12 +3128,15 @@ export const WorkspaceApp = ({ : "edgeever-workspace-grid--single-right", !desktopFocusModeActive && desktopNotebookSidebarCollapsed && "edgeever-workspace-grid--sidebar-collapsed" )} - style={{ "--memo-list-width": `${memoListWidth}px` } as CSSProperties} + style={{ + "--memo-list-width": `${memoListWidth}px`, + "--notebook-sidebar-expanded-width": `${notebookSidebarWidth}px`, + } as CSSProperties} >
{ const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); const [desktopFocusMode, setDesktopFocusModeState] = useState(readDesktopFocusModePreference); const [notebookSidebarCollapsed, setNotebookSidebarCollapsedState] = useState(readNotebookSidebarCollapsedPreference); + const [notebookSidebarWidth, setNotebookSidebarWidthState] = useState(readNotebookSidebarWidthPreference); const [shortcutSettings, setShortcutSettings] = useState(readShortcutSettingsPreference); const [memoListWidth, setMemoListWidthState] = useState(readMemoListWidthPreference); @@ -41,20 +46,33 @@ export const useWorkspacePreferences = () => { writeMemoListWidthPreference(nextWidth); }, []); + const setNotebookSidebarWidth = useCallback((width: number) => { + const nextWidth = clampNotebookSidebarWidth(width); + setNotebookSidebarWidthState(nextWidth); + writeNotebookSidebarWidthPreference(nextWidth); + }, []); + const resetMemoListWidth = useCallback(() => { setMemoListWidth(DEFAULT_MEMO_LIST_WIDTH_PX); }, [setMemoListWidth]); + const resetNotebookSidebarWidth = useCallback(() => { + setNotebookSidebarWidth(DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX); + }, [setNotebookSidebarWidth]); + return { desktopFocusMode, imageCompressionEnabled, memoListWidth, notebookSidebarCollapsed, + notebookSidebarWidth, resetMemoListWidth, + resetNotebookSidebarWidth, setDesktopFocusMode, setNotebookSidebarCollapsed, setImageCompressionEnabled, setMemoListWidth, + setNotebookSidebarWidth, setShortcutSettings, shortcutSettings, }; diff --git a/apps/web/src/lib/app-helpers.test.mjs b/apps/web/src/lib/app-helpers.test.mjs index 7e935509..3ee0b693 100644 --- a/apps/web/src/lib/app-helpers.test.mjs +++ b/apps/web/src/lib/app-helpers.test.mjs @@ -5,6 +5,10 @@ import { DESKTOP_FOCUS_MODE_STORAGE_KEY, DESKTOP_READING_PROTECTION_STORAGE_KEY, NOTEBOOK_SIDEBAR_COLLAPSED_STORAGE_KEY, + NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY, + DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX, + MIN_NOTEBOOK_SIDEBAR_WIDTH_PX, + MAX_NOTEBOOK_SIDEBAR_WIDTH_PX, EDITOR_OUTLINE_COLLAPSED_STORAGE_KEY, EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY, EDITOR_TOOLBAR_EXPANDED_STORAGE_KEY, @@ -17,6 +21,7 @@ import { getCreateMemoNotebookId, getShortcutActionForEvent, canToggleNotebookSidebar, + clampNotebookSidebarWidth, getNotebookSortComparator, readEditorContentAlignmentPreference, readNotebookSortPreference, @@ -24,6 +29,7 @@ import { readDesktopFocusModePreference, readDesktopReadingProtectionPreference, readNotebookSidebarCollapsedPreference, + readNotebookSidebarWidthPreference, readEditorOutlineCollapsedPreference, readEditorToolbarExpandedPreference, readEditorPhonePreviewPreference, @@ -35,6 +41,7 @@ import { writeDesktopFocusModePreference, writeDesktopReadingProtectionPreference, writeNotebookSidebarCollapsedPreference, + writeNotebookSidebarWidthPreference, writeEditorOutlineCollapsedPreference, writeEditorToolbarExpandedPreference, writeEditorPhonePreviewPreference, @@ -163,6 +170,49 @@ describe("notebook sidebar collapsed preference", () => { }); }); +describe("notebook sidebar width preference", () => { + test("defaults to the standard width and clamps stored values", () => { + const values = installLocalStorage(); + expect(readNotebookSidebarWidthPreference()).toBe(DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX); + + values.set(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY, String(MIN_NOTEBOOK_SIDEBAR_WIDTH_PX - 100)); + expect(readNotebookSidebarWidthPreference()).toBe(MIN_NOTEBOOK_SIDEBAR_WIDTH_PX); + + values.set(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY, String(MAX_NOTEBOOK_SIDEBAR_WIDTH_PX + 100)); + expect(readNotebookSidebarWidthPreference()).toBe(MAX_NOTEBOOK_SIDEBAR_WIDTH_PX); + + values.set(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY, "invalid"); + expect(readNotebookSidebarWidthPreference()).toBe(DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX); + }); + + test("rounds, clamps, and persists widths", () => { + const values = installLocalStorage(); + expect(clampNotebookSidebarWidth(253.6)).toBe(254); + + writeNotebookSidebarWidthPreference(253.6); + expect(values.get(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY)).toBe("254"); + + writeNotebookSidebarWidthPreference(MAX_NOTEBOOK_SIDEBAR_WIDTH_PX + 100); + expect(values.get(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY)).toBe(String(MAX_NOTEBOOK_SIDEBAR_WIDTH_PX)); + }); + + test("falls back safely when local storage is unavailable", () => { + globalThis.window = { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }, + }; + + expect(readNotebookSidebarWidthPreference()).toBe(DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX); + expect(() => writeNotebookSidebarWidthPreference(280)).not.toThrow(); + }); +}); + describe("editor toolbar expanded preference", () => { test("defaults to collapsed and only accepts an explicit true value", () => { const values = installLocalStorage(); diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index dea28645..10bb8ee6 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -142,6 +142,7 @@ export { DEFAULT_MEMO_TITLE }; export const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled"; export const DESKTOP_FOCUS_MODE_STORAGE_KEY = "edgeever.desktopFocusMode"; export const NOTEBOOK_SIDEBAR_COLLAPSED_STORAGE_KEY = "edgeever.notebookSidebarCollapsed"; +export const NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY = "edgeever.notebookSidebarWidth"; 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"; @@ -156,6 +157,9 @@ export const SHORTCUT_SETTINGS_STORAGE_KEY = "edgeever.shortcutSettings"; export const DEFAULT_MEMO_LIST_WIDTH_PX = 360; export const MIN_MEMO_LIST_WIDTH_PX = 300; export const MAX_MEMO_LIST_WIDTH_PX = 540; +export const DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX = 240; +export const MIN_NOTEBOOK_SIDEBAR_WIDTH_PX = 180; +export const MAX_NOTEBOOK_SIDEBAR_WIDTH_PX = 420; export const EDITOR_LOCAL_SAVE_DELAY_MS = 1_200; export const MEMO_DRAG_MIME = "application/x-edgeever-memos"; @@ -329,6 +333,28 @@ export const writeNotebookSidebarCollapsedPreference = (collapsed: boolean) => { } }; +export const clampNotebookSidebarWidth = (width: number) => + Math.min(MAX_NOTEBOOK_SIDEBAR_WIDTH_PX, Math.max(MIN_NOTEBOOK_SIDEBAR_WIDTH_PX, Math.round(width))); + +export const readNotebookSidebarWidthPreference = () => { + try { + const storedWidth = window.localStorage.getItem(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY); + if (storedWidth === null) return DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX; + const width = Number(storedWidth); + return Number.isFinite(width) ? clampNotebookSidebarWidth(width) : DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX; + } catch { + return DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX; + } +}; + +export const writeNotebookSidebarWidthPreference = (width: number) => { + try { + window.localStorage.setItem(NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY, String(clampNotebookSidebarWidth(width))); + } catch { + // Local storage can be unavailable in private or restricted browser contexts. + } +}; + export const readDesktopReadingProtectionPreference = () => { try { return window.localStorage.getItem(DESKTOP_READING_PROTECTION_STORAGE_KEY) === "true"; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 652a9665..76c1ca92 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -585,7 +585,7 @@ html[data-edgeever-extension-theme] kbd { } .edgeever-workspace-grid { - --notebook-sidebar-width: clamp(180px, 17vw, 260px); + --notebook-sidebar-width: var(--notebook-sidebar-expanded-width, clamp(180px, 17vw, 260px)); } .edgeever-workspace-grid--sidebar-collapsed { diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 23ba6b65..5b9bbf1f 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -2734,6 +2734,7 @@ export const enUS = { deleteNotebookNotEmpty: "This notebook or one of its child notebooks still has notes. Move or delete those notes, then try again.", deleteNotebookFailed: "The notebook is still here. Please try again.", ok: "OK", + resizeNotebookSidebar: "Resize notebook pane", resizeMemoList: "Resize note list", resizeMemoListHint: "Drag to resize the list column, double-click to reset, or use arrow keys for fine tuning", }, diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 91fc0e89..6bdb0715 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2723,6 +2723,7 @@ export const ja = { deleteNotebookNotEmpty: "このノートブックか子ノートブックにノートが残っています。先にノートを移動または削除してから、もう一度お試しください。", deleteNotebookFailed: "ノートブックは残っています。しばらくしてからもう一度お試しください。", ok: "OK", + resizeNotebookSidebar: "ノートブック欄の幅を変更", resizeMemoList: "ノート一覧の幅を変更", resizeMemoListHint: "ドラッグで一覧欄の幅を変え、ダブルクリックでリセット、矢印キーで微調整できます", }, diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 13c72dba..6a9e03d1 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2723,6 +2723,7 @@ export const pl = { deleteNotebookNotEmpty: "Ten notatnik lub jeden z jego notatników podrzędnych nadal zawiera notatki. Przenieś lub usuń te notatki, a następnie spróbuj ponownie.", deleteNotebookFailed: "Notatnik nadal istnieje. Spróbuj ponownie.", ok: "OK", + resizeNotebookSidebar: "Zmień szerokość panelu notatników", resizeMemoList: "Zmień szerokość listy notatek", resizeMemoListHint: "Przeciągnij, aby zmienić szerokość kolumny listy, kliknij dwukrotnie, aby przywrócić domyślną, lub użyj klawiszy strzałek do precyzyjnej regulacji", }, diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 77833446..5b027461 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2732,6 +2732,7 @@ export const zhCN = { deleteNotebookNotEmpty: "这个笔记本或它的子笔记本里还有笔记。请先把笔记移走或删除,再删除笔记本。", deleteNotebookFailed: "删除没有完成,笔记本还在。请稍后重试。", ok: "知道了", + resizeNotebookSidebar: "调整笔记本栏宽度", resizeMemoList: "调整笔记列表宽度", resizeMemoListHint: "拖拽调整列表栏宽度,双击恢复默认,方向键微调", }, diff --git a/tests/e2e/notebook-sidebar-resize.spec.ts b/tests/e2e/notebook-sidebar-resize.spec.ts new file mode 100644 index 00000000..64b392d4 --- /dev/null +++ b/tests/e2e/notebook-sidebar-resize.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +const NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY = "edgeever.notebookSidebarWidth"; + +test("resizes the notebook pane from a fully clickable drag handle", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/"); + await page.evaluate((storageKey) => window.localStorage.removeItem(storageKey), NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY); + await page.reload(); + + const sidebar = page.locator("#edgeever-notebook-sidebar"); + const handle = page.getByRole("separator", { name: "调整笔记本栏宽度" }); + await expect(sidebar).toBeVisible(); + await expect(handle).toBeVisible(); + await expect(handle).toHaveCSS("touch-action", "none"); + + const sidebarBox = await sidebar.boundingBox(); + const handleBox = await handle.boundingBox(); + expect(sidebarBox).not.toBeNull(); + expect(handleBox).not.toBeNull(); + expect(handleBox!.x).toBeGreaterThanOrEqual(sidebarBox!.x); + expect(handleBox!.x + handleBox!.width).toBeLessThanOrEqual(sidebarBox!.x + sidebarBox!.width + 0.5); + + const initialWidth = Number(await handle.getAttribute("aria-valuenow")); + const dragDistance = 64; + await page.mouse.move(handleBox!.x + handleBox!.width / 2, handleBox!.y + handleBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(handleBox!.x + handleBox!.width / 2 + dragDistance, handleBox!.y + handleBox!.height / 2, { steps: 4 }); + await page.mouse.up(); + + const resizedWidth = initialWidth + dragDistance; + await expect(handle).toHaveAttribute("aria-valuenow", String(resizedWidth)); + expect(await page.evaluate((storageKey) => window.localStorage.getItem(storageKey), NOTEBOOK_SIDEBAR_WIDTH_STORAGE_KEY)).toBe(String(resizedWidth)); + + await page.reload(); + await expect(page.getByRole("separator", { name: "调整笔记本栏宽度" })).toHaveAttribute("aria-valuenow", String(resizedWidth)); +});