From 81416447293044f344ea0f8092ee821010fec826 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 27 Apr 2026 10:10:58 +0200 Subject: [PATCH] fix: revert note width modes --- docs/ABSTRACTIONS.md | 8 +- docs/ARCHITECTURE.md | 7 +- src-tauri/src/settings.rs | 23 -- src-tauri/src/vault/entry.rs | 3 - src-tauri/src/vault/frontmatter.rs | 3 - src-tauri/src/vault/mod.rs | 9 - src-tauri/src/vault/system_metadata_tests.rs | 25 -- src/App.tsx | 28 +- src/components/BreadcrumbBar.test.tsx | 24 +- src/components/BreadcrumbBar.tsx | 34 +-- src/components/Editor.css | 18 +- src/components/Editor.tsx | 24 +- .../EditorContentLayout.test.tsx | 14 +- .../editor-content/EditorContentLayout.tsx | 42 ++- .../editor-content/useEditorContentModel.ts | 6 +- src/hooks/commands/localizeCommands.ts | 5 +- src/hooks/commands/viewCommands.ts | 50 ++-- .../frontmatterOps.systemMetadata.test.ts | 13 +- src/hooks/frontmatterOps.ts | 257 +++++++++++++----- src/hooks/useAppCommands.ts | 17 +- src/hooks/useCommandRegistry.test.ts | 35 +-- src/hooks/useCommandRegistry.ts | 13 +- src/hooks/useNoteActions.ts | 9 +- src/hooks/useNoteLayout.test.ts | 51 ++++ src/hooks/useNoteLayout.ts | 33 +++ src/hooks/useSettings.test.ts | 13 - src/hooks/useSettings.ts | 3 - src/lib/locales/en.ts | 10 +- src/lib/locales/zh-Hans.ts | 10 +- src/mock-tauri/mock-handlers.coverage.test.ts | 1 - src/mock-tauri/mock-handlers.ts | 2 - src/types.ts | 7 +- src/utils/noteWidth.test.ts | 29 -- src/utils/noteWidth.ts | 23 -- src/utils/systemMetadata.ts | 1 - 35 files changed, 424 insertions(+), 426 deletions(-) create mode 100644 src/hooks/useNoteLayout.test.ts create mode 100644 src/hooks/useNoteLayout.ts delete mode 100644 src/utils/noteWidth.test.ts delete mode 100644 src/utils/noteWidth.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index e5653da7..764ed059 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -47,7 +47,6 @@ _icon: shapes # icon assigned to a type _color: blue # color assigned to a type _order: 10 # sort order in the sidebar _sidebar_label: Projects # override label in sidebar -_width: wide # per-note reading width: normal or wide ``` **This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text. @@ -93,7 +92,6 @@ classDiagram +Boolean archived +Boolean trashed ⚠ legacy +Number? trashedAt ⚠ legacy - +String? noteWidth +Record~string,string~ properties } @@ -144,14 +142,11 @@ interface VaultEntry { archived: boolean // Archived flag trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent) trashedAt: number | null // Kept for backward compatibility (Trash system removed) - noteWidth?: 'normal' | 'wide' | null // Per-note `_width` reading preference properties: Record // Scalar frontmatter fields (custom properties) fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior } ``` -Per-note reading width uses the system property `_width: normal | wide`. It is hidden from the Properties panel by the underscore convention, parsed into `VaultEntry.noteWidth`, and updated through the breadcrumb width toggle or the command palette. When absent or invalid, the renderer falls back to the app-level `settings.note_width_mode` default and then to `normal`. - ### File kinds and binary previews `VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained: @@ -652,7 +647,7 @@ No indexing step required — search runs directly against the filesystem. Per-vault settings stored locally and scoped by vault path: - Managed by `useVaultConfig` hook and `vaultConfigStore` -- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle +- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle - One-time migration from localStorage (`configMigration.ts`) ### AI Guidance Files @@ -709,7 +704,6 @@ interface Settings { release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed theme_mode: 'light' | 'dark' | null ui_language: 'en' | 'zh-Hans' | null - note_width_mode: 'normal' | 'wide' | null default_ai_agent: 'claude_code' | 'codex' | null } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85c22477..2d7e6d2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,10 +31,8 @@ When deciding where to persist a piece of data, ask: **"Would the user want this Examples: - ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties) - ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity) -- ✅ Vault: `_width: wide` in a note (this note should open wide on every device) - ✅ App settings: `zoom: 1.3` (machine-specific preference) - ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language) -- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without `_width`) ### No hardcoded exceptions @@ -182,7 +180,7 @@ flowchart TD - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`. - **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. -- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or normal/wide reading width across rich and raw modes. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. +- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. @@ -447,6 +445,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri - `zoom`: Float zoom level (0.8–1.5) - `view_mode`: "all" | "editor-list" | "editor-only" - `editor_mode`: "raw" | "preview" (persists across note switches and sessions) +- `note_layout`: "centered" | "left" (wide-screen note column alignment for rich and raw editors) - `tag_colors`, `status_colors`: Custom color overrides - `property_display_modes`: Property display preferences - `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list @@ -780,7 +779,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | -| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, default note width, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | +| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings | | `useVaultConfig` | Per-vault UI preferences | Vault-specific config | | `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands | diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f80c6a9e..6a36a1e1 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -21,7 +21,6 @@ pub struct Settings { pub ui_language: Option, pub initial_h1_auto_rename_enabled: Option, pub default_ai_agent: Option, - pub note_width_mode: Option, } fn normalize_optional_string(value: Option) -> Option { @@ -63,13 +62,6 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option { } } -pub fn normalize_note_width_mode(value: Option<&str>) -> Option { - match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { - Some(mode) if mode == "normal" || mode == "wide" => Some(mode), - _ => None, - } -} - fn canonical_language_code(value: &str) -> Option { let code = value.trim().replace('_', "-").to_ascii_lowercase(); if code.is_empty() { @@ -118,7 +110,6 @@ fn normalize_settings(settings: Settings) -> Settings { ui_language: normalize_ui_language(settings.ui_language.as_deref()), initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled, default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()), - note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()), } } @@ -261,7 +252,6 @@ mod tests { ui_language: Some("zh-Hans".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), - note_width_mode: Some("wide".to_string()), }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); @@ -289,7 +279,6 @@ mod tests { ui_language: Some("zh-Hans".to_string()), initial_h1_auto_rename_enabled: Some(false), default_ai_agent: Some("codex".to_string()), - note_width_mode: Some("wide".to_string()), ..Default::default() }); assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); @@ -302,7 +291,6 @@ mod tests { assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans")); assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false)); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); - assert_eq!(loaded.note_width_mode.as_deref(), Some("wide")); } #[test] @@ -313,7 +301,6 @@ mod tests { theme_mode: Some(" dark ".to_string()), ui_language: Some(" zh-cn ".to_string()), default_ai_agent: Some(" codex ".to_string()), - note_width_mode: Some(" wide ".to_string()), ..Default::default() }); assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid")); @@ -321,7 +308,6 @@ mod tests { assert_eq!(loaded.theme_mode.as_deref(), Some("dark")); assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans")); assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex")); - assert_eq!(loaded.note_width_mode.as_deref(), Some("wide")); } #[test] @@ -371,15 +357,6 @@ mod tests { assert!(loaded.theme_mode.is_none()); } - #[test] - fn test_invalid_note_width_mode_is_filtered() { - let loaded = save_and_reload(Settings { - note_width_mode: Some("full".to_string()), - ..Default::default() - }); - assert!(loaded.note_width_mode.is_none()); - } - #[test] fn test_invalid_ui_language_is_filtered() { let loaded = save_and_reload(Settings { diff --git a/src-tauri/src/vault/entry.rs b/src-tauri/src/vault/entry.rs index 4bd47496..cf83e453 100644 --- a/src-tauri/src/vault/entry.rs +++ b/src-tauri/src/vault/entry.rs @@ -54,9 +54,6 @@ pub struct VaultEntry { /// Default view mode for the note list when viewing instances of this Type. /// Stored as a string: "all", "editor-list", or "editor-only". pub view: Option, - /// Per-note content width override stored in `_width` frontmatter. - #[serde(rename = "noteWidth")] - pub note_width: Option, /// Whether this Type is visible in the sidebar. Defaults to true when absent. pub visible: Option, /// Whether this note has been explicitly organized (removed from Inbox). diff --git a/src-tauri/src/vault/frontmatter.rs b/src-tauri/src/vault/frontmatter.rs index 0ed82daa..c0c9038e 100644 --- a/src-tauri/src/vault/frontmatter.rs +++ b/src-tauri/src/vault/frontmatter.rs @@ -40,8 +40,6 @@ pub(crate) struct Frontmatter { pub sort: Option, #[serde(default)] pub view: Option, - #[serde(rename = "_width", default)] - pub width: Option, #[serde(default)] pub visible: Option, #[serde( @@ -205,7 +203,6 @@ fn parse_frontmatter(data: &HashMap) -> Frontmatter { "_sort", "sort", "view", - "_width", "visible", "notion_id", "Status", diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index bb9c292d..c59e94ad 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -60,14 +60,6 @@ fn preferred_relationship_refs( .unwrap_or_default() } -fn normalize_note_width(value: Option) -> Option { - match value?.trim().to_ascii_lowercase().as_str() { - "normal" => Some("normal".to_string()), - "wide" => Some("wide".to_string()), - _ => None, - } -} - pub(crate) fn derive_markdown_title_from_content(content: &str, filename: &str) -> String { let matter = Matter::::new(); let parsed = matter.parse(content); @@ -157,7 +149,6 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result void>(() => {}) const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined) + const { noteLayout, toggleNoteLayout } = useNoteLayout() const zoom = useZoom() const buildNumber = useBuildNumber() @@ -1342,18 +1343,6 @@ function App() { () => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined, [activeDeletedFile, handleDiscardFile], ) - const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null - const noteWidth = resolveNoteWidthMode(activeTab?.entry.noteWidth, settings.note_width_mode) - const handleSetDefaultNoteWidth = useCallback((width: NoteWidthMode) => { - void saveSettings({ ...settings, note_width_mode: width }) - }, [saveSettings, settings]) - const handleSetActiveNoteWidth = useCallback((width: NoteWidthMode) => { - if (!notes.activeTabPath) return - void notes.handleUpdateFrontmatter(notes.activeTabPath, '_width', width) - }, [notes]) - const handleToggleNoteWidth = useCallback(() => { - handleSetActiveNoteWidth(toggleNoteWidthMode(noteWidth)) - }, [handleSetActiveNoteWidth, noteWidth]) const commands = useAppCommands({ activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef, @@ -1381,9 +1370,8 @@ function App() { onToggleInspector: handleToggleInspector, onToggleDiff: toggleDiffCommand, onToggleRawEditor: toggleRawEditorCommand, - noteWidth, - onSetNoteWidth: !activeDeletedFile && notes.activeTabPath ? handleSetActiveNoteWidth : undefined, - onSetDefaultNoteWidth: handleSetDefaultNoteWidth, + noteLayout, + onToggleNoteLayout: toggleNoteLayout, onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset, zoomLevel: zoom.zoomLevel, onSelect: handleSetSelection, @@ -1442,6 +1430,8 @@ function App() { canRestoreDeletedNote: !!activeDeletedFile, }) + const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null + const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod]) const aiNoteList = useMemo(() => { @@ -1593,8 +1583,8 @@ function App() { onContentChange={handleTrackedContentChange} onSave={handleTrackedSave} onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename} - noteWidth={noteWidth} - onToggleNoteWidth={activeDeletedFile ? undefined : handleToggleNoteWidth} + noteLayout={noteLayout} + onToggleNoteLayout={toggleNoteLayout} rawToggleRef={rawToggleRef} diffToggleRef={diffToggleRef} canGoBack={canGoBack} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index a86fe327..4692a0e8 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -327,25 +327,25 @@ describe('BreadcrumbBar — raw editor toggle', () => { }) }) -describe('BreadcrumbBar — note width toggle', () => { - it('shows the wide width action while normal', () => { - render() +describe('BreadcrumbBar — note layout toggle', () => { + it('shows the left-align layout action while centered', () => { + render() - expect(screen.getByRole('button', { name: 'Switch this note to wide width' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument() }) - it('shows the normal width action while wide', () => { - render() + it('shows the centered layout action while left-aligned', () => { + render() - expect(screen.getByRole('button', { name: 'Switch this note to normal width' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument() }) - it('calls onToggleNoteWidth when the width button is clicked', () => { - const onToggleNoteWidth = vi.fn() - render() + it('calls onToggleNoteLayout when the layout button is clicked', () => { + const onToggleNoteLayout = vi.fn() + render() - fireEvent.click(screen.getByRole('button', { name: 'Switch this note to wide width' })) + fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })) - expect(onToggleNoteWidth).toHaveBeenCalledOnce() + expect(onToggleNoteLayout).toHaveBeenCalledOnce() }) }) diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 1005b956..44081fbe 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react' -import type { NoteWidthMode, VaultEntry } from '../types' +import type { NoteLayout, VaultEntry } from '../types' import { cn } from '@/lib/utils' import { translate, type AppLocale } from '../lib/i18n' import { formatShortcutDisplay } from '../hooks/appCommandCatalog' @@ -50,8 +50,8 @@ interface BreadcrumbBarProps { onArchive?: () => void onUnarchive?: () => void onRenameFilename?: (path: string, newFilenameStem: string) => void - noteWidth?: NoteWidthMode - onToggleNoteWidth?: () => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void /** Ref for direct DOM manipulation — avoids re-render on scroll. */ barRef?: React.Ref locale?: AppLocale @@ -257,25 +257,25 @@ function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: bo return } -function NoteWidthAction({ - noteWidth = 'normal', +function NoteLayoutAction({ + noteLayout = 'centered', locale = 'en', - onToggleNoteWidth, + onToggleNoteLayout, }: { - noteWidth?: NoteWidthMode + noteLayout?: NoteLayout locale?: AppLocale - onToggleNoteWidth?: () => void + onToggleNoteLayout?: () => void }) { - if (!onToggleNoteWidth) return null + if (!onToggleNoteLayout) return null - const isWide = noteWidth === 'wide' + const isLeftAligned = noteLayout === 'left' return ( - {isWide + {isLeftAligned ? : } @@ -624,8 +624,8 @@ function BreadcrumbActions({ rawMode, onToggleRaw, forceRawMode, - noteWidth, - onToggleNoteWidth, + noteLayout, + onToggleNoteLayout, showAIChat, onToggleAIChat, inspectorCollapsed, @@ -651,7 +651,7 @@ function BreadcrumbActions({ locale={locale} /> {!forceRawMode && } - + diff --git a/src/components/Editor.css b/src/components/Editor.css index 221ac6d5..b47abeb3 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -204,14 +204,9 @@ width: 100%; } -.editor-content-width--wide .editor-content-wrapper { - max-width: min(100%, 1280px); - padding-left: clamp(24px, 4vw, 72px); - padding-right: clamp(24px, 4vw, 72px); -} - -.editor-content-wrapper--raw { - min-height: 0; +.editor-content-layout--left .editor-content-wrapper { + margin-left: clamp(16px, 6%, 96px); + margin-right: auto; } .raw-editor-codemirror { @@ -337,3 +332,10 @@ padding: 12px 0; } } + +@container editor (max-width: 900px) { + .editor-content-layout--left .editor-content-wrapper { + margin-left: auto; + margin-right: auto; + } +} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a2ebd0c0..979f9075 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -7,7 +7,7 @@ import { uploadImageFile } from '../hooks/useImageDrop' import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents' import { translate, type AppLocale } from '../lib/i18n' import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce' -import type { VaultEntry, GitCommit, NoteStatus, NoteWidthMode } from '../types' +import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { FrontmatterValue } from './Inspector' import { ResizeHandle } from './ResizeHandle' @@ -79,8 +79,8 @@ interface EditorProps { onSave?: () => void /** Called when the user explicitly renames the filename from the breadcrumb. */ onRenameFilename?: (path: string, newFilenameStem: string) => void - noteWidth?: NoteWidthMode - onToggleNoteWidth?: () => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void canGoBack?: boolean canGoForward?: boolean onGoBack?: () => void @@ -312,8 +312,8 @@ function EditorLayout({ rawModeContent, rawLatestContentRef, onRenameFilename, - noteWidth, - onToggleNoteWidth, + noteLayout, + onToggleNoteLayout, isConflicted, onKeepMine, onKeepTheirs, @@ -372,8 +372,8 @@ function EditorLayout({ rawModeContent: string | null rawLatestContentRef: React.MutableRefObject onRenameFilename?: (path: string, newFilenameStem: string) => void - noteWidth?: NoteWidthMode - onToggleNoteWidth?: () => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void isConflicted?: boolean onKeepMine?: (path: string) => void onKeepTheirs?: (path: string) => void @@ -447,8 +447,8 @@ function EditorLayout({ rawModeContent={rawModeContent} rawLatestContentRef={rawLatestContentRef} onRenameFilename={onRenameFilename} - noteWidth={noteWidth} - onToggleNoteWidth={onToggleNoteWidth} + noteLayout={noteLayout} + onToggleNoteLayout={onToggleNoteLayout} isConflicted={isConflicted} onKeepMine={onKeepMine} onKeepTheirs={onKeepTheirs} @@ -507,7 +507,7 @@ export const Editor = memo(function Editor(props: EditorProps) { onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile, onDeleteNote, onArchiveNote, onUnarchiveNote, onContentChange, onSave, onRenameFilename, - noteWidth, onToggleNoteWidth, + noteLayout, onToggleNoteLayout, onFileCreated, onFileModified, onVaultChanged, isConflicted, onKeepMine, onKeepTheirs, flushPendingRawContentRef, @@ -572,8 +572,8 @@ export const Editor = memo(function Editor(props: EditorProps) { rawModeContent={rawModeContent} rawLatestContentRef={rawLatestContentRef} onRenameFilename={onRenameFilename} - noteWidth={noteWidth} - onToggleNoteWidth={onToggleNoteWidth} + noteLayout={noteLayout} + onToggleNoteLayout={onToggleNoteLayout} isConflicted={isConflicted} onKeepMine={onKeepMine} onKeepTheirs={onKeepTheirs} diff --git a/src/components/editor-content/EditorContentLayout.test.tsx b/src/components/editor-content/EditorContentLayout.test.tsx index 63a6db1e..a010c666 100644 --- a/src/components/editor-content/EditorContentLayout.test.tsx +++ b/src/components/editor-content/EditorContentLayout.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { EditorContentLayout } from './EditorContentLayout' vi.mock('../BreadcrumbBar', () => ({ - BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) =>
, + BreadcrumbBar: ({ noteLayout }: { noteLayout?: string }) =>
, })) vi.mock('../ArchivedNoteBanner', () => ({ @@ -63,8 +63,8 @@ function createModel(overrides: Record = {}) { onEditorChange: vi.fn(), isDeletedPreview: false, rawLatestContentRef: { current: null }, - noteWidth: 'normal', - onToggleNoteWidth: vi.fn(), + noteLayout: 'centered', + onToggleNoteLayout: vi.fn(), forceRawMode: false, showAIChat: false, onToggleAIChat: vi.fn(), @@ -102,10 +102,10 @@ describe('EditorContentLayout', () => { expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument() }) - it('marks the editor content root and breadcrumb with the note width preference', () => { - const { container } = render() + it('marks the editor content root and breadcrumb with the note layout preference', () => { + const { container } = render() - expect(container.firstElementChild).toHaveClass('editor-content-width--wide') - expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide') + expect(container.firstElementChild).toHaveClass('editor-content-layout--left') + expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left') }) }) diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 7153e09e..fc7e4afc 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -32,8 +32,8 @@ type BreadcrumbActions = Pick< | 'onArchiveNote' | 'onUnarchiveNote' | 'onRenameFilename' - | 'noteWidth' - | 'onToggleNoteWidth' + | 'noteLayout' + | 'onToggleNoteLayout' > function EditorLoadingSkeleton() { @@ -86,21 +86,17 @@ function RawModeEditorSection({ if (!rawMode || !activeTab) return null return ( -
-
- {})} - onSave={onSave ?? (() => {})} - latestContentRef={rawLatestContentRef} - vaultPath={vaultPath} - locale={locale} - /> -
-
+ {})} + onSave={onSave ?? (() => {})} + latestContentRef={rawLatestContentRef} + vaultPath={vaultPath} + locale={locale} + /> ) } @@ -147,8 +143,8 @@ function ActiveTabBreadcrumb({ onArchive={bindPath(actions.onArchiveNote, path)} onUnarchive={bindPath(actions.onUnarchiveNote, path)} onRenameFilename={actions.onRenameFilename} - noteWidth={actions.noteWidth} - onToggleNoteWidth={actions.onToggleNoteWidth} + noteLayout={actions.noteLayout} + onToggleNoteLayout={actions.onToggleNoteLayout} locale={locale} /> ) @@ -252,12 +248,12 @@ export function EditorContentLayout(model: EditorContentModel) { isDeletedPreview, rawLatestContentRef, rawModeContent, - noteWidth, + noteLayout, locale, } = model const rootClassName = cn( 'flex flex-1 flex-col min-w-0 min-h-0', - noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal', + noteLayout === 'left' ? 'editor-content-layout--left' : 'editor-content-layout--centered', ) if (!activeTab) { @@ -296,8 +292,8 @@ export function EditorContentLayout(model: EditorContentModel) { onArchiveNote: model.onArchiveNote, onUnarchiveNote: model.onUnarchiveNote, onRenameFilename: model.onRenameFilename, - noteWidth: model.noteWidth, - onToggleNoteWidth: model.onToggleNoteWidth, + noteLayout: model.noteLayout, + onToggleNoteLayout: model.onToggleNoteLayout, }} /> onRenameFilename?: (path: string, newFilenameStem: string) => void - noteWidth?: NoteWidthMode - onToggleNoteWidth?: () => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void isConflicted?: boolean onKeepMine?: (path: string) => void onKeepTheirs?: (path: string) => void diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index cf0c6fc4..53bab0a3 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -92,10 +92,7 @@ function localizeColumnsCommand(command: CommandAction, t: Translate): string { } const VIEW_STATE_LABELERS: Partial> = { - 'set-note-width-normal': (_command, t) => t('command.view.setNoteWidthNormal'), - 'set-note-width-wide': (_command, t) => t('command.view.setNoteWidthWide'), - 'set-default-note-width-normal': (_command, t) => t('command.view.setDefaultNoteWidthNormal'), - 'set-default-note-width-wide': (_command, t) => t('command.view.setDefaultNoteWidthWide'), + 'toggle-note-layout': (command, t) => t(command.label === 'Use Left-Aligned Note Layout' ? 'command.view.leftLayout' : 'command.view.centerLayout'), 'zoom-in': (command, t) => t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }), 'zoom-out': (command, t) => t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }), 'customize-note-list-columns': localizeColumnsCommand, diff --git a/src/hooks/commands/viewCommands.ts b/src/hooks/commands/viewCommands.ts index d8cc66eb..c6dcb79e 100644 --- a/src/hooks/commands/viewCommands.ts +++ b/src/hooks/commands/viewCommands.ts @@ -1,9 +1,16 @@ import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog' import type { CommandAction } from './types' import type { ViewMode } from '../useViewMode' -import type { NoteWidthMode } from '../../types' +import type { NoteLayout } from '../../types' import { requestNewAiChat } from '../../utils/aiPromptBridge' +const NOTE_LAYOUT_COMMAND_LABELS: Record = { + centered: 'Use Left-Aligned Note Layout', + left: 'Use Centered Note Layout', +} + +const noop = () => {} + interface ViewCommandsConfig { hasActiveNote: boolean activeNoteModified: boolean @@ -11,9 +18,8 @@ interface ViewCommandsConfig { onToggleInspector: () => void onToggleDiff?: () => void onToggleRawEditor?: () => void - noteWidth?: NoteWidthMode - onSetNoteWidth?: (width: NoteWidthMode) => void - onSetDefaultNoteWidth?: (width: NoteWidthMode) => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void onToggleAIChat?: () => void zoomLevel: number onZoomIn: () => void @@ -24,35 +30,21 @@ interface ViewCommandsConfig { noteListColumnsLabel: string } -function buildNoteWidthCommands( - noteWidth: NoteWidthMode, - onSetNoteWidth?: (width: NoteWidthMode) => void, - onSetDefaultNoteWidth?: (width: NoteWidthMode) => void, -): CommandAction[] { - const noteModeCommands = (['normal', 'wide'] as const).map((width) => ({ - id: `set-note-width-${width}`, - label: `Set Note Width: ${width === 'wide' ? 'Wide' : 'Normal'}`, - group: 'View' as const, - keywords: ['layout', 'note', 'width', 'wide', 'normal', 'reading'], - enabled: Boolean(onSetNoteWidth) && noteWidth !== width, - execute: () => onSetNoteWidth?.(width), - })) - const defaultModeCommands = (['normal', 'wide'] as const).map((width) => ({ - id: `set-default-note-width-${width}`, - label: `Set Default Note Width: ${width === 'wide' ? 'Wide' : 'Normal'}`, - group: 'View' as const, - keywords: ['layout', 'default', 'preference', 'width', 'wide', 'normal', 'reading'], - enabled: Boolean(onSetDefaultNoteWidth), - execute: () => onSetDefaultNoteWidth?.(width), - })) - - return [...noteModeCommands, ...defaultModeCommands] +function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction { + return { + id: 'toggle-note-layout', + label: NOTE_LAYOUT_COMMAND_LABELS[noteLayout], + group: 'View', + keywords: ['layout', 'note', 'column', 'wide', 'left', 'centered', 'reading'], + enabled: Boolean(onToggleNoteLayout), + execute: onToggleNoteLayout ?? noop, + } } export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { const { hasActiveNote, activeNoteModified, - onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteWidth = 'normal', onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, + onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, } = config @@ -64,7 +56,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] { { id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector }, { id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() }, { id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() }, - ...buildNoteWidthCommands(noteWidth, onSetNoteWidth, onSetDefaultNoteWidth), + buildNoteLayoutCommand(noteLayout, onToggleNoteLayout), { id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() }, { id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat }, { id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector }, diff --git a/src/hooks/frontmatterOps.systemMetadata.test.ts b/src/hooks/frontmatterOps.systemMetadata.test.ts index 81ce5c5e..fae5f394 100644 --- a/src/hooks/frontmatterOps.systemMetadata.test.ts +++ b/src/hooks/frontmatterOps.systemMetadata.test.ts @@ -10,7 +10,6 @@ describe('frontmatterOps system metadata', () => { ['order', 4, { order: 4 }], ['_sort', 'title:asc', { sort: 'title:asc' }], ['sort', 'title:asc', { sort: 'title:asc' }], - ['_width', 'wide', { noteWidth: 'wide' }], ['_sidebar_label', 'Projects', { sidebarLabel: 'Projects' }], ['sidebar label', 'Projects', { sidebarLabel: 'Projects' }], ] as [string, unknown, Partial][])('maps %s to the expected entry field', (key, value, expected) => { @@ -22,12 +21,11 @@ describe('frontmatterOps system metadata', () => { expect(frontmatterToEntryPatch('delete', '_sidebar_label').patch).toEqual({ sidebarLabel: null }) expect(frontmatterToEntryPatch('delete', '_order').patch).toEqual({ order: null }) expect(frontmatterToEntryPatch('delete', '_sort').patch).toEqual({ sort: null }) - expect(frontmatterToEntryPatch('delete', '_width').patch).toEqual({ noteWidth: null }) }) it('keeps canonical system metadata out of custom properties', () => { const patch = contentToEntryPatch( - '---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n_width: wide\n_internal: secret\nOwner: Luca\n---\n# Project\n', + '---\ntype: Type\n_icon: rocket\n_order: 4\n_sidebar_label: Projects\n_sort: title:asc\n_internal: secret\nOwner: Luca\n---\n# Project\n', ) expect(patch).toEqual({ @@ -36,16 +34,7 @@ describe('frontmatterOps system metadata', () => { order: 4, sidebarLabel: 'Projects', sort: 'title:asc', - noteWidth: 'wide', properties: { Owner: 'Luca' }, }) }) - - it('keeps bare width as a custom property', () => { - const patch = contentToEntryPatch('---\nwidth: 320\n---\n# Project\n') - - expect(patch).toEqual({ - properties: { width: 320 }, - }) - }) }) diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 8602c1bd..7565e2a0 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -7,6 +7,15 @@ import { updateMockContent, trackMockChange } from '../mock-tauri' import { parseFrontmatter } from '../utils/frontmatter' import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata' +type FrontmatterCommand = 'update_frontmatter' | 'delete_frontmatter_property' +type FrontmatterKey = string +type FrontmatterOp = 'update' | 'delete' +type MarkdownContent = string +type ToastMessage = string | null +type VaultPath = string +type WikilinkText = string +type ScalarPropertyValue = string | number | boolean | null + const ENTRY_DELETE_MAP: Record> = { title: { title: '' }, type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null }, @@ -15,19 +24,18 @@ const ENTRY_DELETE_MAP: Record> = { _archived: { archived: false }, archived: { archived: false }, _order: { order: null }, template: { template: null }, _sort: { sort: null }, visible: { visible: null }, - _width: { noteWidth: null }, _organized: { organized: false }, _favorite: { favorite: false }, _favorite_index: { favoriteIndex: null }, _list_properties_display: { listPropertiesDisplay: [] }, } /** Check if a string contains a wikilink pattern `[[...]]`. */ -function isWikilink(s: string): boolean { - return s.startsWith('[[') && s.includes(']]') +function isWikilink(value: WikilinkText): boolean { + return value.startsWith('[[') && value.includes(']]') } /** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */ -function extractWikilinks(value: FrontmatterValue): string[] { +function extractWikilinks(value: FrontmatterValue): WikilinkText[] { if (typeof value === 'string') return isWikilink(value) ? [value] : [] if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v)) return [] @@ -37,11 +45,11 @@ function extractWikilinks(value: FrontmatterValue): string[] { * Relationship patch: a partial update to merge into `entry.relationships`. * Keys map to their new ref arrays. A `null` value means "remove this key". */ -export type RelationshipPatch = Record +export type RelationshipPatch = Record /** Properties patch: a partial update to merge into `entry.properties`. * Keys map to their new scalar values. A `null` value means "remove this key". */ -export type PropertiesPatch = Record +export type PropertiesPatch = Record export interface EntryPatchResult { patch: Partial @@ -49,6 +57,13 @@ export interface EntryPatchResult { propertiesPatch: PropertiesPatch | null } +interface FrontmatterPatchInput { + key: FrontmatterKey + lookupKey: FrontmatterKey + systemMetadataKey: boolean + value?: FrontmatterValue +} + function applyRecordPatch( existing: Record, patch: Record, @@ -61,54 +76,98 @@ function applyRecordPatch( return merged } -/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ -export function frontmatterToEntryPatch( - op: 'update' | 'delete', key: string, value?: FrontmatterValue, -): EntryPatchResult { - const lookupKey = canonicalSystemMetadataKey(key) - const systemMetadataKey = isSystemMetadataKey(key) - if (op === 'delete') { - const relationshipPatch = systemMetadataKey ? null : { [key]: null } - const propertiesPatch = !systemMetadataKey && !(lookupKey in ENTRY_DELETE_MAP) ? { [key]: null } : null - return { patch: ENTRY_DELETE_MAP[lookupKey] ?? {}, relationshipPatch, propertiesPatch } - } - const str = value != null ? String(value) : null - const arr = Array.isArray(value) ? value.map(String) : [] - const updates: Record> = { +function frontmatterString(value: FrontmatterValue | undefined): string | null { + return value != null ? String(value) : null +} + +function frontmatterStringList(value: FrontmatterValue | undefined): string[] { + return Array.isArray(value) ? value.map(String) : [] +} + +function frontmatterNumber(value: FrontmatterValue | undefined): number | null { + return typeof value === 'number' ? value : null +} + +function visibleValue(value: FrontmatterValue | undefined): false | null { + return value === false ? false : null +} + +function scalarPropertyValue(value: FrontmatterValue): ScalarPropertyValue { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + return String(value) +} + +function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record> { + const str = frontmatterString(value) + const arr = frontmatterStringList(value) + return { title: { title: str ?? '' }, type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str }, _icon: { icon: str }, _sidebar_label: { sidebarLabel: str }, aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr }, _archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) }, - _order: { order: typeof value === 'number' ? value : null }, + _order: { order: frontmatterNumber(value) }, template: { template: str }, _sort: { sort: str }, view: { view: str }, - _width: { noteWidth: str === 'wide' || str === 'normal' ? str : null }, - visible: { visible: value === false ? false : null }, + visible: { visible: visibleValue(value) }, _organized: { organized: Boolean(value) }, _favorite: { favorite: Boolean(value) }, - _favorite_index: { favoriteIndex: typeof value === 'number' ? value : null }, - _list_properties_display: { listPropertiesDisplay: Array.isArray(value) ? value.map(String) : [] }, + _favorite_index: { favoriteIndex: frontmatterNumber(value) }, + _list_properties_display: { listPropertiesDisplay: arr }, } - // Also update the relationships map for wikilink-containing values +} + +function deleteEntryPatch({ key, lookupKey, systemMetadataKey }: FrontmatterPatchInput): EntryPatchResult { + const relationshipPatch = systemMetadataKey ? null : { [key]: null } + const propertiesPatch = !systemMetadataKey && !(lookupKey in ENTRY_DELETE_MAP) ? { [key]: null } : null + return { patch: ENTRY_DELETE_MAP[lookupKey] ?? {}, relationshipPatch, propertiesPatch } +} + +function relationshipUpdatePatch( + key: FrontmatterKey, + systemMetadataKey: boolean, + value: FrontmatterValue | undefined, +): RelationshipPatch | null { const wikilinks = value != null ? extractWikilinks(value) : [] - const relationshipPatch: RelationshipPatch | null = - !systemMetadataKey && wikilinks.length > 0 ? { [key]: wikilinks } : null - // For unknown keys (custom properties), produce a propertiesPatch - const isKnownKey = lookupKey in updates - const propertiesPatch: PropertiesPatch | null = - !systemMetadataKey && !isKnownKey && value != null - ? { [key]: typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : String(value) } - : null - return { patch: updates[lookupKey] ?? {}, relationshipPatch, propertiesPatch } + return !systemMetadataKey && wikilinks.length > 0 ? { [key]: wikilinks } : null +} + +function propertiesUpdatePatch({ + key, + lookupKey, + systemMetadataKey, + value, +}: FrontmatterPatchInput): PropertiesPatch | null { + const knownUpdates = knownFrontmatterUpdates(value) + if (systemMetadataKey || lookupKey in knownUpdates || value == null) return null + return { [key]: scalarPropertyValue(value) } +} + +function updateEntryPatch(input: FrontmatterPatchInput): EntryPatchResult { + const updates = knownFrontmatterUpdates(input.value) + return { + patch: updates[input.lookupKey] ?? {}, + relationshipPatch: relationshipUpdatePatch(input.key, input.systemMetadataKey, input.value), + propertiesPatch: propertiesUpdatePatch(input), + } +} + +/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */ +export function frontmatterToEntryPatch( + op: FrontmatterOp, key: FrontmatterKey, value?: FrontmatterValue, +): EntryPatchResult { + const lookupKey = canonicalSystemMetadataKey(key) + const systemMetadataKey = isSystemMetadataKey(key) + const input = { key, lookupKey, systemMetadataKey, value } + return op === 'delete' ? deleteEntryPatch(input) : updateEntryPatch(input) } /** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */ -export function contentToEntryPatch(content: string): Partial { +export function contentToEntryPatch(content: MarkdownContent): Partial { const fm = parseFrontmatter(content) const merged: Partial = {} - const customProps: Record = {} + const customProps: Record = {} for (const [key, value] of Object.entries(fm)) { const { patch, propertiesPatch } = frontmatterToEntryPatch('update', key, value) Object.assign(merged, patch) @@ -118,25 +177,30 @@ export function contentToEntryPatch(content: string): Partial { return merged } -async function invokeFrontmatter(command: string, args: Record): Promise { +async function invokeFrontmatter(command: FrontmatterCommand, args: Record): Promise { return invoke(command, args) } -function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string { +function applyMockFrontmatterUpdate(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent { const content = updateMockFrontmatter(path, key, value) updateMockContent(path, content) trackMockChange(path) return content } -function applyMockFrontmatterDelete(path: string, key: string): string { +function applyMockFrontmatterDelete(path: VaultPath, key: FrontmatterKey): MarkdownContent { const content = deleteMockFrontmatterProperty(path, key) updateMockContent(path, content) trackMockChange(path) return content } -async function executeFrontmatterOp(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue): Promise { +async function executeFrontmatterOp( + op: FrontmatterOp, + path: VaultPath, + key: FrontmatterKey, + value?: FrontmatterValue, +): Promise { if (op === 'update') { return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!) } @@ -148,55 +212,100 @@ export interface FrontmatterOpOptions { silent?: boolean } +export interface FrontmatterApplyCallbacks { + updateTab: (path: VaultPath, content: MarkdownContent) => void + updateEntry: (path: VaultPath, patch: Partial) => void + toast: (message: ToastMessage) => void + getEntry?: (path: VaultPath) => VaultEntry | undefined +} + +export interface FrontmatterRunRequest { + op: FrontmatterOp + path: VaultPath + key: FrontmatterKey + value?: FrontmatterValue + callbacks: FrontmatterApplyCallbacks + options?: FrontmatterOpOptions +} + /** Apply a properties patch by merging into the existing properties map. */ export function applyPropertiesPatch( - existing: Record, propPatch: PropertiesPatch, -): Record { + existing: Record, propPatch: PropertiesPatch, +): Record { return applyRecordPatch(existing, propPatch) } /** Apply a relationship patch by merging into the existing relationships map. */ export function applyRelationshipPatch( - existing: Record, relPatch: RelationshipPatch, -): Record { + existing: Record, relPatch: RelationshipPatch, +): Record { return applyRecordPatch(existing, relPatch) } +function patchNeedsExistingEntry(result: EntryPatchResult): boolean { + return Boolean(result.relationshipPatch || result.propertiesPatch) +} + +function buildFullEntryPatch( + path: VaultPath, + callbacks: FrontmatterApplyCallbacks, + result: EntryPatchResult, +): Partial { + const fullPatch = { ...result.patch } + if (!patchNeedsExistingEntry(result) || !callbacks.getEntry) return fullPatch + + const current = callbacks.getEntry(path) + if (!current) return fullPatch + + if (result.relationshipPatch) { + fullPatch.relationships = applyRelationshipPatch(current.relationships, result.relationshipPatch) + } + if (result.propertiesPatch) { + fullPatch.properties = applyPropertiesPatch(current.properties, result.propertiesPatch) + } + return fullPatch +} + +function applyEntryPatch(path: VaultPath, callbacks: FrontmatterApplyCallbacks, result: EntryPatchResult) { + const fullPatch = buildFullEntryPatch(path, callbacks, result) + if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch) +} + +function successToastMessage(op: FrontmatterOp): ToastMessage { + return op === 'update' ? 'Property updated' : 'Property deleted' +} + +function notifyFrontmatterSuccess(op: FrontmatterOp, callbacks: FrontmatterApplyCallbacks, options?: FrontmatterOpOptions) { + if (!options?.silent) callbacks.toast(successToastMessage(op)) +} + +function failureToastMessage(op: FrontmatterOp): ToastMessage { + return `Failed to ${op} property` +} + +function handleFrontmatterFailure( + op: FrontmatterOp, + err: unknown, + callbacks: FrontmatterApplyCallbacks, + options?: FrontmatterOpOptions, +): undefined { + console.error(`Failed to ${op} frontmatter:`, err) + if (options?.silent) throw err + callbacks.toast(failureToastMessage(op)) + return undefined +} + /** Run a frontmatter update/delete and apply the result to state. * Returns the new file content on success, or undefined on failure. */ -export async function runFrontmatterAndApply( - op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined, - callbacks: { - updateTab: (p: string, c: string) => void - updateEntry: (p: string, patch: Partial) => void - toast: (m: string | null) => void - getEntry?: (p: string) => VaultEntry | undefined - }, - options?: FrontmatterOpOptions, -): Promise { +export async function runFrontmatterAndApply(request: FrontmatterRunRequest): Promise { + const { op, path, key, value, callbacks, options } = request try { const newContent = await executeFrontmatterOp(op, path, key, value) callbacks.updateTab(path, newContent) - const { patch, relationshipPatch, propertiesPatch } = frontmatterToEntryPatch(op, key, value) - const fullPatch = { ...patch } - if ((relationshipPatch || propertiesPatch) && callbacks.getEntry) { - const current = callbacks.getEntry(path) - if (current) { - if (relationshipPatch) { - fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch) - } - if (propertiesPatch) { - fullPatch.properties = applyPropertiesPatch(current.properties, propertiesPatch) - } - } - } - if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch) - if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted') + applyEntryPatch(path, callbacks, frontmatterToEntryPatch(op, key, value)) + notifyFrontmatterSuccess(op, callbacks, options) return newContent } catch (err) { - console.error(`Failed to ${op} frontmatter:`, err) - if (options?.silent) throw err - callbacks.toast(`Failed to ${op} property`) - return undefined + return handleFrontmatterFailure(op, err, callbacks, options) } } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 3ee036a8..03109d2f 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -7,7 +7,7 @@ import { useCommandRegistry } from './useCommandRegistry' import type { CommandAction } from './useCommandRegistry' import { useKeyboardNavigation } from './useKeyboardNavigation' import { useMenuEvents } from './useMenuEvents' -import type { NoteWidthMode, SidebarSelection, SidebarFilter, VaultEntry } from '../types' +import type { NoteLayout, SidebarSelection, SidebarFilter, VaultEntry } from '../types' import { requestAddRemote } from '../utils/addRemoteEvents' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' @@ -39,9 +39,8 @@ interface AppCommandsConfig { onToggleInspector: () => void onToggleDiff?: () => void onToggleRawEditor?: () => void - noteWidth?: NoteWidthMode - onSetNoteWidth?: (width: NoteWidthMode) => void - onSetDefaultNoteWidth?: (width: NoteWidthMode) => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void activeNoteModified: boolean onZoomIn: () => void onZoomOut: () => void @@ -152,9 +151,8 @@ type CommandRegistryCoreActions = Pick< | 'onToggleInspector' | 'onToggleDiff' | 'onToggleRawEditor' - | 'noteWidth' - | 'onSetNoteWidth' - | 'onSetDefaultNoteWidth' + | 'noteLayout' + | 'onToggleNoteLayout' | 'onToggleAIChat' > type CommandRegistryVaultActions = Pick< @@ -409,9 +407,8 @@ function createCommandRegistryCoreConfig( onToggleInspector: config.onToggleInspector, onToggleDiff: config.onToggleDiff, onToggleRawEditor: config.onToggleRawEditor, - noteWidth: config.noteWidth, - onSetNoteWidth: config.onSetNoteWidth, - onSetDefaultNoteWidth: config.onSetDefaultNoteWidth, + noteLayout: config.noteLayout, + onToggleNoteLayout: config.onToggleNoteLayout, onToggleAIChat: config.onToggleAIChat, } } diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index b6c06203..a8919f2e 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -25,9 +25,8 @@ function makeConfig(overrides: Record = {}) { onToggleInspector: vi.fn(), onToggleDiff: vi.fn(), onToggleRawEditor: vi.fn(), - noteWidth: 'normal', - onSetNoteWidth: vi.fn(), - onSetDefaultNoteWidth: vi.fn(), + noteLayout: 'centered', + onToggleNoteLayout: vi.fn(), onToggleAIChat: vi.fn(), onOpenVault: vi.fn(), activeNoteModified: false, @@ -344,39 +343,27 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false) }) - it('exposes command palette actions for active note width', () => { - const onSetNoteWidth = vi.fn() - const config = makeConfig({ noteWidth: 'normal', onSetNoteWidth }) + it('exposes a command palette action for the note layout preference', () => { + const onToggleNoteLayout = vi.fn() + const config = makeConfig({ noteLayout: 'centered', onToggleNoteLayout }) const { result } = renderHook(() => useCommandRegistry(config)) - const cmd = findCommand(result.current, 'set-note-width-wide') + const cmd = findCommand(result.current, 'toggle-note-layout') expect(cmd).toBeDefined() expect(cmd!.group).toBe('View') - expect(cmd!.label).toBe('Set Note Width: Wide') + expect(cmd!.label).toBe('Use Left-Aligned Note Layout') expect(cmd!.keywords).toContain('wide') cmd!.execute() - expect(onSetNoteWidth).toHaveBeenCalledWith('wide') + expect(onToggleNoteLayout).toHaveBeenCalledOnce() }) - it('disables the active note width command when already selected', () => { - const config = makeConfig({ noteWidth: 'wide' }) + it('updates note layout command copy when left alignment is active', () => { + const config = makeConfig({ noteLayout: 'left' }) const { result } = renderHook(() => useCommandRegistry(config)) - expect(findCommand(result.current, 'set-note-width-wide')?.enabled).toBe(false) - expect(findCommand(result.current, 'set-note-width-normal')?.enabled).toBe(true) - }) - - it('exposes default note width commands backed by settings', () => { - const onSetDefaultNoteWidth = vi.fn() - const config = makeConfig({ onSetDefaultNoteWidth }) - const { result } = renderHook(() => useCommandRegistry(config)) - const cmd = findCommand(result.current, 'set-default-note-width-wide') - - expect(cmd?.label).toBe('Set Default Note Width: Wide') - cmd?.execute() - expect(onSetDefaultNoteWidth).toHaveBeenCalledWith('wide') + expect(findCommand(result.current, 'toggle-note-layout')?.label).toBe('Use Centered Note Layout') }) it('includes a New AI chat command that opens and resets the panel session', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 52aa1cc8..3da62c6c 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents' import type { AppLocale, UiLanguagePreference } from '../lib/i18n' import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance' -import type { NoteWidthMode, SidebarSelection, VaultEntry } from '../types' +import type { NoteLayout, SidebarSelection, VaultEntry } from '../types' import type { NoteListFilter } from '../utils/noteListHelpers' import type { ViewMode } from './useViewMode' import { buildNavigationCommands } from './commands/navigationCommands' @@ -83,9 +83,8 @@ interface CommandRegistryConfig { onToggleInspector: () => void onToggleDiff?: () => void onToggleRawEditor?: () => void - noteWidth?: NoteWidthMode - onSetNoteWidth?: (width: NoteWidthMode) => void - onSetDefaultNoteWidth?: (width: NoteWidthMode) => void + noteLayout?: NoteLayout + onToggleNoteLayout?: () => void onToggleAIChat?: () => void activeNoteModified: boolean onCheckForUpdates?: () => void @@ -117,7 +116,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com activeTabPath, entries, modifiedCount, onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault, + onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, activeNoteModified, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath, @@ -210,11 +209,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const viewCommands = useMemo(() => buildViewCommands({ hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, - onToggleDiff, onToggleRawEditor, noteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, + onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, }), [ hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, - onToggleDiff, onToggleRawEditor, noteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, + onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset, onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel, ]) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 8d391dca..fa0833fa 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -335,7 +335,14 @@ export function useNoteActions(config: NoteActionsConfig) { const runFrontmatterOp = useCallback( (op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) => - runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options), + runFrontmatterAndApply({ + op, + path, + key, + value, + callbacks: { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, + options, + }), [updateTabContent, updateEntry, setToastMessage, entries], ) const frontmatterActions = useFrontmatterActionHandlers({ diff --git a/src/hooks/useNoteLayout.test.ts b/src/hooks/useNoteLayout.test.ts new file mode 100644 index 00000000..52e59132 --- /dev/null +++ b/src/hooks/useNoteLayout.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useNoteLayout } from './useNoteLayout' +import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore' + +describe('useNoteLayout', () => { + beforeEach(() => { + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, editor_mode: null, note_layout: null, tag_colors: null, status_colors: null, property_display_modes: null }, + vi.fn(), + ) + }) + + it('defaults to centered note layout', () => { + const { result } = renderHook(() => useNoteLayout()) + + expect(result.current.noteLayout).toBe('centered') + }) + + it('loads persisted left note layout from vault config', () => { + resetVaultConfigStore() + bindVaultConfigStore( + { zoom: null, view_mode: null, editor_mode: null, note_layout: 'left', tag_colors: null, status_colors: null, property_display_modes: null }, + vi.fn(), + ) + + const { result } = renderHook(() => useNoteLayout()) + + expect(result.current.noteLayout).toBe('left') + }) + + it('sets note layout and persists it to vault config', () => { + const { result } = renderHook(() => useNoteLayout()) + + act(() => result.current.setNoteLayout('left')) + + expect(result.current.noteLayout).toBe('left') + expect(getVaultConfig().note_layout).toBe('left') + }) + + it('toggles between centered and left note layout', () => { + const { result } = renderHook(() => useNoteLayout()) + + act(() => result.current.toggleNoteLayout()) + expect(result.current.noteLayout).toBe('left') + + act(() => result.current.toggleNoteLayout()) + expect(result.current.noteLayout).toBe('centered') + }) +}) diff --git a/src/hooks/useNoteLayout.ts b/src/hooks/useNoteLayout.ts new file mode 100644 index 00000000..045eb753 --- /dev/null +++ b/src/hooks/useNoteLayout.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react' +import type { NoteLayout } from '../types' +import { getVaultConfig, subscribeVaultConfig, updateVaultConfigField } from '../utils/vaultConfigStore' + +function isNoteLayout(value: string | null | undefined): value is NoteLayout { + return value === 'centered' || value === 'left' +} + +function loadNoteLayout(): NoteLayout { + const stored = getVaultConfig().note_layout + return isNoteLayout(stored) ? stored : 'centered' +} + +export function useNoteLayout() { + const [noteLayout, setNoteLayoutState] = useState(loadNoteLayout) + + useEffect(() => { + return subscribeVaultConfig(() => { + setNoteLayoutState(loadNoteLayout()) + }) + }, []) + + const setNoteLayout = useCallback((layout: NoteLayout) => { + setNoteLayoutState(layout) + updateVaultConfigField('note_layout', layout) + }, []) + + const toggleNoteLayout = useCallback(() => { + setNoteLayout(noteLayout === 'left' ? 'centered' : 'left') + }, [noteLayout, setNoteLayout]) + + return { noteLayout, setNoteLayout, toggleNoteLayout } +} diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 2b819670..045978d9 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -18,7 +18,6 @@ const defaultSettings: Settings = { theme_mode: null, ui_language: null, default_ai_agent: null, - note_width_mode: null, } const savedSettings: Settings = { @@ -35,7 +34,6 @@ const savedSettings: Settings = { theme_mode: null, ui_language: null, default_ai_agent: null, - note_width_mode: null, } let mockSettingsStore: Settings = { ...defaultSettings } @@ -85,7 +83,6 @@ function changedSettings(): Settings { theme_mode: null, ui_language: 'zh-Hans', default_ai_agent: null, - note_width_mode: 'wide', } } @@ -136,16 +133,6 @@ describe('useSettings', () => { expect(settings.release_channel).toBeNull() }) - it('normalizes invalid note width settings on load', async () => { - mockSettingsStore = { - ...savedSettings, - note_width_mode: 'full' as Settings['note_width_mode'], - } - - const settings = await renderLoadedSettings() - expect(settings.note_width_mode).toBeNull() - }) - it('normalizes unsupported language preferences on load', async () => { mockSettingsStore = { ...savedSettings, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c04067d0..3f2ca2e6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -5,7 +5,6 @@ import { normalizeStoredAiAgent } from '../lib/aiAgents' import { serializeUiLanguagePreference } from '../lib/i18n' import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel' import { normalizeThemeMode } from '../lib/themeMode' -import { normalizeNoteWidthMode } from '../utils/noteWidth' import type { Settings } from '../types' async function invokeNativeIfAvailable(command: string, tauriArgs: Record): Promise { @@ -40,7 +39,6 @@ const EMPTY_SETTINGS: Settings = { theme_mode: null, ui_language: null, default_ai_agent: null, - note_width_mode: null, } function normalizeSettings(settings: Settings): Settings { @@ -52,7 +50,6 @@ function normalizeSettings(settings: Settings): Settings { theme_mode: normalizeThemeMode(settings.theme_mode), ui_language: serializeUiLanguagePreference(settings.ui_language), default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent), - note_width_mode: normalizeNoteWidthMode(settings.note_width_mode), } } diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index 31eb6f06..c42b7600 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -63,10 +63,8 @@ export const EN_TRANSLATIONS = { 'command.view.toggleProperties': 'Toggle Properties Panel', 'command.view.toggleDiff': 'Toggle Diff Mode', 'command.view.toggleRaw': 'Toggle Raw Editor', - 'command.view.setNoteWidthNormal': 'Set Note Width: Normal', - 'command.view.setNoteWidthWide': 'Set Note Width: Wide', - 'command.view.setDefaultNoteWidthNormal': 'Set Default Note Width: Normal', - 'command.view.setDefaultNoteWidthWide': 'Set Default Note Width: Wide', + 'command.view.leftLayout': 'Use Left-Aligned Note Layout', + 'command.view.centerLayout': 'Use Centered Note Layout', 'command.view.toggleAiPanel': 'Toggle AI Panel', 'command.view.newAiChat': 'New AI chat', 'command.view.toggleBacklinks': 'Toggle Backlinks', @@ -231,8 +229,8 @@ export const EN_TRANSLATIONS = { 'editor.raw.label': 'Raw editor', 'editor.toolbar.rawReturn': 'Return to the editor', 'editor.toolbar.rawOpen': 'Open the raw editor', - 'editor.toolbar.normalWidth': 'Switch this note to normal width', - 'editor.toolbar.wideWidth': 'Switch this note to wide width', + 'editor.toolbar.centerLayout': 'Switch to centered note layout', + 'editor.toolbar.leftLayout': 'Switch to left-aligned note layout', 'editor.toolbar.removeFavorite': 'Remove from favorites', 'editor.toolbar.addFavorite': 'Add to favorites', 'editor.toolbar.markUnorganized': 'Set note as not organized', diff --git a/src/lib/locales/zh-Hans.ts b/src/lib/locales/zh-Hans.ts index 6ecfaec3..a054ed86 100644 --- a/src/lib/locales/zh-Hans.ts +++ b/src/lib/locales/zh-Hans.ts @@ -65,10 +65,8 @@ export const ZH_HANS_TRANSLATIONS = { 'command.view.toggleProperties': '切换属性面板', 'command.view.toggleDiff': '切换差异模式', 'command.view.toggleRaw': '切换源码编辑器', - 'command.view.setNoteWidthNormal': '设置笔记宽度:普通', - 'command.view.setNoteWidthWide': '设置笔记宽度:宽屏', - 'command.view.setDefaultNoteWidthNormal': '设置默认笔记宽度:普通', - 'command.view.setDefaultNoteWidthWide': '设置默认笔记宽度:宽屏', + 'command.view.leftLayout': '使用左对齐笔记布局', + 'command.view.centerLayout': '使用居中笔记布局', 'command.view.toggleAiPanel': '切换 AI 面板', 'command.view.newAiChat': '新建 AI 对话', 'command.view.toggleBacklinks': '切换反向链接', @@ -233,8 +231,8 @@ export const ZH_HANS_TRANSLATIONS = { 'editor.raw.label': '源码编辑器', 'editor.toolbar.rawReturn': '返回编辑器', 'editor.toolbar.rawOpen': '打开源码编辑器', - 'editor.toolbar.normalWidth': '将此笔记切换为普通宽度', - 'editor.toolbar.wideWidth': '将此笔记切换为宽屏宽度', + 'editor.toolbar.centerLayout': '切换到居中笔记布局', + 'editor.toolbar.leftLayout': '切换到左对齐笔记布局', 'editor.toolbar.removeFavorite': '从收藏中移除', 'editor.toolbar.addFavorite': '添加到收藏', 'editor.toolbar.markUnorganized': '将笔记标记为未整理', diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts index 89193faf..0347f332 100644 --- a/src/mock-tauri/mock-handlers.coverage.test.ts +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -169,7 +169,6 @@ describe('mockHandlers coverage', () => { anonymous_id: 'anon-1', release_channel: 'alpha', theme_mode: null, - note_width_mode: null, ui_language: 'zh-Hans', default_ai_agent: 'codex', }) diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index b431efaa..1a24c82f 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -114,7 +114,6 @@ let mockSettings: Settings = { theme_mode: null, ui_language: null, default_ai_agent: 'claude_code', - note_width_mode: null, } const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2' @@ -424,7 +423,6 @@ export const mockHandlers: Record any> = { theme_mode: s.theme_mode ?? null, ui_language: s.ui_language ?? null, default_ai_agent: s.default_ai_agent ?? null, - note_width_mode: s.note_width_mode ?? null, } return null }, diff --git a/src/types.ts b/src/types.ts index 54330031..5080240c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,8 +45,6 @@ export interface VaultEntry { favoriteIndex: number | null /** Properties to display as chips in the note list for this Type's notes. */ listPropertiesDisplay: string[] - /** Per-note content width override stored in `_width` frontmatter. */ - noteWidth?: NoteWidthMode | null /** All wikilink targets found in the note content. Extracted from [[target]] patterns. */ outgoingLinks: string[] /** Custom scalar frontmatter properties (non-relationship, non-structural). */ @@ -97,7 +95,6 @@ export interface Settings { ui_language?: AppLocale | null initial_h1_auto_rename_enabled?: boolean | null default_ai_agent?: AiAgentId | null - note_width_mode?: NoteWidthMode | null } export interface GitPullResult { @@ -154,11 +151,9 @@ export interface AllNotesConfig { noteListProperties: string[] | null } -/** Legacy vault-scoped note layout value retained for persisted config compatibility. */ +/** Vault-scoped UI configuration stored locally per vault path. */ export type NoteLayout = 'centered' | 'left' -export type NoteWidthMode = 'normal' | 'wide' - /** Vault-scoped UI configuration stored locally per vault path. */ export interface VaultConfig { zoom: number | null diff --git a/src/utils/noteWidth.test.ts b/src/utils/noteWidth.test.ts deleted file mode 100644 index 12409850..00000000 --- a/src/utils/noteWidth.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - DEFAULT_NOTE_WIDTH_MODE, - normalizeNoteWidthMode, - resolveNoteWidthMode, - toggleNoteWidthMode, -} from './noteWidth' - -describe('noteWidth', () => { - it('normalizes known width modes', () => { - expect(normalizeNoteWidthMode('normal')).toBe('normal') - expect(normalizeNoteWidthMode(' Wide ')).toBe('wide') - }) - - it('rejects unknown width modes', () => { - expect(normalizeNoteWidthMode('narrow')).toBeNull() - expect(normalizeNoteWidthMode(null)).toBeNull() - }) - - it('prefers per-note width before default settings', () => { - expect(resolveNoteWidthMode('wide', 'normal')).toBe('wide') - expect(resolveNoteWidthMode(null, 'wide')).toBe('wide') - expect(resolveNoteWidthMode('invalid', 'invalid')).toBe(DEFAULT_NOTE_WIDTH_MODE) - }) - - it('toggles between normal and wide', () => { - expect(toggleNoteWidthMode('normal')).toBe('wide') - expect(toggleNoteWidthMode('wide')).toBe('normal') - }) -}) diff --git a/src/utils/noteWidth.ts b/src/utils/noteWidth.ts deleted file mode 100644 index dd3001f9..00000000 --- a/src/utils/noteWidth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { NoteWidthMode } from '../types' - -export const DEFAULT_NOTE_WIDTH_MODE: NoteWidthMode = 'normal' - -export function normalizeNoteWidthMode(value: unknown): NoteWidthMode | null { - if (typeof value !== 'string') return null - - const normalized = value.trim().toLowerCase() - return normalized === 'normal' || normalized === 'wide' ? normalized : null -} - -export function resolveNoteWidthMode( - noteWidth: unknown, - defaultWidth: unknown, -): NoteWidthMode { - return normalizeNoteWidthMode(noteWidth) - ?? normalizeNoteWidthMode(defaultWidth) - ?? DEFAULT_NOTE_WIDTH_MODE -} - -export function toggleNoteWidthMode(width: unknown): NoteWidthMode { - return resolveNoteWidthMode(width, DEFAULT_NOTE_WIDTH_MODE) === 'wide' ? 'normal' : 'wide' -} diff --git a/src/utils/systemMetadata.ts b/src/utils/systemMetadata.ts index 4db401a6..32fecd99 100644 --- a/src/utils/systemMetadata.ts +++ b/src/utils/systemMetadata.ts @@ -3,7 +3,6 @@ const SYSTEM_METADATA_ALIAS_GROUPS = { _order: ['_order', 'order'], _sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'], _sort: ['_sort', 'sort'], - _width: ['_width'], } as const const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS)