feat(web): make notebook pane resizable (#527)

* feat(web): make notebook pane resizable

* fix(web): harden notebook pane dragging
This commit is contained in:
Thorufur authored and GitHub committed 2026-10-12 20:50:38 +08:00
1 parent 1e6a355bb5
commit 5053e0cd71
10 files changed
+220 -3

No files matched your search

+84 -2
View File
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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}
>
<aside
id="edgeever-notebook-sidebar"
className={cn(
"edgeever-workspace-sidebar min-h-0 overflow-hidden border-r",
"edgeever-workspace-sidebar relative min-h-0 overflow-hidden border-r",
desktopFocusModeActive
? "hidden"
: visibleActivePane === "notebooks"
@@ -3138,6 +3205,21 @@ export const WorkspaceApp = ({
/>
</Suspense>
)}
{!desktopNotebookSidebarCollapsed && (
<div
className="absolute inset-y-0 right-0 z-20 hidden w-1.5 touch-none cursor-col-resize transition hover:bg-slate-300/70 focus-visible:bg-slate-400/80 focus-visible:outline-none lg:block"
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_NOTEBOOK_SIDEBAR_WIDTH_PX}
aria-valuemax={MAX_NOTEBOOK_SIDEBAR_WIDTH_PX}
aria-valuenow={notebookSidebarWidth}
aria-label={t("workspaceDialogs.resizeNotebookSidebar")}
tabIndex={0}
onDoubleClick={resetNotebookSidebarWidth}
onKeyDown={handleNotebookSidebarResizeKeyDown}
onPointerDown={handleNotebookSidebarResizePointerDown}
/>
)}
</aside>
<section
@@ -1,16 +1,20 @@
import { useCallback, useEffect, useState } from "react";
import {
DEFAULT_MEMO_LIST_WIDTH_PX,
DEFAULT_NOTEBOOK_SIDEBAR_WIDTH_PX,
clampMemoListWidth,
clampNotebookSidebarWidth,
readDesktopFocusModePreference,
readImageCompressionPreference,
readMemoListWidthPreference,
readNotebookSidebarCollapsedPreference,
readNotebookSidebarWidthPreference,
readShortcutSettingsPreference,
writeDesktopFocusModePreference,
writeImageCompressionPreference,
writeMemoListWidthPreference,
writeNotebookSidebarCollapsedPreference,
writeNotebookSidebarWidthPreference,
writeShortcutSettingsPreference,
type ShortcutSettings,
} from "@/lib/app-helpers";
@@ -19,6 +23,7 @@ export const useWorkspacePreferences = () => {
const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference);
const [desktopFocusMode, setDesktopFocusModeState] = useState(readDesktopFocusModePreference);
const [notebookSidebarCollapsed, setNotebookSidebarCollapsedState] = useState(readNotebookSidebarCollapsedPreference);
const [notebookSidebarWidth, setNotebookSidebarWidthState] = useState(readNotebookSidebarWidthPreference);
const [shortcutSettings, setShortcutSettings] = useState<ShortcutSettings>(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,
};
+50
View File
@@ -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();
+26
View File
@@ -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";
+1 -1
View File
@@ -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 {
+1
View File
@@ -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",
},
+1
View File
@@ -2723,6 +2723,7 @@ export const ja = {
deleteNotebookNotEmpty: "このノートブックか子ノートブックにノートが残っています。先にノートを移動または削除してから、もう一度お試しください。",
deleteNotebookFailed: "ノートブックは残っています。しばらくしてからもう一度お試しください。",
ok: "OK",
resizeNotebookSidebar: "ノートブック欄の幅を変更",
resizeMemoList: "ノート一覧の幅を変更",
resizeMemoListHint: "ドラッグで一覧欄の幅を変え、ダブルクリックでリセット、矢印キーで微調整できます",
},
+1
View File
@@ -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",
},
+1
View File
@@ -2732,6 +2732,7 @@ export const zhCN = {
deleteNotebookNotEmpty: "这个笔记本或它的子笔记本里还有笔记。请先把笔记移走或删除,再删除笔记本。",
deleteNotebookFailed: "删除没有完成,笔记本还在。请稍后重试。",
ok: "知道了",
resizeNotebookSidebar: "调整笔记本栏宽度",
resizeMemoList: "调整笔记列表宽度",
resizeMemoListHint: "拖拽调整列表栏宽度,双击恢复默认,方向键微调",
},
+37
View File
@@ -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));
});