diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index d0eb4df0..d9855a1f 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -126,6 +126,7 @@ import { EDITOR_LOCAL_SAVE_DELAY_MS, getEditableMemoTitle, getNotebookMoveOptions, + type EditorContentAlignment, type MemoDocumentActionRequest, } from "@/lib/app-helpers"; import { copyEditorToWeChat, copyMarkdownToWeChat } from "@/lib/wechat-copy"; @@ -604,6 +605,7 @@ type EditorPaneProps = { repository: EdgeEverRepository; desktopFocusMode: boolean; onToggleDesktopFocusMode: () => void; + editorContentAlignment: EditorContentAlignment; mobileDefaultEditMemoId: string | null; preserveUnsavedContentFromMemoId?: string | null; saveBlocked?: boolean; @@ -670,6 +672,7 @@ const RichEditorPane = ({ repository, desktopFocusMode, onToggleDesktopFocusMode, + editorContentAlignment, mobileDefaultEditMemoId, preserveUnsavedContentFromMemoId: _preserveUnsavedContentFromMemoId, saveBlocked: _saveBlocked = false, @@ -4118,7 +4121,9 @@ const RichEditorPane = ({ : "min-h-full items-start px-6 py-6 sm:px-10", desktopFocusMode ? "mx-auto w-full max-w-[1400px] justify-center" - : "w-full justify-center" + : editorContentAlignment === "center" + ? "w-full justify-center" + : "w-full justify-start" )} >
{ + test("uses a Radix submenu for notebook moves so opening the picker does not close the menu", () => { + const source = readFileSync(new URL("./MemoListPane.tsx", import.meta.url), "utf8"); + + expect(source).toContain(""); + expect(source).toContain(" { const { t } = useTranslation(); const [memoContextMenu, setMemoContextMenu] = useState(null); - const [contextMoveOpen, setContextMoveOpen] = useState(false); const [listDensity, setListDensity] = useState(() => readMemoListDensityPreference()); const [lastSelectedMemoId, setLastSelectedMemoId] = useState(null); const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(""); @@ -740,7 +741,6 @@ export const MemoListPane = ({ setDesktopActionsOpen(false); setDesktopFilterOpen(false); setDesktopSortOpen(false); - setContextMoveOpen(false); setMemoContextMenu(null); setMobileListActionsOpen(false); setMobileMoveOpen(false); @@ -786,7 +786,6 @@ export const MemoListPane = ({ const x = Math.min(clientX, Math.max(12, window.innerWidth - menuWidth - 12)); const y = Math.min(clientY, Math.max(12, window.innerHeight - menuHeight - 12)); - setContextMoveOpen(false); setMemoContextMenu({ memo, x, y }); }; @@ -804,7 +803,6 @@ export const MemoListPane = ({ } event.preventDefault(); - setContextMoveOpen(false); setMemoContextMenu(null); }; @@ -817,7 +815,6 @@ export const MemoListPane = ({ handleToggleMemo(memo.id); } - setContextMoveOpen(false); setMemoContextMenu(null); }; @@ -1522,40 +1519,36 @@ export const MemoListPane = ({ ) : ( <> - setContextMoveOpen((value) => !value)} - > - - {t("memoList.moveToNotebook")} - - - {contextMoveOpen && ( -
- {moveNotebookOptions.map((option: any) => ( - + ))} -
- )} + +
(null); const [notebookSortMode, setNotebookSortMode] = useState(readNotebookSortPreference); + const handleMoveNotebook = useCallback((notebookId: string, targetNotebookId: string, position: NotebookDropPosition) => { + setNotebookSortMode("custom"); + onMoveNotebook(notebookId, targetNotebookId, position); + }, [onMoveNotebook]); + const stopNotebookDragAutoScroll = useCallback(() => { if (notebookDragScrollFrameRef.current === null) { return; @@ -550,16 +555,24 @@ export const NotebookPane = ({ - - - + + + + + + + + + {t("notebookPane.sortTitle", { label: activeNotebookSortLabel })} + + + {notebookSortOptions.map((option) => ( void; shortcutSettings: ShortcutSettings; onShortcutSettingsChange: (settings: ShortcutSettings) => void; + editorContentAlignment: EditorContentAlignment; + onEditorContentAlignmentChange: (alignment: EditorContentAlignment) => void; onLogout: () => void; isLoggingOut: boolean; authRequired: boolean; @@ -93,6 +95,8 @@ export const SettingsPane = ({ onSyncIntervalChange, shortcutSettings, onShortcutSettingsChange, + editorContentAlignment, + onEditorContentAlignmentChange, onLogout, isLoggingOut, authRequired, @@ -214,6 +218,8 @@ export const SettingsPane = ({ onSyncIntervalChange={onSyncIntervalChange} shortcutSettings={shortcutSettings} onShortcutSettingsChange={onShortcutSettingsChange} + editorContentAlignment={editorContentAlignment} + onEditorContentAlignmentChange={onEditorContentAlignmentChange} /> diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 0302d7e0..020000d7 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -761,10 +761,12 @@ export const WorkspaceApp = ({ const [multiSelectKeyDown, setMultiSelectKeyDown] = useState(false); const { desktopFocusMode, + editorContentAlignment, imageCompressionEnabled, memoListWidth, resetMemoListWidth, setDesktopFocusMode, + setEditorContentAlignment, setImageCompressionEnabled, setMemoListWidth, setShortcutSettings, @@ -2965,6 +2967,8 @@ export const WorkspaceApp = ({ onSyncIntervalChange={setSyncIntervalMs} shortcutSettings={shortcutSettings} onShortcutSettingsChange={setShortcutSettings} + editorContentAlignment={editorContentAlignment} + onEditorContentAlignmentChange={setEditorContentAlignment} onLogout={onLogout} isLoggingOut={isLoggingOut} authRequired={authRequired} @@ -3011,6 +3015,7 @@ export const WorkspaceApp = ({ onOpenAiPrompts={handleOpenAiPrompts} desktopFocusMode={desktopFocusModeActive} onToggleDesktopFocusMode={toggleDesktopFocusMode} + editorContentAlignment={editorContentAlignment} mobileDefaultEditMemoId={createdMemoEditId} isTrashView={memoView === "trash"} notebooks={notebooks} diff --git a/apps/web/src/components/settings/PreferenceCard.test.mjs b/apps/web/src/components/settings/PreferenceCard.test.mjs new file mode 100644 index 00000000..1515f6ac --- /dev/null +++ b/apps/web/src/components/settings/PreferenceCard.test.mjs @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +describe("editor content alignment preference", () => { + test("is configured from settings instead of the per-note toolbar", () => { + const preferenceCard = readFileSync(new URL("./PreferenceCard.tsx", import.meta.url), "utf8"); + const editorPane = readFileSync(new URL("../EditorPane.tsx", import.meta.url), "utf8"); + + expect(preferenceCard).toContain('t("settings.editorContentAlignmentTitle")'); + expect(preferenceCard).toContain('onEditorContentAlignmentChange(value as EditorContentAlignment)'); + expect(editorPane).not.toContain("onToggleEditorContentAlignment"); + }); +}); diff --git a/apps/web/src/components/settings/PreferenceCard.tsx b/apps/web/src/components/settings/PreferenceCard.tsx index 1f70d351..49a1092b 100644 --- a/apps/web/src/components/settings/PreferenceCard.tsx +++ b/apps/web/src/components/settings/PreferenceCard.tsx @@ -1,7 +1,7 @@ -import { ChartNoAxesCombined, Image, Languages, MousePointerClick, Palette, RefreshCw, Sparkles } from "lucide-react"; +import { AlignHorizontalJustifyCenter, ChartNoAxesCombined, Image, Languages, MousePointerClick, Palette, RefreshCw, Sparkles } from "lucide-react"; import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { writeSyncIntervalPreference, type ShortcutSettings, type SyncIntervalPreference } from "@/lib/app-helpers"; +import { writeSyncIntervalPreference, type EditorContentAlignment, type ShortcutSettings, type SyncIntervalPreference } from "@/lib/app-helpers"; import { EDITOR_LINK_OPEN_MODE_CHANGED_EVENT, getStoredEditorLinkOpenMode, @@ -42,6 +42,8 @@ interface PreferenceCardProps { onSyncIntervalChange: (intervalMs: number | null) => void; shortcutSettings: ShortcutSettings; onShortcutSettingsChange: (settings: ShortcutSettings) => void; + editorContentAlignment: EditorContentAlignment; + onEditorContentAlignmentChange: (alignment: EditorContentAlignment) => void; } export const PreferenceCard = ({ @@ -51,6 +53,8 @@ export const PreferenceCard = ({ onSyncIntervalChange, shortcutSettings, onShortcutSettingsChange, + editorContentAlignment, + onEditorContentAlignmentChange, }: PreferenceCardProps) => { const { t } = useTranslation(); const { @@ -194,6 +198,30 @@ export const PreferenceCard = ({
+
+
+ +
+
{t("settings.editorContentAlignmentTitle")}
+
{t("settings.editorContentAlignmentDescription")}
+
+
+
+ +
+
+
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 2be13008..0cfb6be9 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -36,14 +36,16 @@ const DropdownMenuSubContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + + + )); DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; diff --git a/apps/web/src/hooks/useWorkspacePreferences.ts b/apps/web/src/hooks/useWorkspacePreferences.ts index 3477e2f6..134d99ae 100644 --- a/apps/web/src/hooks/useWorkspacePreferences.ts +++ b/apps/web/src/hooks/useWorkspacePreferences.ts @@ -3,16 +3,19 @@ import { DEFAULT_MEMO_LIST_WIDTH_PX, clampMemoListWidth, readDesktopFocusModePreference, + readEditorContentAlignmentPreference, readImageCompressionPreference, readMemoListWidthPreference, readShortcutSettingsPreference, readSyncIntervalPreference, writeDesktopFocusModePreference, + writeEditorContentAlignmentPreference, writeImageCompressionPreference, writeMemoListWidthPreference, writeShortcutSettingsPreference, writeSyncIntervalPreference, type ShortcutSettings, + type EditorContentAlignment, } from "@/lib/app-helpers"; export type SyncIntervalPreference = "off" | "30s" | "5m" | "15m" | "30m" | "1h" | "2h"; @@ -33,6 +36,7 @@ export const useWorkspacePreferences = () => { const [imageCompressionEnabled, setImageCompressionEnabled] = useState(readImageCompressionPreference); const [syncIntervalMs, setSyncIntervalMs] = useState(readSyncIntervalPreference); const [desktopFocusMode, setDesktopFocusModeState] = useState(readDesktopFocusModePreference); + const [editorContentAlignment, setEditorContentAlignmentState] = useState(readEditorContentAlignmentPreference); const [shortcutSettings, setShortcutSettings] = useState(readShortcutSettingsPreference); const [memoListWidth, setMemoListWidthState] = useState(readMemoListWidthPreference); @@ -45,6 +49,11 @@ export const useWorkspacePreferences = () => { writeDesktopFocusModePreference(enabled); }, []); + const setEditorContentAlignment = useCallback((alignment: EditorContentAlignment) => { + setEditorContentAlignmentState(alignment); + writeEditorContentAlignmentPreference(alignment); + }, []); + const setMemoListWidth = useCallback((width: number) => { const nextWidth = clampMemoListWidth(width); setMemoListWidthState(nextWidth); @@ -57,10 +66,12 @@ export const useWorkspacePreferences = () => { return { desktopFocusMode, + editorContentAlignment, imageCompressionEnabled, memoListWidth, resetMemoListWidth, setDesktopFocusMode, + setEditorContentAlignment, setImageCompressionEnabled, setMemoListWidth, setShortcutSettings, diff --git a/apps/web/src/lib/app-helpers.test.mjs b/apps/web/src/lib/app-helpers.test.mjs index 0047bb05..2017958c 100644 --- a/apps/web/src/lib/app-helpers.test.mjs +++ b/apps/web/src/lib/app-helpers.test.mjs @@ -2,9 +2,16 @@ import { afterEach, describe, expect, test } from "bun:test"; import { DEFAULT_SYNC_INTERVAL_MS, DESKTOP_FOCUS_MODE_STORAGE_KEY, + EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY, + NOTEBOOK_SORT_STORAGE_KEY, SYNC_INTERVAL_STORAGE_KEY, + getNotebookSortComparator, + readEditorContentAlignmentPreference, + readNotebookSortPreference, readSyncIntervalPreference, readDesktopFocusModePreference, + writeEditorContentAlignmentPreference, + writeNotebookSortPreference, writeSyncIntervalPreference, writeDesktopFocusModePreference, } from "./app-helpers.ts"; @@ -70,6 +77,66 @@ describe("desktop focus mode preference", () => { }); }); +describe("editor content alignment preference", () => { + test("defaults to left aligned and persists both supported alignments", () => { + const values = installLocalStorage(); + expect(readEditorContentAlignmentPreference()).toBe("start"); + + writeEditorContentAlignmentPreference("start"); + expect(values.get(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY)).toBe("start"); + expect(readEditorContentAlignmentPreference()).toBe("start"); + + writeEditorContentAlignmentPreference("center"); + expect(values.get(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY)).toBe("center"); + expect(readEditorContentAlignmentPreference()).toBe("center"); + }); + + test("falls back to left aligned for unknown or unavailable storage", () => { + const values = installLocalStorage(); + values.set(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY, "unexpected"); + expect(readEditorContentAlignmentPreference()).toBe("start"); + + globalThis.window = { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + }, + }; + expect(readEditorContentAlignmentPreference()).toBe("start"); + }); +}); + +describe("custom notebook sorting", () => { + test("persists the custom sort mode", () => { + const values = installLocalStorage(); + writeNotebookSortPreference("custom"); + expect(values.get(NOTEBOOK_SORT_STORAGE_KEY)).toBe("custom"); + expect(readNotebookSortPreference()).toBe("custom"); + }); + + test("orders notebooks by persisted sort order with a stable name fallback", () => { + const compare = getNotebookSortComparator("custom"); + const base = { + parentId: null, + slug: null, + icon: null, + color: null, + memoCount: 0, + lastMemoUpdatedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const notebooks = [ + { ...base, id: "third", name: "C", sortOrder: 30 }, + { ...base, id: "second", name: "B", sortOrder: 20 }, + { ...base, id: "first", name: "A", sortOrder: 20 }, + ]; + + expect(notebooks.sort(compare).map((item) => item.id)).toEqual(["first", "second", "third"]); + }); +}); + describe("automatic sync interval preference", () => { test("defaults to 30 seconds", () => { installLocalStorage(); diff --git a/apps/web/src/lib/app-helpers.ts b/apps/web/src/lib/app-helpers.ts index fd143dec..fa589c8e 100644 --- a/apps/web/src/lib/app-helpers.ts +++ b/apps/web/src/lib/app-helpers.ts @@ -10,7 +10,8 @@ export type Pane = "notebooks" | "memos" | "editor"; export type MemoView = "notebook" | "trash"; export type MemoFilterMode = "all" | "tagged" | "untagged" | "pinned"; export type MemoSortMode = "updated-desc" | "created-desc" | "title-asc"; -export type NotebookSortMode = "name-asc" | "memo-count-desc" | "updated-desc"; +export type NotebookSortMode = "custom" | "name-asc" | "memo-count-desc" | "updated-desc"; +export type EditorContentAlignment = "start" | "center"; export type MemoListDensity = "preview" | "compact"; export type ShortcutAction = "createMemo" | "createNotebook" | "focusSearch" | "focusReplace"; export type ShortcutBinding = { @@ -109,6 +110,7 @@ export const IMAGE_COMPRESSION_STORAGE_KEY = "edgeever.imageCompressionEnabled"; export const SYNC_INTERVAL_STORAGE_KEY = "edgeever.syncInterval"; const LEGACY_AUTO_SAVE_INTERVAL_STORAGE_KEY = "edgeever.autoSaveInterval"; export const DESKTOP_FOCUS_MODE_STORAGE_KEY = "edgeever.desktopFocusMode"; +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"; export const NOTEBOOK_SORT_STORAGE_KEY = "edgeever.notebookSort"; @@ -139,9 +141,10 @@ export const getMemoSortOptions = (t: TFunction): Array<{ value: MemoSortMode; l { value: "title-asc", label: t("options.memoSort.titleAsc") }, ]; -const NOTEBOOK_SORT_VALUES: NotebookSortMode[] = ["name-asc", "memo-count-desc", "updated-desc"]; +const NOTEBOOK_SORT_VALUES: NotebookSortMode[] = ["custom", "name-asc", "memo-count-desc", "updated-desc"]; export const getNotebookSortOptions = (t: TFunction): Array<{ value: NotebookSortMode; label: string }> => [ + { value: "custom", label: t("options.notebookSort.custom") }, { value: "name-asc", label: t("options.notebookSort.nameAsc") }, { value: "memo-count-desc", label: t("options.notebookSort.memoCountDesc") }, { value: "updated-desc", label: t("options.notebookSort.updatedDesc") }, @@ -275,6 +278,22 @@ export const writeDesktopFocusModePreference = (enabled: boolean) => { } }; +export const readEditorContentAlignmentPreference = (): EditorContentAlignment => { + try { + return window.localStorage.getItem(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY) === "center" ? "center" : "start"; + } catch { + return "start"; + } +}; + +export const writeEditorContentAlignmentPreference = (alignment: EditorContentAlignment) => { + try { + window.localStorage.setItem(EDITOR_CONTENT_ALIGNMENT_STORAGE_KEY, alignment); + } catch { + // Local storage can be unavailable in private or restricted browser contexts. + } +}; + export const readMemoListDensityPreference = (): MemoListDensity => { try { const density = window.localStorage.getItem(MEMO_LIST_DENSITY_STORAGE_KEY); @@ -457,6 +476,10 @@ const compareNotebookUpdatedDesc = (first: Notebook, second: Notebook) => { }; export const getNotebookSortComparator = (sortMode: NotebookSortMode): NotebookNodeComparator => { + if (sortMode === "custom") { + return (first, second) => first.sortOrder - second.sortOrder || compareNotebookNameAsc(first, second); + } + if (sortMode === "memo-count-desc") { return (first, second) => second.memoCount - first.memoCount || compareNotebookNameAsc(first, second); } diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 1008aca1..61d26044 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -204,6 +204,12 @@ export const enUS = { mermaidThemeDescription: "Choose the visual theme for diagrams in notes.", editorThemeTitle: "Editor theme", editorThemeDescription: "Choose a writing style for the note editor. Saved in this browser.", + editorContentAlignmentTitle: "Note content layout", + editorContentAlignmentDescription: "Choose how note content is displayed across all notes on desktop. Saved in this browser.", + editorContentAlignments: { + center: "Optimize readability (centered)", + start: "Align left", + }, editorThemes: { default: "System default", "minimal-emerald": "Minimal Emerald", @@ -695,6 +701,7 @@ export const enUS = { titleAsc: "Title A-Z", }, notebookSort: { + custom: "Custom", nameAsc: "Name", memoCountDesc: "Note count", updatedDesc: "Updated date", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 559502c0..443c68fe 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -204,6 +204,12 @@ export const zhCN = { mermaidThemeDescription: "选择笔记中图表的视觉主题。", editorThemeTitle: "编辑器主题", editorThemeDescription: "选择正文编辑区的排版风格,偏好会保存在当前浏览器中。", + editorContentAlignmentTitle: "笔记正文布局", + editorContentAlignmentDescription: "选择桌面端所有笔记的正文显示方式,偏好会保存在当前浏览器中。", + editorContentAlignments: { + center: "优化可读性(居中)", + start: "靠左显示", + }, editorThemes: { default: "系统默认", "minimal-emerald": "minimal品牌绿", @@ -695,6 +701,7 @@ export const zhCN = { titleAsc: "标题 A-Z", }, notebookSort: { + custom: "自定义", nameAsc: "名称", memoCountDesc: "笔记数量", updatedDesc: "更新日期",