Merge branch 'main' into main
This commit is contained in:
@@ -47,6 +47,7 @@ _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 # rich-editor width override for this note
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -87,6 +88,7 @@ classDiagram
|
||||
+Record~string,string[]~ relationships
|
||||
+String[] outgoingLinks
|
||||
+String? status
|
||||
+String? noteWidth
|
||||
+Number? modifiedAt
|
||||
+Number? createdAt
|
||||
+Number wordCount
|
||||
@@ -135,6 +137,7 @@ interface VaultEntry {
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width`
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
// Note: owner and cadence are now in the generic `properties` map
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
@@ -602,6 +605,12 @@ While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry`
|
||||
|
||||
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
|
||||
|
||||
### Rich Editor Width Modes
|
||||
|
||||
Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette.
|
||||
|
||||
Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width.
|
||||
|
||||
### Arrow Ligature Normalization
|
||||
|
||||
Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
@@ -685,7 +694,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, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
@@ -744,12 +753,13 @@ interface Settings {
|
||||
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
|
||||
theme_mode: 'light' | 'dark' | null
|
||||
ui_language: AppLocale | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
@@ -32,8 +33,10 @@ 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 that already has frontmatter (per-note reading/editing preference)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
|
||||
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -194,7 +197,7 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, 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. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. 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 and PDF binaries get file indicators 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-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, 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 and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns 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 rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns 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.
|
||||
|
||||
@@ -66,6 +66,10 @@ files:
|
||||
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
|
||||
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
|
||||
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
|
||||
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
|
||||
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
|
||||
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
|
||||
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
|
||||
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
|
||||
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
|
||||
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
|
||||
@@ -316,6 +320,8 @@ files:
|
||||
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
|
||||
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
|
||||
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
|
||||
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
|
||||
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
|
||||
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
|
||||
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
|
||||
|
||||
@@ -6,6 +6,7 @@ const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
|
||||
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -21,6 +22,7 @@ pub struct Settings {
|
||||
pub release_channel: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub ui_language: Option<String>,
|
||||
pub note_width_mode: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
pub hide_gitignored_files: Option<bool>,
|
||||
@@ -65,6 +67,13 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
|
||||
settings
|
||||
.hide_gitignored_files
|
||||
@@ -123,6 +132,7 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
|
||||
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
|
||||
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
|
||||
note_width_mode: normalize_note_width_mode(settings.note_width_mode.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()),
|
||||
hide_gitignored_files: settings.hide_gitignored_files,
|
||||
@@ -266,6 +276,7 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
@@ -294,6 +305,7 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
@@ -307,6 +319,7 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
assert_eq!(loaded.hide_gitignored_files, Some(false));
|
||||
@@ -332,6 +345,7 @@ mod tests {
|
||||
release_channel: Some(" alpha ".to_string()),
|
||||
theme_mode: Some(" dark ".to_string()),
|
||||
ui_language: Some(" zh-cn ".to_string()),
|
||||
note_width_mode: Some(" WIDE ".to_string()),
|
||||
default_ai_agent: Some(" codex ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -339,6 +353,7 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
@@ -416,6 +431,15 @@ 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("expanded".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 {
|
||||
|
||||
@@ -54,6 +54,9 @@ 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<String>,
|
||||
/// Rich-editor width mode for this note. None means use the app default.
|
||||
#[serde(rename = "noteWidth")]
|
||||
pub note_width: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note has been explicitly organized (removed from Inbox).
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) struct Frontmatter {
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(rename = "_width", alias = "width", default)]
|
||||
pub note_width: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
@@ -203,6 +205,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_sort",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"notion_id",
|
||||
"Status",
|
||||
@@ -238,6 +242,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"status",
|
||||
"_organized",
|
||||
@@ -332,6 +338,16 @@ pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
|
||||
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
|
||||
match note_width
|
||||
.and_then(StringOrList::into_scalar)
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
{
|
||||
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert gray_matter::Pod to serde_json::Value
|
||||
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
match pod {
|
||||
|
||||
@@ -41,7 +41,7 @@ pub use views::{
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a};
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_width};
|
||||
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -151,6 +151,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
template: frontmatter.template.and_then(|v| v.into_scalar()),
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
note_width: resolve_note_width(frontmatter.note_width),
|
||||
visible: frontmatter.visible,
|
||||
organized: frontmatter.organized.unwrap_or(false),
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
|
||||
@@ -71,3 +71,29 @@ fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_note_width_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: wide\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width.as_deref(), Some("wide"));
|
||||
assert!(!entry.properties.contains_key("_width"));
|
||||
assert!(!entry.relationships.contains_key("_width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_note_width_modes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width, None);
|
||||
}
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -32,6 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteWidthMode } from './hooks/useNoteWidthMode'
|
||||
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
|
||||
import { useThemeMode } from './hooks/useThemeMode'
|
||||
import type { ThemeMode } from './lib/themeMode'
|
||||
@@ -40,7 +41,6 @@ import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useNoteLayout } from './hooks/useNoteLayout'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
@@ -1164,7 +1164,6 @@ function App() {
|
||||
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
|
||||
const { noteLayout, toggleNoteLayout } = useNoteLayout()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
@@ -1415,6 +1414,22 @@ function App() {
|
||||
return entries
|
||||
}, [reloadVaultForCommand, setToastMessage])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
noteWidth: activeNoteWidth,
|
||||
setDefaultNoteWidth: handleSetDefaultNoteWidth,
|
||||
setNoteWidth: handleSetActiveNoteWidth,
|
||||
toggleNoteWidth: handleToggleNoteWidth,
|
||||
} = useNoteWidthMode({
|
||||
tabs: notes.tabs,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
settings,
|
||||
saveSettings,
|
||||
updateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
@@ -1443,8 +1458,10 @@ function App() {
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: toggleDiffCommand,
|
||||
onToggleRawEditor: toggleRawEditorCommand,
|
||||
noteLayout,
|
||||
onToggleNoteLayout: toggleNoteLayout,
|
||||
noteWidth: activeNoteWidth,
|
||||
defaultNoteWidth,
|
||||
onSetNoteWidth: handleSetActiveNoteWidth,
|
||||
onSetDefaultNoteWidth: handleSetDefaultNoteWidth,
|
||||
selectedViewName: viewOrdering.selectedViewName,
|
||||
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
|
||||
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
|
||||
@@ -1509,8 +1526,6 @@ 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<NoteListItem[]>(() => {
|
||||
@@ -1663,8 +1678,8 @@ function App() {
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={toggleNoteLayout}
|
||||
noteWidth={activeNoteWidth}
|
||||
onToggleNoteWidth={handleToggleNoteWidth}
|
||||
rawToggleRef={rawToggleRef}
|
||||
findInNoteRef={findInNoteRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
|
||||
@@ -327,25 +327,25 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — note layout toggle', () => {
|
||||
it('shows the left-align layout action while centered', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
|
||||
describe('BreadcrumbBar — note width toggle', () => {
|
||||
it('shows the wide width action while normal', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to wide note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the centered layout action while left-aligned', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
|
||||
it('shows the normal width action while wide', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="wide" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to normal note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleNoteLayout when the layout button is clicked', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
|
||||
it('calls onToggleNoteWidth when the width button is clicked', () => {
|
||||
const onToggleNoteWidth = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={onToggleNoteWidth} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to wide note width' }))
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { NoteLayout, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
TextAlignCenter,
|
||||
TextAlignLeft,
|
||||
ArrowsInLineHorizontal,
|
||||
ArrowsOutLineHorizontal,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
@@ -50,8 +50,8 @@ interface BreadcrumbBarProps {
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
locale?: AppLocale
|
||||
@@ -257,27 +257,27 @@ function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: bo
|
||||
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
|
||||
}
|
||||
|
||||
function NoteLayoutAction({
|
||||
noteLayout = 'centered',
|
||||
function NoteWidthAction({
|
||||
noteWidth = 'normal',
|
||||
locale = 'en',
|
||||
onToggleNoteLayout,
|
||||
onToggleNoteWidth,
|
||||
}: {
|
||||
noteLayout?: NoteLayout
|
||||
noteWidth?: NoteWidthMode
|
||||
locale?: AppLocale
|
||||
onToggleNoteLayout?: () => void
|
||||
onToggleNoteWidth?: () => void
|
||||
}) {
|
||||
if (!onToggleNoteLayout) return null
|
||||
if (!onToggleNoteWidth) return null
|
||||
|
||||
const isLeftAligned = noteLayout === 'left'
|
||||
const isWide = noteWidth === 'wide'
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
|
||||
onClick={onToggleNoteLayout}
|
||||
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
|
||||
copy={{ label: translate(locale, isWide ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide') }}
|
||||
onClick={onToggleNoteWidth}
|
||||
className={cn(isWide ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
{isLeftAligned
|
||||
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
{isWide
|
||||
? <ArrowsInLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <ArrowsOutLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -624,8 +624,8 @@ function BreadcrumbActions({
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
@@ -651,7 +651,7 @@ function BreadcrumbActions({
|
||||
locale={locale}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
|
||||
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
|
||||
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
|
||||
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
|
||||
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
|
||||
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
|
||||
@@ -204,9 +204,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: clamp(16px, 6%, 96px);
|
||||
margin-right: auto;
|
||||
.editor-content-width--wide .editor-content-wrapper {
|
||||
max-width: min(100%, 1280px);
|
||||
padding-left: clamp(24px, 4vw, 72px);
|
||||
padding-right: clamp(24px, 4vw, 72px);
|
||||
}
|
||||
|
||||
.raw-editor-codemirror {
|
||||
@@ -332,10 +333,3 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container editor (max-width: 900px) {
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
|
||||
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
@@ -83,8 +83,8 @@ interface EditorProps {
|
||||
onSave?: () => void
|
||||
/** Called when the user explicitly renames the filename from the breadcrumb. */
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
@@ -324,8 +324,8 @@ function EditorLayout({
|
||||
findRequest,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
@@ -386,8 +386,8 @@ function EditorLayout({
|
||||
findRequest?: RawEditorFindRequest | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
@@ -463,8 +463,8 @@ function EditorLayout({
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
@@ -524,7 +524,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteLayout, onToggleNoteLayout,
|
||||
noteWidth, onToggleNoteWidth,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef,
|
||||
@@ -599,8 +599,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { EditorContentLayout } from './EditorContentLayout'
|
||||
|
||||
vi.mock('../BreadcrumbBar', () => ({
|
||||
BreadcrumbBar: ({ noteLayout }: { noteLayout?: string }) => <div data-testid="breadcrumb-bar" data-note-layout={noteLayout} />,
|
||||
BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) => <div data-testid="breadcrumb-bar" data-note-width={noteWidth} />,
|
||||
}))
|
||||
|
||||
vi.mock('../ArchivedNoteBanner', () => ({
|
||||
@@ -63,8 +63,8 @@ function createModel(overrides: Record<string, unknown> = {}) {
|
||||
onEditorChange: vi.fn(),
|
||||
isDeletedPreview: false,
|
||||
rawLatestContentRef: { current: null },
|
||||
noteLayout: 'centered',
|
||||
onToggleNoteLayout: vi.fn(),
|
||||
noteWidth: 'normal',
|
||||
onToggleNoteWidth: vi.fn(),
|
||||
forceRawMode: false,
|
||||
showAIChat: false,
|
||||
onToggleAIChat: vi.fn(),
|
||||
@@ -102,18 +102,18 @@ describe('EditorContentLayout', () => {
|
||||
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the editor content root and breadcrumb with the note layout preference', () => {
|
||||
const { container } = render(<EditorContentLayout {...createModel({ noteLayout: 'left' })} />)
|
||||
it('marks the editor content root and breadcrumb with the note width mode', () => {
|
||||
const { container } = render(<EditorContentLayout {...createModel({ noteWidth: 'wide' })} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('editor-content-layout--left')
|
||||
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left')
|
||||
expect(container.firstElementChild).toHaveClass('editor-content-width--wide')
|
||||
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide')
|
||||
})
|
||||
|
||||
it('keeps raw mode out of the centered rich-editor content wrapper', () => {
|
||||
it('keeps raw mode out of the rich-editor content wrapper', () => {
|
||||
render(<EditorContentLayout {...createModel({
|
||||
effectiveRawMode: true,
|
||||
showEditor: false,
|
||||
noteLayout: 'centered',
|
||||
noteWidth: 'normal',
|
||||
})} />)
|
||||
|
||||
const rawEditor = screen.getByTestId('raw-editor-view')
|
||||
|
||||
@@ -34,8 +34,8 @@ type BreadcrumbActions = Pick<
|
||||
| 'onArchiveNote'
|
||||
| 'onUnarchiveNote'
|
||||
| 'onRenameFilename'
|
||||
| 'noteLayout'
|
||||
| 'onToggleNoteLayout'
|
||||
| 'noteWidth'
|
||||
| 'onToggleNoteWidth'
|
||||
>
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -149,8 +149,8 @@ function ActiveTabBreadcrumb({
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
|
||||
onRenameFilename={actions.onRenameFilename}
|
||||
noteLayout={actions.noteLayout}
|
||||
onToggleNoteLayout={actions.onToggleNoteLayout}
|
||||
noteWidth={actions.noteWidth}
|
||||
onToggleNoteWidth={actions.onToggleNoteWidth}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
@@ -287,13 +287,13 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
isDeletedPreview,
|
||||
rawLatestContentRef,
|
||||
rawModeContent,
|
||||
noteLayout,
|
||||
noteWidth,
|
||||
findRequest,
|
||||
locale,
|
||||
} = model
|
||||
const rootClassName = cn(
|
||||
'flex flex-1 flex-col min-w-0 min-h-0',
|
||||
noteLayout === 'left' ? 'editor-content-layout--left' : 'editor-content-layout--centered',
|
||||
noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal',
|
||||
)
|
||||
|
||||
if (!activeTab) {
|
||||
@@ -332,8 +332,8 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
onUnarchiveNote: model.onUnarchiveNote,
|
||||
onRenameFilename: model.onRenameFilename,
|
||||
noteLayout: model.noteLayout,
|
||||
onToggleNoteLayout: model.onToggleNoteLayout,
|
||||
noteWidth: model.noteWidth,
|
||||
onToggleNoteWidth: model.onToggleNoteWidth,
|
||||
}}
|
||||
/>
|
||||
<EditorChrome
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from 'react'
|
||||
import { useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { AppLocale } from '../../lib/i18n'
|
||||
import type { NoteLayout, NoteStatus, VaultEntry } from '../../types'
|
||||
import type { NoteWidthMode, NoteStatus, VaultEntry } from '../../types'
|
||||
import { useEditorTheme } from '../../hooks/useTheme'
|
||||
import { deriveEditorContentState } from './editorContentState'
|
||||
import type { RawEditorFindRequest } from '../RawEditorFindBar'
|
||||
@@ -45,8 +45,8 @@ export interface EditorContentProps {
|
||||
findRequest?: RawEditorFindRequest | null
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
|
||||
@@ -49,6 +49,10 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
|
||||
'toggle-inspector': 'command.view.toggleProperties',
|
||||
'toggle-diff': 'command.view.toggleDiff',
|
||||
'toggle-raw-editor': 'command.view.toggleRaw',
|
||||
'set-note-width-normal': 'command.view.noteWidthNormal',
|
||||
'set-note-width-wide': 'command.view.noteWidthWide',
|
||||
'set-default-note-width-normal': 'command.view.defaultNoteWidthNormal',
|
||||
'set-default-note-width-wide': 'command.view.defaultNoteWidthWide',
|
||||
'toggle-ai-panel': 'command.view.toggleAiPanel',
|
||||
'new-ai-chat': 'command.view.newAiChat',
|
||||
'toggle-backlinks': 'command.view.toggleBacklinks',
|
||||
@@ -116,7 +120,6 @@ function localizeMoveSavedViewCommand(command: CommandAction, t: Translate, dire
|
||||
}
|
||||
|
||||
const VIEW_STATE_LABELERS: Partial<Record<string, CommandLabeler>> = {
|
||||
'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,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import type { NoteLayout } from '../../types'
|
||||
import type { NoteWidthMode } from '../../types'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
import { DEFAULT_NOTE_WIDTH_MODE } from '../../utils/noteWidth'
|
||||
|
||||
const NOTE_LAYOUT_COMMAND_LABELS: Record<NoteLayout, string> = {
|
||||
centered: 'Use Left-Aligned Note Layout',
|
||||
left: 'Use Centered Note Layout',
|
||||
const NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
|
||||
normal: 'Use Normal Note Width',
|
||||
wide: 'Use Wide Note Width',
|
||||
}
|
||||
|
||||
const DEFAULT_NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
|
||||
normal: 'Use Normal Note Width by Default',
|
||||
wide: 'Use Wide Note Width by Default',
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
@@ -18,8 +24,10 @@ interface ViewCommandsConfig {
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onToggleAIChat?: () => void
|
||||
zoomLevel: number
|
||||
onZoomIn: () => void
|
||||
@@ -35,14 +43,34 @@ interface ViewCommandsConfig {
|
||||
canMoveSelectedViewDown?: boolean
|
||||
}
|
||||
|
||||
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
|
||||
function buildSetNoteWidthCommand(
|
||||
mode: NoteWidthMode,
|
||||
activeMode: NoteWidthMode,
|
||||
hasActiveNote: boolean,
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void,
|
||||
): CommandAction {
|
||||
return {
|
||||
id: 'toggle-note-layout',
|
||||
label: NOTE_LAYOUT_COMMAND_LABELS[noteLayout],
|
||||
id: `set-note-width-${mode}`,
|
||||
label: NOTE_WIDTH_COMMAND_LABELS[mode],
|
||||
group: 'View',
|
||||
keywords: ['layout', 'note', 'column', 'wide', 'left', 'centered', 'reading'],
|
||||
enabled: Boolean(onToggleNoteLayout),
|
||||
execute: onToggleNoteLayout ?? noop,
|
||||
keywords: ['layout', 'note', 'column', 'width', mode, 'reading'],
|
||||
enabled: hasActiveNote && Boolean(onSetNoteWidth) && activeMode !== mode,
|
||||
execute: onSetNoteWidth ? () => onSetNoteWidth(mode) : noop,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSetDefaultNoteWidthCommand(
|
||||
mode: NoteWidthMode,
|
||||
defaultMode: NoteWidthMode,
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void,
|
||||
): CommandAction {
|
||||
return {
|
||||
id: `set-default-note-width-${mode}`,
|
||||
label: DEFAULT_NOTE_WIDTH_COMMAND_LABELS[mode],
|
||||
group: 'View',
|
||||
keywords: ['layout', 'note', 'column', 'width', mode, 'default', 'reading'],
|
||||
enabled: Boolean(onSetDefaultNoteWidth) && defaultMode !== mode,
|
||||
execute: onSetDefaultNoteWidth ? () => onSetDefaultNoteWidth(mode) : noop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +95,9 @@ function buildMoveSavedViewCommand(
|
||||
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
hasActiveNote, activeNoteModified,
|
||||
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
|
||||
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor,
|
||||
noteWidth = DEFAULT_NOTE_WIDTH_MODE, defaultNoteWidth = DEFAULT_NOTE_WIDTH_MODE,
|
||||
onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
|
||||
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
|
||||
@@ -81,7 +111,10 @@ 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?.() },
|
||||
buildNoteLayoutCommand(noteLayout, onToggleNoteLayout),
|
||||
buildSetNoteWidthCommand('normal', noteWidth, hasActiveNote, onSetNoteWidth),
|
||||
buildSetNoteWidthCommand('wide', noteWidth, hasActiveNote, onSetNoteWidth),
|
||||
buildSetDefaultNoteWidthCommand('normal', defaultNoteWidth, onSetDefaultNoteWidth),
|
||||
buildSetDefaultNoteWidthCommand('wide', defaultNoteWidth, onSetDefaultNoteWidth),
|
||||
{ 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 },
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', true, { archived: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
['_width', 'wide', { noteWidth: 'wide' }],
|
||||
['width', 'normal', { noteWidth: 'normal' }],
|
||||
['visible', false, { visible: false }],
|
||||
['visible', true, { visible: null }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
@@ -68,6 +70,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
['_width', { noteWidth: null }],
|
||||
['visible', { visible: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
@@ -165,6 +168,11 @@ describe('contentToEntryPatch', () => {
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', properties: { custom: 'value' } })
|
||||
})
|
||||
|
||||
it('extracts note width without leaking it into properties', () => {
|
||||
const content = '---\ntype: Note\n_width: wide\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', noteWidth: 'wide' })
|
||||
})
|
||||
|
||||
it('preserves _favorite_index as a number (not null)', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
import { normalizeNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
type FrontmatterCommand = 'update_frontmatter' | 'delete_frontmatter_property'
|
||||
type FrontmatterKey = string
|
||||
@@ -23,7 +24,8 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
_archived: { archived: false }, archived: { archived: false },
|
||||
_order: { order: null },
|
||||
template: { template: null }, _sort: { sort: null }, visible: { visible: null },
|
||||
template: { template: null }, _sort: { sort: null }, view: { view: null },
|
||||
_width: { noteWidth: null }, visible: { visible: null },
|
||||
_organized: { organized: false },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
_list_properties_display: { listPropertiesDisplay: [] },
|
||||
@@ -110,6 +112,7 @@ function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record<Fr
|
||||
template: { template: str },
|
||||
_sort: { sort: str },
|
||||
view: { view: str },
|
||||
_width: { noteWidth: normalizeNoteWidthMode(value) },
|
||||
visible: { visible: visibleValue(value) },
|
||||
_organized: { organized: Boolean(value) },
|
||||
_favorite: { favorite: Boolean(value) },
|
||||
@@ -181,17 +184,48 @@ async function invokeFrontmatter(command: FrontmatterCommand, args: Record<strin
|
||||
return invoke<string>(command, args)
|
||||
}
|
||||
|
||||
function seedMockContent(path: VaultPath, content: MarkdownContent): void {
|
||||
updateMockContent(path, content)
|
||||
}
|
||||
|
||||
async function loadMockContent(path: VaultPath): Promise<MarkdownContent> {
|
||||
try {
|
||||
return await mockInvoke<MarkdownContent>('get_note_content', { path })
|
||||
} catch {
|
||||
return typeof window === 'undefined' ? '' : window.__mockContent?.[path] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function persistMockContent(path: VaultPath, content: MarkdownContent): Promise<void> {
|
||||
try {
|
||||
await mockInvoke('save_note_content', { path, content })
|
||||
} finally {
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
}
|
||||
}
|
||||
|
||||
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: VaultPath, key: FrontmatterKey): MarkdownContent {
|
||||
const content = deleteMockFrontmatterProperty(path, key)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
async function executeMockFrontmatterOp(
|
||||
op: FrontmatterOp,
|
||||
path: VaultPath,
|
||||
key: FrontmatterKey,
|
||||
value?: FrontmatterValue,
|
||||
): Promise<MarkdownContent> {
|
||||
seedMockContent(path, await loadMockContent(path))
|
||||
const content = op === 'update'
|
||||
? applyMockFrontmatterUpdate(path, key, value!)
|
||||
: applyMockFrontmatterDelete(path, key)
|
||||
await persistMockContent(path, content)
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -202,9 +236,13 @@ async function executeFrontmatterOp(
|
||||
value?: FrontmatterValue,
|
||||
): Promise<MarkdownContent> {
|
||||
if (op === 'update') {
|
||||
return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!)
|
||||
return isTauri()
|
||||
? invokeFrontmatter('update_frontmatter', { path, key, value })
|
||||
: executeMockFrontmatterOp(op, path, key, value)
|
||||
}
|
||||
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
|
||||
return isTauri()
|
||||
? invokeFrontmatter('delete_frontmatter_property', { path, key })
|
||||
: executeMockFrontmatterOp(op, path, key)
|
||||
}
|
||||
|
||||
export interface FrontmatterOpOptions {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import { useMenuEvents } from './useMenuEvents'
|
||||
import type { NoteLayout, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
|
||||
import { requestAddRemote } from '../utils/addRemoteEvents'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
@@ -47,8 +47,10 @@ interface AppCommandsConfig {
|
||||
onMoveSelectedViewDown?: () => void
|
||||
canMoveSelectedViewUp?: boolean
|
||||
canMoveSelectedViewDown?: boolean
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
activeNoteModified: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -167,8 +169,10 @@ type CommandRegistryCoreActions = Pick<
|
||||
| 'onMoveSelectedViewDown'
|
||||
| 'canMoveSelectedViewUp'
|
||||
| 'canMoveSelectedViewDown'
|
||||
| 'noteLayout'
|
||||
| 'onToggleNoteLayout'
|
||||
| 'noteWidth'
|
||||
| 'defaultNoteWidth'
|
||||
| 'onSetNoteWidth'
|
||||
| 'onSetDefaultNoteWidth'
|
||||
| 'onToggleAIChat'
|
||||
>
|
||||
type CommandRegistryVaultActions = Pick<
|
||||
@@ -437,8 +441,10 @@ function createCommandRegistryCoreConfig(
|
||||
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
|
||||
onFindInNote: config.onFindInNote,
|
||||
onReplaceInNote: config.onReplaceInNote,
|
||||
noteLayout: config.noteLayout,
|
||||
onToggleNoteLayout: config.onToggleNoteLayout,
|
||||
noteWidth: config.noteWidth,
|
||||
defaultNoteWidth: config.defaultNoteWidth,
|
||||
onSetNoteWidth: config.onSetNoteWidth,
|
||||
onSetDefaultNoteWidth: config.onSetDefaultNoteWidth,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onToggleInspector: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
noteLayout: 'centered',
|
||||
onToggleNoteLayout: vi.fn(),
|
||||
noteWidth: 'normal',
|
||||
defaultNoteWidth: 'normal',
|
||||
onSetNoteWidth: vi.fn(),
|
||||
onSetDefaultNoteWidth: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
activeNoteModified: false,
|
||||
@@ -343,20 +345,20 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes a command palette action for the note layout preference', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
const config = makeConfig({ noteLayout: 'centered', onToggleNoteLayout })
|
||||
it('exposes command palette actions for note width modes', () => {
|
||||
const onSetNoteWidth = vi.fn()
|
||||
const config = makeConfig({ noteWidth: 'normal', onSetNoteWidth })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'toggle-note-layout')
|
||||
const cmd = findCommand(result.current, 'set-note-width-wide')
|
||||
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('Use Left-Aligned Note Layout')
|
||||
expect(cmd!.label).toBe('Use Wide Note Width')
|
||||
expect(cmd!.keywords).toContain('wide')
|
||||
|
||||
cmd!.execute()
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
expect(onSetNoteWidth).toHaveBeenCalledWith('wide')
|
||||
})
|
||||
|
||||
it('exposes command palette actions for moving the selected saved view', () => {
|
||||
@@ -406,11 +408,28 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('updates note layout command copy when left alignment is active', () => {
|
||||
const config = makeConfig({ noteLayout: 'left' })
|
||||
it('disables the command for the active note width mode', () => {
|
||||
const config = makeConfig({ noteWidth: 'wide' })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
expect(findCommand(result.current, 'toggle-note-layout')?.label).toBe('Use Centered Note Layout')
|
||||
expect(findCommand(result.current, 'set-note-width-wide')?.enabled).toBe(false)
|
||||
expect(findCommand(result.current, 'set-note-width-normal')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes command palette actions for the default note width', () => {
|
||||
const onSetDefaultNoteWidth = vi.fn()
|
||||
const config = makeConfig({ defaultNoteWidth: 'normal', onSetDefaultNoteWidth })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-default-note-width-wide')
|
||||
|
||||
expect(cmd).toMatchObject({
|
||||
label: 'Use Wide Note Width by Default',
|
||||
group: 'View',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
cmd!.execute()
|
||||
expect(onSetDefaultNoteWidth).toHaveBeenCalledWith('wide')
|
||||
})
|
||||
|
||||
it('exposes command palette actions for light and dark mode', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
|
||||
import type { ThemeMode } from '../lib/themeMode'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import type { NoteLayout, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { buildNavigationCommands } from './commands/navigationCommands'
|
||||
@@ -92,8 +92,10 @@ interface CommandRegistryConfig {
|
||||
canMoveSelectedViewDown?: boolean
|
||||
onFindInNote?: () => void
|
||||
onReplaceInNote?: () => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onToggleAIChat?: () => void
|
||||
activeNoteModified: boolean
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -125,7 +127,8 @@ 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, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote,
|
||||
noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
@@ -220,12 +223,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
|
||||
const viewCommands = useMemo(() => buildViewCommands({
|
||||
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
|
||||
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
}), [
|
||||
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
|
||||
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
|
||||
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
|
||||
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
|
||||
165
src/hooks/useNoteWidthMode.ts
Normal file
165
src/hooks/useNoteWidthMode.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { NoteWidthMode, Settings, VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { canPersistNoteWidthMode, resolveNoteWidthMode, toggleNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
type VaultPath = VaultEntry['path']
|
||||
type MarkdownContent = string
|
||||
type ToastMessage = string | null
|
||||
|
||||
interface EditorTab {
|
||||
entry: VaultEntry
|
||||
content: MarkdownContent
|
||||
}
|
||||
|
||||
interface ReadWidthContentRequest {
|
||||
path: VaultPath
|
||||
fallbackContent: MarkdownContent
|
||||
}
|
||||
|
||||
interface ActiveTabRequest {
|
||||
tabs: EditorTab[]
|
||||
activeTabPath: VaultPath | null
|
||||
}
|
||||
|
||||
interface CurrentWidthRequest {
|
||||
activeTab: EditorTab | null
|
||||
defaultNoteWidth: NoteWidthMode
|
||||
transientNoteWidths: Record<VaultPath, NoteWidthMode>
|
||||
}
|
||||
|
||||
interface UseNoteWidthModeOptions {
|
||||
tabs: EditorTab[]
|
||||
activeTabPath: VaultPath | null
|
||||
settings: Settings
|
||||
saveSettings: (settings: Settings) => Promise<void>
|
||||
updateFrontmatter: (
|
||||
path: VaultPath,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<void>
|
||||
setToastMessage: (message: ToastMessage) => void
|
||||
}
|
||||
|
||||
interface PersistWidthRequest {
|
||||
activeTab: EditorTab | null
|
||||
mode: NoteWidthMode
|
||||
updateFrontmatter: UseNoteWidthModeOptions['updateFrontmatter']
|
||||
rememberTransientWidth: (path: VaultPath, mode: NoteWidthMode) => void
|
||||
setToastMessage: (message: ToastMessage) => void
|
||||
}
|
||||
|
||||
function resolveActiveTab({ tabs, activeTabPath }: ActiveTabRequest): EditorTab | null {
|
||||
return tabs.find((tab) => tab.entry.path === activeTabPath) ?? null
|
||||
}
|
||||
|
||||
function resolveCurrentWidth({
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
transientNoteWidths,
|
||||
}: CurrentWidthRequest): NoteWidthMode {
|
||||
const path = activeTab?.entry.path
|
||||
if (!path) return defaultNoteWidth
|
||||
return resolveNoteWidthMode(transientNoteWidths[path] ?? activeTab.entry.noteWidth, defaultNoteWidth)
|
||||
}
|
||||
|
||||
async function readNoteContentForWidthPersistence({
|
||||
path,
|
||||
fallbackContent,
|
||||
}: ReadWidthContentRequest): Promise<MarkdownContent> {
|
||||
try {
|
||||
return isTauri()
|
||||
? await invoke<MarkdownContent>('get_note_content', { path })
|
||||
: await mockInvoke<MarkdownContent>('get_note_content', { path })
|
||||
} catch {
|
||||
return fallbackContent
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOrRememberNoteWidth({
|
||||
activeTab,
|
||||
mode,
|
||||
updateFrontmatter,
|
||||
rememberTransientWidth,
|
||||
setToastMessage,
|
||||
}: PersistWidthRequest): Promise<void> {
|
||||
const path = activeTab?.entry.path
|
||||
if (!path) return
|
||||
|
||||
const persistedContent = await readNoteContentForWidthPersistence({
|
||||
path,
|
||||
fallbackContent: activeTab.content,
|
||||
})
|
||||
if (!canPersistNoteWidthMode(persistedContent)) {
|
||||
rememberTransientWidth(path, mode)
|
||||
trackEvent('note_width_mode_changed', { mode, scope: 'transient' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateFrontmatter(path, '_width', mode, { silent: true })
|
||||
rememberTransientWidth(path, mode)
|
||||
trackEvent('note_width_mode_changed', { mode, scope: 'note' })
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to update note width: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteWidthMode({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
settings,
|
||||
saveSettings,
|
||||
updateFrontmatter,
|
||||
setToastMessage,
|
||||
}: UseNoteWidthModeOptions) {
|
||||
const [transientNoteWidths, setTransientNoteWidths] = useState<Record<VaultPath, NoteWidthMode>>({})
|
||||
const activeTab = useMemo(
|
||||
() => resolveActiveTab({ tabs, activeTabPath }),
|
||||
[activeTabPath, tabs],
|
||||
)
|
||||
const defaultNoteWidth = useMemo(
|
||||
() => resolveNoteWidthMode(settings.note_width_mode, null),
|
||||
[settings.note_width_mode],
|
||||
)
|
||||
const noteWidth = useMemo(() => {
|
||||
return resolveCurrentWidth({ activeTab, defaultNoteWidth, transientNoteWidths })
|
||||
}, [activeTab, defaultNoteWidth, transientNoteWidths])
|
||||
|
||||
const rememberTransientWidth = useCallback((path: VaultPath, mode: NoteWidthMode) => {
|
||||
setTransientNoteWidths((previous) => (
|
||||
previous[path] === mode ? previous : { ...previous, [path]: mode }
|
||||
))
|
||||
}, [])
|
||||
|
||||
const setNoteWidth = useCallback((mode: NoteWidthMode) => persistOrRememberNoteWidth({
|
||||
activeTab,
|
||||
mode,
|
||||
updateFrontmatter,
|
||||
rememberTransientWidth,
|
||||
setToastMessage,
|
||||
}), [activeTab, rememberTransientWidth, setToastMessage, updateFrontmatter])
|
||||
|
||||
const toggleNoteWidth = useCallback(() => {
|
||||
void setNoteWidth(toggleNoteWidthMode(noteWidth))
|
||||
}, [noteWidth, setNoteWidth])
|
||||
|
||||
const setDefaultNoteWidth = useCallback(async (mode: NoteWidthMode) => {
|
||||
await saveSettings({ ...settings, note_width_mode: mode })
|
||||
trackEvent('note_width_default_changed', { mode })
|
||||
}, [saveSettings, settings])
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
noteWidth,
|
||||
setDefaultNoteWidth,
|
||||
setNoteWidth,
|
||||
toggleNoteWidth,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const defaultSettings: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -38,6 +39,7 @@ const savedSettings: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -88,6 +90,7 @@ function changedSettings(): Settings {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: 'zh-CN',
|
||||
note_width_mode: 'wide',
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: false,
|
||||
}
|
||||
@@ -150,6 +153,16 @@ describe('useSettings', () => {
|
||||
expect(settings.ui_language).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes unsupported note width modes on load', async () => {
|
||||
mockSettingsStore = {
|
||||
...savedSettings,
|
||||
note_width_mode: 'expanded' as Settings['note_width_mode'],
|
||||
}
|
||||
|
||||
const settings = await renderLoadedSettings()
|
||||
expect(settings.note_width_mode).toBeNull()
|
||||
})
|
||||
|
||||
it('saves settings via backend', async () => {
|
||||
const { result } = renderHook(() => useSettings())
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { serializeUiLanguagePreference } from '../lib/i18n'
|
||||
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
|
||||
import { normalizeThemeMode } from '../lib/themeMode'
|
||||
import type { Settings } from '../types'
|
||||
import { normalizeNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
async function invokeNativeIfAvailable<T>(command: string, tauriArgs: Record<string, unknown>): Promise<T | undefined> {
|
||||
try {
|
||||
@@ -43,6 +44,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -55,6 +57,7 @@ function normalizeSettings(settings: Settings): Settings {
|
||||
),
|
||||
theme_mode: normalizeThemeMode(settings.theme_mode),
|
||||
ui_language: serializeUiLanguagePreference(settings.ui_language),
|
||||
note_width_mode: normalizeNoteWidthMode(settings.note_width_mode),
|
||||
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
|
||||
hide_gitignored_files: settings.hide_gitignored_files ?? null,
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Eigenschaften-Panel ein-/ausblenden",
|
||||
"command.view.toggleDiff": "Diff-Modus umschalten",
|
||||
"command.view.toggleRaw": "Raw-Editor umschalten",
|
||||
"command.view.noteWidthNormal": "Normale Notizenbreite verwenden",
|
||||
"command.view.noteWidthWide": "Breite Notizenbreite verwenden",
|
||||
"command.view.defaultNoteWidthNormal": "Standardmäßig normale Notenbreite verwenden",
|
||||
"command.view.defaultNoteWidthWide": "Standardmäßig breite Notenbreite verwenden",
|
||||
"command.view.leftLayout": "Linksbündiges Notizenlayout verwenden",
|
||||
"command.view.centerLayout": "Zentriertes Notizen-Layout verwenden",
|
||||
"command.view.toggleAiPanel": "KI-Panel umschalten",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Alle",
|
||||
"editor.toolbar.rawReturn": "Zum Editor zurückkehren",
|
||||
"editor.toolbar.rawOpen": "Raw-Editor öffnen",
|
||||
"editor.toolbar.noteWidthNormal": "Zur normalen Notenbreite wechseln",
|
||||
"editor.toolbar.noteWidthWide": "Zur breiten Notenbreite wechseln",
|
||||
"editor.toolbar.centerLayout": "Zum zentrierten Notizen-Layout wechseln",
|
||||
"editor.toolbar.leftLayout": "Zum linksbündigen Notizen-Layout wechseln",
|
||||
"editor.toolbar.removeFavorite": "Aus Favoriten entfernen",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Toggle Properties Panel",
|
||||
"command.view.toggleDiff": "Toggle Diff Mode",
|
||||
"command.view.toggleRaw": "Toggle Raw Editor",
|
||||
"command.view.noteWidthNormal": "Use Normal Note Width",
|
||||
"command.view.noteWidthWide": "Use Wide Note Width",
|
||||
"command.view.defaultNoteWidthNormal": "Use Normal Note Width by Default",
|
||||
"command.view.defaultNoteWidthWide": "Use Wide Note Width by Default",
|
||||
"command.view.leftLayout": "Use Left-Aligned Note Layout",
|
||||
"command.view.centerLayout": "Use Centered Note Layout",
|
||||
"command.view.toggleAiPanel": "Toggle AI Panel",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "All",
|
||||
"editor.toolbar.rawReturn": "Return to the editor",
|
||||
"editor.toolbar.rawOpen": "Open the raw editor",
|
||||
"editor.toolbar.noteWidthNormal": "Switch to normal note width",
|
||||
"editor.toolbar.noteWidthWide": "Switch to wide note width",
|
||||
"editor.toolbar.centerLayout": "Switch to centered note layout",
|
||||
"editor.toolbar.leftLayout": "Switch to left-aligned note layout",
|
||||
"editor.toolbar.removeFavorite": "Remove from favorites",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
|
||||
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
|
||||
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
|
||||
"command.view.noteWidthNormal": "Usar ancho de nota normal",
|
||||
"command.view.noteWidthWide": "Usar ancho de nota amplio",
|
||||
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
|
||||
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
|
||||
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
|
||||
"command.view.centerLayout": "Usar diseño de nota centrado",
|
||||
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todo",
|
||||
"editor.toolbar.rawReturn": "Volver al editor",
|
||||
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
|
||||
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
|
||||
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
|
||||
"editor.toolbar.removeFavorite": "Eliminar de favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
|
||||
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
|
||||
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
|
||||
"command.view.noteWidthNormal": "Usar ancho de nota normal",
|
||||
"command.view.noteWidthWide": "Usar ancho de nota amplio",
|
||||
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
|
||||
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
|
||||
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
|
||||
"command.view.centerLayout": "Usar diseño de nota centrado",
|
||||
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todo",
|
||||
"editor.toolbar.rawReturn": "Volver al editor",
|
||||
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
|
||||
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
|
||||
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
|
||||
"editor.toolbar.removeFavorite": "Eliminar de favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activer/désactiver le panneau des propriétés",
|
||||
"command.view.toggleDiff": "Activer/désactiver le mode Diff",
|
||||
"command.view.toggleRaw": "Activer/désactiver l'éditeur Raw",
|
||||
"command.view.noteWidthNormal": "Utiliser la largeur de note normale",
|
||||
"command.view.noteWidthWide": "Utiliser la largeur de note large",
|
||||
"command.view.defaultNoteWidthNormal": "Utiliser la largeur de note normale par défaut",
|
||||
"command.view.defaultNoteWidthWide": "Utiliser la largeur de note large par défaut",
|
||||
"command.view.leftLayout": "Utiliser la mise en page des notes alignée à gauche",
|
||||
"command.view.centerLayout": "Utiliser la mise en page centrée pour les notes",
|
||||
"command.view.toggleAiPanel": "Activer/désactiver le panneau d'IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Tout",
|
||||
"editor.toolbar.rawReturn": "Retourner à l'éditeur",
|
||||
"editor.toolbar.rawOpen": "Ouvrir l'éditeur Raw",
|
||||
"editor.toolbar.noteWidthNormal": "Passer à la largeur de note normale",
|
||||
"editor.toolbar.noteWidthWide": "Passer à la largeur de note large",
|
||||
"editor.toolbar.centerLayout": "Passer à la mise en page centrée de la note",
|
||||
"editor.toolbar.leftLayout": "Passer à mise en page de la note en alignement à gauche",
|
||||
"editor.toolbar.removeFavorite": "Supprimer des favoris",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Attiva/disattiva pannello Proprietà",
|
||||
"command.view.toggleDiff": "Attiva/disattiva modalità Diff",
|
||||
"command.view.toggleRaw": "Attiva/disattiva editor RAW",
|
||||
"command.view.noteWidthNormal": "Usa larghezza normale per le note",
|
||||
"command.view.noteWidthWide": "Usa larghezza nota ampia",
|
||||
"command.view.defaultNoteWidthNormal": "Usa larghezza normale delle note per impostazione predefinita",
|
||||
"command.view.defaultNoteWidthWide": "Usa larghezza nota ampia per impostazione predefinita",
|
||||
"command.view.leftLayout": "Usa layout delle note allineato a sinistra",
|
||||
"command.view.centerLayout": "Usa layout delle note centrato",
|
||||
"command.view.toggleAiPanel": "Attiva/disattiva pannello IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Tutti",
|
||||
"editor.toolbar.rawReturn": "Torna all'editor",
|
||||
"editor.toolbar.rawOpen": "Apri l'editor raw",
|
||||
"editor.toolbar.noteWidthNormal": "Passa alla larghezza normale delle note",
|
||||
"editor.toolbar.noteWidthWide": "Passa alla larghezza delle note ampia",
|
||||
"editor.toolbar.centerLayout": "Passa al layout della nota centrato",
|
||||
"editor.toolbar.leftLayout": "Passa al layout della nota allineato a sinistra",
|
||||
"editor.toolbar.removeFavorite": "Rimuovi dai preferiti",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "プロパティパネルの切り替え",
|
||||
"command.view.toggleDiff": "差分モードの切り替え",
|
||||
"command.view.toggleRaw": "Rawエディターの切り替え",
|
||||
"command.view.noteWidthNormal": "通常のノート幅を使用",
|
||||
"command.view.noteWidthWide": "広いノート幅を使用",
|
||||
"command.view.defaultNoteWidthNormal": "デフォルトで通常のノート幅を使用",
|
||||
"command.view.defaultNoteWidthWide": "デフォルトで広いノート幅を使用",
|
||||
"command.view.leftLayout": "左揃えのノートレイアウトを使用",
|
||||
"command.view.centerLayout": "メモの中央揃えレイアウトを使用",
|
||||
"command.view.toggleAiPanel": "AIパネルの切り替え",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "すべて",
|
||||
"editor.toolbar.rawReturn": "エディターに戻る",
|
||||
"editor.toolbar.rawOpen": "RAWエディターを開く",
|
||||
"editor.toolbar.noteWidthNormal": "通常のノート幅に切り替える",
|
||||
"editor.toolbar.noteWidthWide": "ワイドノート幅に切り替える",
|
||||
"editor.toolbar.centerLayout": "ノートの中央揃えレイアウトに切り替える",
|
||||
"editor.toolbar.leftLayout": "左揃えのノートレイアウトに切り替える",
|
||||
"editor.toolbar.removeFavorite": "お気に入りから削除",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "속성 패널 켜기/끄기",
|
||||
"command.view.toggleDiff": "차이 모드 전환",
|
||||
"command.view.toggleRaw": "Raw Editor 켜기/끄기",
|
||||
"command.view.noteWidthNormal": "일반 노트 너비 사용",
|
||||
"command.view.noteWidthWide": "넓은 노트 너비 사용",
|
||||
"command.view.defaultNoteWidthNormal": "기본적으로 일반 노트 너비 사용",
|
||||
"command.view.defaultNoteWidthWide": "기본적으로 넓은 노트 너비 사용",
|
||||
"command.view.leftLayout": "왼쪽 정렬 노트 레이아웃 사용",
|
||||
"command.view.centerLayout": "중앙 정렬 노트 레이아웃 사용",
|
||||
"command.view.toggleAiPanel": "AI 패널 켜기/끄기",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "모두",
|
||||
"editor.toolbar.rawReturn": "편집기로 돌아가기",
|
||||
"editor.toolbar.rawOpen": "Raw 편집기 열기",
|
||||
"editor.toolbar.noteWidthNormal": "일반 노트 너비로 전환",
|
||||
"editor.toolbar.noteWidthWide": "넓은 노트 너비로 전환",
|
||||
"editor.toolbar.centerLayout": "노트 중앙 정렬 레이아웃으로 전환",
|
||||
"editor.toolbar.leftLayout": "왼쪽 정렬 노트 레이아웃으로 전환",
|
||||
"editor.toolbar.removeFavorite": "즐겨찾기에서 삭제",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
|
||||
"command.view.toggleDiff": "Ativar/desativar modo de comparação",
|
||||
"command.view.toggleRaw": "Ativar/desativar editor Raw",
|
||||
"command.view.noteWidthNormal": "Usar largura normal de nota",
|
||||
"command.view.noteWidthWide": "Usar largura de nota ampla",
|
||||
"command.view.defaultNoteWidthNormal": "Usar largura de nota normal por padrão",
|
||||
"command.view.defaultNoteWidthWide": "Usar largura de nota ampla por padrão",
|
||||
"command.view.leftLayout": "Usar layout de nota alinhado à esquerda",
|
||||
"command.view.centerLayout": "Usar layout de nota centralizado",
|
||||
"command.view.toggleAiPanel": "Alternar painel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todas",
|
||||
"editor.toolbar.rawReturn": "Voltar ao editor",
|
||||
"editor.toolbar.rawOpen": "Abrir o editor Raw",
|
||||
"editor.toolbar.noteWidthNormal": "Alternar para largura de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Mudar para largura de nota ampla",
|
||||
"editor.toolbar.centerLayout": "Alternar para o layout de nota centralizado",
|
||||
"editor.toolbar.leftLayout": "Alternar para o layout de nota alinhado à esquerda",
|
||||
"editor.toolbar.removeFavorite": "Remover dos favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
|
||||
"command.view.toggleDiff": "Ativar/desativar modo de diferenças",
|
||||
"command.view.toggleRaw": "Ativar/desativar editor RAW",
|
||||
"command.view.noteWidthNormal": "Utilizar largura de nota normal",
|
||||
"command.view.noteWidthWide": "Utilizar largura de nota ampla",
|
||||
"command.view.defaultNoteWidthNormal": "Utilizar largura de nota normal por predefinição",
|
||||
"command.view.defaultNoteWidthWide": "Utilizar largura de nota ampla por predefinição",
|
||||
"command.view.leftLayout": "Utilizar layout de nota alinhado à esquerda",
|
||||
"command.view.centerLayout": "Utilizar layout de nota centrado",
|
||||
"command.view.toggleAiPanel": "Ativar/desativar painel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todas",
|
||||
"editor.toolbar.rawReturn": "Voltar ao editor",
|
||||
"editor.toolbar.rawOpen": "Abrir o editor RAW",
|
||||
"editor.toolbar.noteWidthNormal": "Mudar para largura de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Mudar para largura de nota larga",
|
||||
"editor.toolbar.centerLayout": "Mudar para o layout de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Mudar para o layout de nota alinhado à esquerda",
|
||||
"editor.toolbar.removeFavorite": "Remover dos favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Переключить панель свойств",
|
||||
"command.view.toggleDiff": "Переключить режим сравнения",
|
||||
"command.view.toggleRaw": "Переключить редактор Raw",
|
||||
"command.view.noteWidthNormal": "Использовать обычную ширину заметки",
|
||||
"command.view.noteWidthWide": "Использовать широкую ширину заметки",
|
||||
"command.view.defaultNoteWidthNormal": "По умолчанию использовать нормальную ширину заметки",
|
||||
"command.view.defaultNoteWidthWide": "По умолчанию использовать широкую ширину нот",
|
||||
"command.view.leftLayout": "Использовать левостороннее расположение заметок",
|
||||
"command.view.centerLayout": "Использовать центрированный макет заметки",
|
||||
"command.view.toggleAiPanel": "Переключить панель ИИ",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Все",
|
||||
"editor.toolbar.rawReturn": "Вернуться в редактор",
|
||||
"editor.toolbar.rawOpen": "Открыть RAW-редактор",
|
||||
"editor.toolbar.noteWidthNormal": "Переключиться на обычную ширину нот",
|
||||
"editor.toolbar.noteWidthWide": "Переключиться на широкую ширину нот",
|
||||
"editor.toolbar.centerLayout": "Переключиться на центрированный макет заметки",
|
||||
"editor.toolbar.leftLayout": "Переключиться на макет заметки с выравниванием по левому краю",
|
||||
"editor.toolbar.removeFavorite": "Удалить из избранного",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "切换属性面板",
|
||||
"command.view.toggleDiff": "切换差异模式",
|
||||
"command.view.toggleRaw": "切换源码编辑器",
|
||||
"command.view.noteWidthNormal": "使用普通笔记宽度",
|
||||
"command.view.noteWidthWide": "使用宽笔记宽度",
|
||||
"command.view.defaultNoteWidthNormal": "默认使用普通笔记宽度",
|
||||
"command.view.defaultNoteWidthWide": "默认使用宽笔记宽度",
|
||||
"command.view.leftLayout": "使用左对齐笔记布局",
|
||||
"command.view.centerLayout": "使用居中笔记布局",
|
||||
"command.view.toggleAiPanel": "切换 AI 面板",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "全部",
|
||||
"editor.toolbar.rawReturn": "返回编辑器",
|
||||
"editor.toolbar.rawOpen": "打开源码编辑器",
|
||||
"editor.toolbar.noteWidthNormal": "切换为普通笔记宽度",
|
||||
"editor.toolbar.noteWidthWide": "切换为宽笔记宽度",
|
||||
"editor.toolbar.centerLayout": "切换到居中笔记布局",
|
||||
"editor.toolbar.leftLayout": "切换到左对齐笔记布局",
|
||||
"editor.toolbar.removeFavorite": "从收藏中移除",
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface VaultEntry {
|
||||
sort: string | null
|
||||
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
|
||||
view: string | null
|
||||
/** Rich-editor note width mode from `_width` frontmatter. null means use the default. */
|
||||
noteWidth?: NoteWidthMode | null
|
||||
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
|
||||
visible: boolean | null
|
||||
/** Whether this note has been explicitly organized (removed from Inbox). */
|
||||
@@ -94,6 +96,7 @@ export interface Settings {
|
||||
release_channel: string | null
|
||||
theme_mode?: ThemeMode | null
|
||||
ui_language?: AppLocale | null
|
||||
note_width_mode?: NoteWidthMode | null
|
||||
initial_h1_auto_rename_enabled?: boolean | null
|
||||
default_ai_agent?: AiAgentId | null
|
||||
hide_gitignored_files?: boolean | null
|
||||
@@ -156,6 +159,8 @@ export interface AllNotesConfig {
|
||||
/** 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
|
||||
|
||||
33
src/utils/noteWidth.test.ts
Normal file
33
src/utils/noteWidth.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canPersistNoteWidthMode,
|
||||
normalizeNoteWidthMode,
|
||||
resolveNoteWidthMode,
|
||||
toggleNoteWidthMode,
|
||||
} from './noteWidth'
|
||||
|
||||
describe('noteWidth', () => {
|
||||
it('normalizes supported width modes', () => {
|
||||
expect(normalizeNoteWidthMode(' wide ')).toBe('wide')
|
||||
expect(normalizeNoteWidthMode('NORMAL')).toBe('normal')
|
||||
expect(normalizeNoteWidthMode('expanded')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves note override before the default', () => {
|
||||
expect(resolveNoteWidthMode('wide', 'normal')).toBe('wide')
|
||||
expect(resolveNoteWidthMode(null, 'wide')).toBe('wide')
|
||||
expect(resolveNoteWidthMode(null, 'expanded')).toBe('normal')
|
||||
})
|
||||
|
||||
it('toggles between normal and wide', () => {
|
||||
expect(toggleNoteWidthMode('normal')).toBe('wide')
|
||||
expect(toggleNoteWidthMode('wide')).toBe('normal')
|
||||
})
|
||||
|
||||
it('only persists width when frontmatter already exists', () => {
|
||||
expect(canPersistNoteWidthMode('---\ntype: Note\n---\n# Note')).toBe(true)
|
||||
expect(canPersistNoteWidthMode('---\n---\n# Note')).toBe(true)
|
||||
expect(canPersistNoteWidthMode('# Note')).toBe(false)
|
||||
expect(canPersistNoteWidthMode('---\nnot yaml\n---\n# Note')).toBe(false)
|
||||
})
|
||||
})
|
||||
26
src/utils/noteWidth.ts
Normal file
26
src/utils/noteWidth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { NoteWidthMode } from '../types'
|
||||
import { detectFrontmatterState } from './frontmatter'
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
export function canPersistNoteWidthMode(content: string | null): boolean {
|
||||
const frontmatterState = detectFrontmatterState(content)
|
||||
return frontmatterState === 'valid' || frontmatterState === 'empty'
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const SYSTEM_METADATA_ALIAS_GROUPS = {
|
||||
_order: ['_order', 'order'],
|
||||
_sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'],
|
||||
_sort: ['_sort', 'sort'],
|
||||
_width: ['_width', 'width'],
|
||||
} as const
|
||||
|
||||
const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS)
|
||||
|
||||
54
tests/smoke/note-width-modes.spec.ts
Normal file
54
tests/smoke/note-width-modes.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function alphaProjectPath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'project', 'alpha-project.md')
|
||||
}
|
||||
|
||||
function plainNotePath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'plain-width-note.md')
|
||||
}
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function executePaletteCommand(page: Page, label: string) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, label)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
fs.writeFileSync(plainNotePath(tempVaultDir), '# Plain Width Note\n\nNo frontmatter here.\n')
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('note width modes persist only when frontmatter already exists', async ({ page }) => {
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
|
||||
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?wide"?/)
|
||||
|
||||
await executePaletteCommand(page, 'Use Normal Note Width')
|
||||
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?normal"?/)
|
||||
|
||||
await openNote(page, 'Plain Width Note')
|
||||
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
|
||||
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
|
||||
expect(fs.readFileSync(plainNotePath(tempVaultDir), 'utf8')).toBe('# Plain Width Note\n\nNo frontmatter here.\n')
|
||||
})
|
||||
Reference in New Issue
Block a user