Compare commits

...

4 Commits

Author SHA1 Message Date
lucaronin
cb9ebaadad fix: keep manual selection after inbox organize 2026-04-29 19:53:33 +02:00
lucaronin
c295c9f2b5 feat: add note width modes 2026-04-29 19:26:24 +02:00
lucaronin
a4b11089c0 fix: debounce rich editor serialization 2026-04-29 18:19:35 +02:00
lucaronin
6f29542528 fix: render mermaid diagrams from notes 2026-04-29 17:14:28 +02:00
60 changed files with 1700 additions and 264 deletions

View File

@@ -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)
@@ -584,6 +587,8 @@ flowchart LR
style E fill:#d4edda,stroke:#28a745,color:#000
```
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
### Wikilink Navigation
Two navigation mechanisms:
@@ -600,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:
@@ -683,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`)
@@ -742,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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 {

View File

@@ -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).

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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);
}

View File

@@ -144,6 +144,7 @@ const mockCommandResults: Record<string, unknown> = {
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
save_note_content: null,
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
@@ -279,6 +280,7 @@ function resetMockCommandResults() {
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
save_note_content: null,
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
@@ -1117,6 +1119,63 @@ describe('App', () => {
})
})
it('keeps the manually selected note after organizing finishes later', async () => {
configureNeighborhoodVault()
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: true,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
let resolveOrganizeSave!: () => void
const organizeSave = new Promise<void>((resolve) => {
resolveOrganizeSave = resolve
})
mockCommandResults.save_note_content = vi.fn(() => organizeSave)
render(<App />)
const noteListContainer = await screen.findByTestId('note-list-container')
await waitFor(() => {
expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox')
})
await act(async () => {
fireEvent.click(within(noteListContainer).getByText('Alpha'))
await Promise.resolve()
})
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument()
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' }))
await Promise.resolve()
})
await act(async () => {
fireEvent.click(within(noteListContainer).getByText('Gamma'))
await Promise.resolve()
})
await waitFor(() => {
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
})
await act(async () => {
resolveOrganizeSave()
await organizeSave
await Promise.resolve()
await Promise.resolve()
})
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
})
it('renders status bar', async () => {
render(<App />)
// StatusBar should be present

View File

@@ -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'
@@ -555,8 +555,10 @@ function App() {
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
})
const flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null)
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
const flushEditorStateBeforeAction = async (path: string) => {
flushPendingEditorContentRef.current?.(path)
flushPendingRawContentRef.current?.(path)
await appSave.flushBeforeAction(path)
}
@@ -980,10 +982,14 @@ function App() {
}, [handleAppContentChange, recordAutoGitActivity])
const handleTrackedSave = useCallback(async (...args: Parameters<typeof handleAppSave>) => {
if (notes.activeTabPath) {
flushPendingEditorContentRef.current?.(notes.activeTabPath)
flushPendingRawContentRef.current?.(notes.activeTabPath)
}
const result = await handleAppSave(...args)
recordAutoGitActivity()
return result
}, [handleAppSave, recordAutoGitActivity])
}, [handleAppSave, notes.activeTabPath, recordAutoGitActivity])
const seedAutoGitSavedChange = useCallback(async () => {
if (isTauri()) {
@@ -1029,7 +1035,7 @@ function App() {
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onBeforeAction: appSave.flushBeforeAction,
onBeforeAction: flushEditorStateBeforeAction,
})
const deleteActions = useDeleteActions({
@@ -1158,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()
@@ -1386,7 +1391,7 @@ function App() {
const organized = await entryActions.handleToggleOrganized(path)
if (organized && nextVisibleInboxEntry) {
if (organized && nextVisibleInboxEntry && notes.activeTabPathRef.current === path) {
void notes.handleSelectNote(nextVisibleInboxEntry)
}
}, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, vault.entries])
@@ -1409,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,
@@ -1437,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,
@@ -1503,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[]>(() => {
@@ -1657,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}
@@ -1673,6 +1694,7 @@ function App() {
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingEditorContentRef={flushPendingEditorContentRef}
flushPendingRawContentRef={flushPendingRawContentRef}
locale={appLocale}
/>

View File

@@ -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()
})
})

View File

@@ -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} />

View File

@@ -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;
}
}

View File

@@ -43,6 +43,9 @@ const mockEditor = vi.hoisted(() => ({
const blockNoteCreation = vi.hoisted(() => ({
options: [] as unknown[],
}))
const blockNoteViewState = vi.hoisted(() => ({
onChange: null as (() => void) | null,
}))
// Mock BlockNote components
vi.mock('@blocknote/core', () => ({
@@ -81,16 +84,23 @@ vi.mock('@blocknote/react', () => ({
ComponentsContext: {
Provider: ({ children }: PropsWithChildren) => <>{children}</>,
},
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div
contentEditable={editable !== false}
data-testid="blocknote-editable"
suppressContentEditableWarning
/>
{children}
</div>
),
BlockNoteViewRaw: ({
children,
editable,
onChange,
}: PropsWithChildren<{ editable?: boolean; onChange?: () => void }>) => {
blockNoteViewState.onChange = onChange ?? null
return (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div
contentEditable={editable !== false}
data-testid="blocknote-editable"
suppressContentEditableWarning
/>
{children}
</div>
)
},
FormattingToolbarController: () => null,
LinkToolbarController: () => null,
EditLinkButton: () => null,
@@ -224,6 +234,7 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
})
it('shows empty state when no tabs are open', () => {
@@ -387,6 +398,46 @@ describe('Editor', () => {
})
})
it('registers a rich-editor flush hook for pending BlockNote changes', async () => {
const onContentChange = vi.fn()
const flushPendingEditorContentRef = { current: null as ((path: string) => void) | null }
const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
mockEditor.replaceBlocks.mockClear()
try {
renderEditor({
tabs: [mockTab],
activeTabPath: mockEntry.path,
onContentChange,
flushPendingEditorContentRef,
})
await vi.waitFor(() => {
expect(blockNoteViewState.onChange).toEqual(expect.any(Function))
expect(flushPendingEditorContentRef.current).toEqual(expect.any(Function))
})
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nEdited rich body.\n')
act(() => {
blockNoteViewState.onChange?.()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
flushPendingEditorContentRef.current?.(mockEntry.path)
})
expect(onContentChange).toHaveBeenCalledWith(
mockEntry.path,
expect.stringContaining('Edited rich body.'),
)
} finally {
mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer)
}
})
it('disables native text assistance on the rich editor editable surface', () => {
renderEditor({
tabs: [mockTab],

View File

@@ -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'
@@ -25,6 +25,7 @@ import {
resolvePendingRawExitContent,
resolveRawModeContent,
} from './editorRawModeSync'
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
@@ -82,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
@@ -104,6 +105,8 @@ interface EditorProps {
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes pending rich-editor changes into app state before external actions. */
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
locale?: AppLocale
@@ -219,7 +222,7 @@ function useEditorSetup({
}))
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEditorFocus(editor, editorMountedRef)
@@ -244,45 +247,11 @@ function useEditorSetup({
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
}
}
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useEffect(() => {
if (!flushPendingRawContentRef) return
flushPendingRawContentRef.current = flushPendingRawContent
return () => {
if (flushPendingRawContentRef.current === flushPendingRawContent) {
flushPendingRawContentRef.current = null
}
}
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function useEditorFindCommand({
activeTab,
findInNoteRef,
@@ -355,8 +324,8 @@ function EditorLayout({
findRequest,
rawLatestContentRef,
onRenameFilename,
noteLayout,
onToggleNoteLayout,
noteWidth,
onToggleNoteWidth,
isConflicted,
onKeepMine,
onKeepTheirs,
@@ -417,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
@@ -494,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}
@@ -555,10 +524,10 @@ 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,
flushPendingRawContentRef, findInNoteRef,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef,
locale,
} = props
@@ -566,7 +535,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
isLoadingNewTab, activeStatus, showDiffToggle,
} = useEditorSetup({
tabs, activeTabPath, vaultPath, onContentChange,
@@ -584,8 +553,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
rawMode,
})
useRegisterRawContentFlush({
useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
rawLatestContentRef,
rawMode,
onContentChange,
@@ -628,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}

View File

@@ -4,6 +4,7 @@ import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
function createFixture() {
let beforeInputListener: ((event: InputEvent) => void) | null = null
const transaction = { insertText: vi.fn(() => transaction) }
const paragraphNode = { type: { name: 'paragraph', spec: {} } }
const view = {
dispatch: vi.fn(),
state: {
@@ -13,6 +14,10 @@ function createFixture() {
selection: {
from: 2,
to: 2,
$from: {
depth: 0,
node: vi.fn(() => paragraphNode),
},
},
tr: transaction,
},
@@ -119,6 +124,21 @@ describe('createArrowLigaturesExtension', () => {
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('does not replace arrows while typing inside code blocks', () => {
const fixture = createFixture()
fixture.mount()
fixture.view.state.doc.textBetween.mockReturnValue('-')
fixture.view.state.selection.$from.node.mockReturnValue({
type: { name: 'codeBlock', spec: { code: true } },
})
const event = fixture.fireInput()
expect(event.preventDefault).not.toHaveBeenCalled()
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('ignores composing input so IME text is not rewritten', () => {
const fixture = createFixture()
fixture.mount()

View File

@@ -3,62 +3,104 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
const PREFIX_CONTEXT_LENGTH = 2
interface CodeContextSelection {
$from?: {
depth: number
node: (depth?: number) => {
type?: {
name?: string
spec?: { code?: boolean }
}
}
}
}
function isInsertedCharacter(event: InputEvent): event is InputEvent & { data: string } {
return event.inputType === 'insertText' && typeof event.data === 'string'
}
function isCodeContext(selection: CodeContextSelection): boolean {
const position = selection.$from
if (!position) return false
for (let depth = position.depth; depth >= 0; depth--) {
const type = position.node(depth).type
if (type?.spec?.code || type?.name === 'codeBlock') return true
}
return false
}
function hasWritableCursor(selection: { from: number; to: number }): boolean {
return selection.from === selection.to
}
function isComposingInput({
event,
view,
}: {
event: InputEvent
view: { composing?: boolean }
}): boolean {
return event.isComposing || Boolean(view.composing)
}
export const createArrowLigaturesExtension = createExtension(({ editor }) => {
let literalAsciiCursor: number | null = null
const handleBeforeInput = (event: InputEvent) => {
if (!isInsertedCharacter(event)) {
return
}
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view) {
return
}
if (isComposingInput({ event, view })) {
return
}
const { from } = view.state.selection
if (!hasWritableCursor(view.state.selection)) {
return
}
if (isCodeContext(view.state.selection)) {
literalAsciiCursor = null
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
return {
key: 'arrowLigatures',
mount: ({ dom, signal }) => {
const handleBeforeInput = (event: InputEvent) => {
if (!isInsertedCharacter(event)) {
return
}
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view) {
return
}
if (event.isComposing || view.composing) {
return
}
const { from, to } = view.state.selection
if (from !== to) {
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
dom.addEventListener('beforeinput', handleBeforeInput as EventListener, {
capture: true,
signal,

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect } from 'react'
import type React from 'react'
type Tab = {
entry: { path: string }
content: string
}
export function useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
flushPendingEditorChange: () => boolean
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
useRegisterRichContentFlush({ activeTab, flushPendingEditorChange, flushPendingEditorContentRef })
useRegisterRawContentFlush({ activeTab, rawLatestContentRef, rawMode, onContentChange, flushPendingRawContentRef })
}
function useRegisterRichContentFlush({
activeTab,
flushPendingEditorChange,
flushPendingEditorContentRef,
}: {
activeTab: Tab | null
flushPendingEditorChange: () => boolean
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingEditorContent = useCallback((path: string) => {
if (!activeTab || activeTab.entry.path !== path) return
flushPendingEditorChange()
}, [activeTab, flushPendingEditorChange])
useRegisteredFlushRef(flushPendingEditorContentRef, flushPendingEditorContent)
}
function useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
rawMode,
onContentChange,
flushPendingRawContentRef,
}: {
activeTab: Tab | null
rawLatestContentRef: React.MutableRefObject<string | null>
rawMode: boolean
onContentChange?: (path: string, content: string) => void
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
}) {
const flushPendingRawContent = useCallback((path: string) => {
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
const latestContent = rawLatestContentRef.current
if (latestContent === null || latestContent === activeTab.content) return
onContentChange?.(path, latestContent)
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
useRegisteredFlushRef(flushPendingRawContentRef, flushPendingRawContent)
}
function useRegisteredFlushRef(
ref: React.MutableRefObject<((path: string) => void) | null> | undefined,
flush: (path: string) => void,
) {
useEffect(() => {
if (!ref) return
ref.current = flush
return () => {
if (ref.current === flush) ref.current = null
}
}, [flush, ref])
}

View File

@@ -0,0 +1,25 @@
import { BlockNoteEditor } from '@blocknote/core'
import { describe, expect, it } from 'vitest'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { schema } from './editorSchema'
describe('editor schema Mermaid parsing', () => {
it('parses fenced Mermaid Markdown as a rendered Mermaid block', async () => {
const editor = BlockNoteEditor.create({ schema })
const blocks = await editor.tryParseMarkdownToBlocks([
'```mermaid',
'graph TD',
'A --> B',
'```',
].join('\n'))
expect(blocks[0]).toMatchObject({
type: MERMAID_BLOCK_TYPE,
props: {
diagram: 'graph TD\nA --> B\n',
source: '```mermaid\ngraph TD\nA --> B\n```',
},
})
})
})

View File

@@ -5,7 +5,7 @@ import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/r
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown'
import type { VaultEntry } from '../types'
import { NoteTitleIcon } from './NoteTitleIcon'
import { MermaidDiagram } from './MermaidDiagram'
@@ -106,6 +106,32 @@ const MathBlock = createReactBlockSpec(
},
)
function readCodeElementLanguage(code: Element): string | null {
const language = code.getAttribute('data-language')
?? Array.from(code.classList)
.find(className => className.startsWith('language-'))
?.replace(/^language-/u, '')
if (!language) return null
return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null
}
function readMermaidPreElement(element: HTMLElement): { source: string; diagram: string } | undefined {
if (element.tagName !== 'PRE') return undefined
if (element.childElementCount !== 1 || element.firstElementChild?.tagName !== 'CODE') return undefined
const code = element.firstElementChild
if (readCodeElementLanguage(code) !== 'mermaid') return undefined
const diagram = code.textContent?.endsWith('\n')
? code.textContent
: `${code.textContent ?? ''}\n`
return {
diagram,
source: mermaidFenceSource({ diagram }),
}
}
const MermaidBlock = createReactBlockSpec(
{
type: MERMAID_BLOCK_TYPE,
@@ -116,6 +142,8 @@ const MermaidBlock = createReactBlockSpec(
content: 'none',
},
{
runsBefore: ['codeBlock'],
parse: readMermaidPreElement,
render: (props) => (
<MermaidDiagram
diagram={props.block.props.diagram}
@@ -140,8 +168,8 @@ export const schema = BlockNoteSchema.create({
},
}).extend({
blockSpecs: {
codeBlock,
mathBlock,
mermaidBlock,
codeBlock,
},
})

View File

@@ -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,

View File

@@ -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 },

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
export const RICH_EDITOR_CHANGE_DEBOUNCE_MS = 150
export function useDebouncedEditorChange({
onFlush,
suppressChangeRef,
}: {
onFlush: () => void
suppressChangeRef: MutableRefObject<boolean>
}) {
const pendingRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearTimer = useCallback(() => {
if (!timerRef.current) return
clearTimeout(timerRef.current)
timerRef.current = null
}, [])
const flushPendingEditorChange = useCallback(() => {
if (!pendingRef.current) return false
clearTimer()
pendingRef.current = false
onFlush()
return true
}, [clearTimer, onFlush])
const handleEditorChange = useCallback(() => {
if (suppressChangeRef.current) return
pendingRef.current = true
clearTimer()
timerRef.current = setTimeout(() => {
timerRef.current = null
void flushPendingEditorChange()
}, RICH_EDITOR_CHANGE_DEBOUNCE_MS)
}, [clearTimer, flushPendingEditorChange, suppressChangeRef])
useEffect(() => {
return () => {
void flushPendingEditorChange()
clearTimer()
}
}, [clearTimer, flushPendingEditorChange])
return { handleEditorChange, flushPendingEditorChange }
}
export function consumeRawModeTransition(
prevRawModeRef: MutableRefObject<boolean>,
rawMode: boolean | undefined,
) {
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
return rawModeJustEnded
}
export function flushBeforeRawMode(options: {
rawMode?: boolean
flushPendingEditorChange: () => boolean
}) {
const { rawMode, flushPendingEditorChange } = options
if (!rawMode) return false
flushPendingEditorChange()
return true
}
export function flushBeforePathChange(options: {
pathChanged: boolean
flushPendingEditorChange: () => boolean
}) {
const { pathChanged, flushPendingEditorChange } = options
if (pathChanged) flushPendingEditorChange()
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -35,10 +35,10 @@ function typeSequence(view: EditorView, inputs: readonly string[]) {
})
}
function createView(container: HTMLDivElement) {
function createView(container: HTMLDivElement, content = '') {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, '', noopCallbacks),
useCodeMirror(ref, content, noopCallbacks),
)
return result.current.current!
}
@@ -90,4 +90,22 @@ describe('useCodeMirror arrow ligatures', () => {
})
expect(view.state.doc.toString()).toBe('→')
})
it('keeps Mermaid arrows literal while typing inside fenced code', () => {
const content = [
'```mermaid',
'flowchart TD',
'A --',
'```',
].join('\n')
const cursor = content.indexOf('A --') + 'A --'.length
const view = createView(container, content)
act(() => {
view.dispatch({ selection: { anchor: cursor } })
})
typeSequence(view, ['>'])
expect(view.state.doc.toString()).toContain('A -->')
})
})

View File

@@ -18,6 +18,11 @@ const RAW_EDITOR_COLORS = {
gutterBorder: 'var(--border-subtle)',
gutterText: 'var(--text-muted)',
}
interface MarkdownFence {
character: '`' | '~'
length: number
}
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
onCursorActivity: (view: EditorView) => void
@@ -25,6 +30,41 @@ export interface CodeMirrorCallbacks {
onEscape: () => boolean
}
function readMarkdownFence(line: string): MarkdownFence | null {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line)
if (!match) return null
const fence = match[2]
return {
character: fence[0] as MarkdownFence['character'],
length: fence.length,
}
}
function isClosingMarkdownFence(line: string, opening: MarkdownFence): boolean {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
if (!match) return false
const fence = match[2]
return fence[0] === opening.character && fence.length >= opening.length
}
function isInsideMarkdownFence(markdownBeforeCursor: string): boolean {
const lines = markdownBeforeCursor.split(/\r?\n/)
let opening: MarkdownFence | null = null
for (const line of lines) {
if (opening) {
if (isClosingMarkdownFence(line, opening)) opening = null
continue
}
opening = readMarkdownFence(line)
}
return opening !== null
}
function buildBaseTheme() {
return EditorView.theme({
'&': {
@@ -83,6 +123,11 @@ function buildArrowLigaturesExtension() {
let literalAsciiCursor: number | null = null
return EditorView.inputHandler.of((view, from, _to, text) => {
if (isInsideMarkdownFence(view.state.doc.sliceString(0, from))) {
literalAsciiCursor = null
return false
}
const beforeText = view.state.doc.sliceString(Math.max(0, from - 2), from)
const resolution = resolveArrowLigatureInput({
beforeText,

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -105,6 +105,9 @@ async function expectRenameSessionContinues(options: { renamedTabArrivesLate: bo
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'fresh-title.md',
@@ -171,6 +174,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const syncedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof syncedContent).toBe('string')
@@ -211,6 +217,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const queryContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof queryContent).toBe('string')
@@ -218,6 +227,9 @@ describe('useEditorTabSwap untitled rename continuity', () => {
act(() => {
result.current.handleEditorChange()
})
act(() => {
result.current.flushPendingEditorChange()
})
const insertedContent = onContentChange.mock.calls.at(-1)?.[1]
expect(typeof insertedContent).toBe('string')

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, useEditorTabSwap } from './useEditorTabSwap'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
@@ -254,6 +254,19 @@ function makeHeadingBlocks(
}]
}
function makeLongNoteBlocks(wordCount: number) {
const words = Array.from({ length: wordCount }, (_, index) => `word${index}`)
const paragraphs: unknown[] = []
for (let index = 0; index < words.length; index += 20) {
paragraphs.push({
type: 'paragraph',
content: [{ type: 'text', text: words.slice(index, index + 20).join(' '), styles: {} }],
children: [],
})
}
return paragraphs
}
async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
@@ -299,6 +312,7 @@ async function createSwapHarness(options: {
return {
...rendered,
docRef,
mockEditor,
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
currentProps = { ...currentProps, ...nextProps }
@@ -701,12 +715,98 @@ describe('useEditorTabSwap raw mode sync', () => {
result.current.handleEditorChange()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
result.current.flushPendingEditorChange()
})
expect(onContentChange).toHaveBeenCalledWith(
'a.md',
'---\ntitle: Note A\n---\nInline $x^2$\n',
)
})
it('coalesces rich-editor serialization for a 4000-word note', async () => {
const tabA = makeTab('long.md', 'Long Note')
const onContentChange = vi.fn()
const { docRef, mockEditor, result } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'long.md', rawMode: false },
onContentChange,
})
const longBlocks = makeLongNoteBlocks(4200)
docRef.current = longBlocks
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
(blocks as Array<{ content?: Array<{ text?: string }> }>)
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
.join('\n\n')
))
mockEditor.blocksToMarkdownLossy.mockClear()
vi.useFakeTimers()
try {
act(() => {
result.current.handleEditorChange()
result.current.handleEditorChange()
result.current.handleEditorChange()
})
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(RICH_EDITOR_CHANGE_DEBOUNCE_MS - 1)
})
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(mockEditor.blocksToMarkdownLossy).toHaveBeenCalledTimes(1)
expect(onContentChange).toHaveBeenCalledTimes(1)
expect(onContentChange.mock.calls[0][1].split(/\s+/).length).toBeGreaterThan(4000)
} finally {
vi.useRealTimers()
}
})
it('flushes pending rich-editor content before switching notes', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const onContentChange = vi.fn()
const { docRef, mockEditor, rerender, result } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
onContentChange,
})
docRef.current = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Changed before switch', styles: {} }],
children: [],
}]
mockEditor.blocksToMarkdownLossy.mockReturnValue('Changed before switch\n')
vi.useFakeTimers()
try {
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).not.toHaveBeenCalled()
act(() => {
rerender({ tabs: [tabA, tabB], activeTabPath: 'b.md', rawMode: false })
})
await act(async () => { await Promise.resolve() })
expect(onContentChange).toHaveBeenCalledWith(
'a.md',
'---\ntitle: Note A\n---\nChanged before switch\n',
)
} finally {
vi.useRealTimers()
}
})
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -10,6 +10,12 @@ import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { getResolvedCachedNoteContent, NOTE_CONTENT_CACHE_RESOLVED_EVENT } from './useTabManagement'
import { useEditorMountState, useLatestRef } from './editorTabSwapLifecycle'
import { usePreparedNotePreload } from './usePreparedNotePreload'
import {
consumeRawModeTransition,
flushBeforePathChange,
flushBeforeRawMode,
useDebouncedEditorChange,
} from './editorChangeDebounce'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
@@ -23,6 +29,7 @@ import {
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce'
interface Tab {
entry: VaultEntry
@@ -365,8 +372,7 @@ function useEditorChangeHandler(options: {
vaultPathRef,
} = options
return useCallback(() => {
if (suppressChangeRef.current) return
const propagateEditorChange = useCallback(() => {
const path = prevActivePathRef.current
if (!path) return
@@ -387,16 +393,9 @@ function useEditorChangeHandler(options: {
sourceContent: nextContent,
})
onContentChangeRef.current?.(path, nextContent)
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, suppressChangeRef, tabCacheRef, tabsRef, vaultPathRef])
}
}, [editor, onContentChangeRef, pendingLocalContentRef, prevActivePathRef, tabCacheRef, tabsRef, vaultPathRef])
function consumeRawModeTransition(
prevRawModeRef: MutableRefObject<boolean>,
rawMode: boolean | undefined,
) {
const rawModeJustEnded = prevRawModeRef.current && !rawMode
prevRawModeRef.current = !!rawMode
return rawModeJustEnded
return useDebouncedEditorChange({ onFlush: propagateEditorChange, suppressChangeRef })
}
function cachePreviousTabOnPathChange(options: {
@@ -983,6 +982,7 @@ function runTabSwapEffect(options: {
rawSwapPendingRef: MutableRefObject<boolean>
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
flushPendingEditorChange: () => boolean
vaultPath?: string
}) {
const {
@@ -998,11 +998,12 @@ function runTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath,
} = options
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (flushBeforeRawMode({ rawMode, flushPendingEditorChange })) return
const state = resolveTabSwapState({
tabs,
activeTabPath,
@@ -1010,6 +1011,7 @@ function runTabSwapEffect(options: {
prevActivePathRef,
rawModeJustEnded,
})
flushBeforePathChange({ pathChanged: state.pathChanged, flushPendingEditorChange })
if (shouldSkipScheduledTabSwap({
state,
@@ -1053,6 +1055,7 @@ function useTabSwapEffect(options: {
suppressChangeRef: MutableRefObject<boolean>
pendingLocalContentRef: MutableRefObject<PendingLocalContent | null>
vaultPathRef: MutableRefObject<string | undefined>
flushPendingEditorChange: () => boolean
}) {
const {
tabs,
@@ -1068,6 +1071,7 @@ function useTabSwapEffect(options: {
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
} = options
useEffect(() => {
@@ -1084,6 +1088,7 @@ function useTabSwapEffect(options: {
rawSwapPendingRef,
suppressChangeRef,
pendingLocalContentRef,
flushPendingEditorChange,
vaultPath: vaultPathRef.current,
})
}, [
@@ -1100,6 +1105,7 @@ function useTabSwapEffect(options: {
tabs,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
])
}
@@ -1112,7 +1118,8 @@ function useTabSwapEffect(options: {
* - Cleaning up the block cache when tabs are closed
* - Serializing editor blocks → markdown on change (suppressChangeRef)
*
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
* Returns the onChange callback for SingleEditorView and a flush hook for
* save/navigation paths that need the latest rich-editor content immediately.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, rawMode, vaultPath }: UseEditorTabSwapOptions) {
const tabCacheRef = useRef<Map<string, CachedTabState>>(new Map())
@@ -1126,7 +1133,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
const onContentChangeRef = useLatestRef(onContentChange)
const tabsRef = useLatestRef(tabs)
const vaultPathRef = useLatestRef(vaultPath)
const handleEditorChange = useEditorChangeHandler({
const { handleEditorChange, flushPendingEditorChange } = useEditorChangeHandler({
editor,
tabsRef,
onContentChangeRef,
@@ -1153,6 +1160,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
suppressChangeRef,
pendingLocalContentRef,
vaultPathRef,
flushPendingEditorChange,
})
usePreparedNotePreload({
eventName: NOTE_CONTENT_CACHE_RESOLVED_EVENT,
@@ -1161,5 +1169,5 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
preparePath: preparePreloadedPath,
})
return { handleEditorChange, editorMountedRef }
return { handleEditorChange, flushPendingEditorChange, editorMountedRef }
}

View 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,
}
}

View File

@@ -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())

View File

@@ -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,
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "お気に入りから削除",

View File

@@ -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": "즐겨찾기에서 삭제",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Удалить из избранного",

View File

@@ -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": "从收藏中移除",

View File

@@ -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

View File

@@ -56,6 +56,57 @@ describe('mermaid markdown round-trip', () => {
].join('\n\n'))
})
it('injects parsed Mermaid code blocks into dedicated diagram blocks', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'mermaid' },
content: [{ type: 'text', text: 'flowchart LR\n A --> B', styles: {} }],
children: [],
}]) as Array<{
type: string
props: { source: string; diagram: string }
}>
expect(block.type).toBe(MERMAID_BLOCK_TYPE)
expect(block.props.source).toBe('```mermaid\nflowchart LR\n A --> B\n```')
expect(block.props.diagram).toBe('flowchart LR\n A --> B\n')
})
it('injects Mermaid-looking text code blocks when the parser drops the language', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'text' },
content: [{
type: 'text',
text: [
"%%{init: {'theme':'base'}}%%",
'flowchart TD',
' A --> B',
].join('\n'),
styles: {},
}],
children: [],
}]) as Array<{
type: string
props: { source: string; diagram: string }
}>
expect(block.type).toBe(MERMAID_BLOCK_TYPE)
expect(block.props.source).toContain('```mermaid\n')
expect(block.props.diagram).toContain('flowchart TD\n A --> B\n')
})
it('keeps ordinary text code blocks unchanged', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'text' },
content: [{ type: 'text', text: 'const chart = "flowchart TD"', styles: {} }],
children: [],
}]) as Array<{ type: string }>
expect(block.type).toBe('codeBlock')
})
it('leaves non-Mermaid and unclosed fences as normal Markdown', () => {
const markdown = [
'```ts',

View File

@@ -63,6 +63,10 @@ interface DiagramSource {
diagram: string
}
interface CodeBlockSource {
block: BlockLike
}
function lineEnding({ line }: MarkdownLine): string {
if (line.endsWith('\r\n')) return '\r\n'
return line.endsWith('\n') ? '\n' : ''
@@ -185,10 +189,69 @@ function buildMermaidBlock({ block, payload }: { block: BlockLike; payload: Merm
}
}
export function mermaidFenceSource({ diagram }: DiagramSource): string {
const body = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return `\`\`\`mermaid\n${body}\`\`\``
}
function readCodeBlockLanguage({ block }: CodeBlockSource): string | null {
const language = block.props?.language
if (typeof language !== 'string') return null
return language.trim().split(/\s+/)[0]?.toLowerCase() ?? null
}
function readInlineText(content: InlineItem[] | undefined): string | null {
if (!Array.isArray(content)) return null
return content.map((item) => (
item.type === 'text' && typeof item.text === 'string' ? item.text : ''
)).join('')
}
function looksLikeMermaidDiagram(diagram: string): boolean {
const firstStatement = diagram
.split(/\r?\n/)
.map(line => line.trim())
.find(line => line.length > 0 && !line.startsWith('%%'))
return typeof firstStatement === 'string'
&& /^(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|mindmap|timeline|quadrantChart|requirementDiagram|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|sankey-beta|xychart-beta)\b/.test(firstStatement)
}
function shouldInjectCodeBlockAsMermaid({
diagram,
language,
}: {
diagram: string
language: string | null
}): boolean {
if (language === 'mermaid') return true
if (language !== null && language !== 'text' && language !== 'plain' && language !== 'plaintext') return false
return looksLikeMermaidDiagram(diagram)
}
function readMermaidCodeBlock({ block }: CodeBlockSource): MermaidPayload | null {
if (block.type !== 'codeBlock') return null
const diagram = readInlineText(block.content)
if (diagram === null) return null
if (!shouldInjectCodeBlockAsMermaid({ diagram, language: readCodeBlockLanguage({ block }) })) return null
const normalizedDiagram = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return {
diagram: normalizedDiagram,
source: mermaidFenceSource({ diagram: normalizedDiagram }),
}
}
function injectMermaidInBlock(block: BlockLike): BlockLike {
const payload = readMermaidPayload(block.content)
if (payload) return buildMermaidBlock({ block, payload })
const codeBlockPayload = readMermaidCodeBlock({ block })
if (codeBlockPayload) return buildMermaidBlock({ block, payload: codeBlockPayload })
const children = Array.isArray(block.children) ? block.children.map(injectMermaidInBlock) : block.children
return { ...block, children }
}
@@ -199,16 +262,11 @@ function isMermaidBlock(block: BlockLike): boolean {
&& typeof block.props?.diagram === 'string'
}
function fallbackMermaidSource({ diagram }: DiagramSource): string {
const body = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return `\`\`\`mermaid\n${body}\`\`\``
}
function mermaidMarkdown(block: BlockLike): string {
const source = block.props?.source
if (source) return source
return fallbackMermaidSource({ diagram: block.props?.diagram ?? '' })
return mermaidFenceSource({ diagram: block.props?.diagram ?? '' })
}
export function injectMermaidInBlocks(blocks: unknown[]): unknown[] {

View 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
View 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'
}

View File

@@ -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)

View File

@@ -0,0 +1,101 @@
import fs from 'fs'
import path from 'path'
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { sendShortcut } from './helpers'
const LONG_NOTE_TITLE = 'Long Note Scaling QA'
const LONG_NOTE_RELATIVE_PATH = path.join('note', 'long-note-scaling-qa.md')
const PARAGRAPH_COUNT = 82
const WORDS_PER_PARAGRAPH = 50
let tempVaultDir: string
let longNotePath: string
function segmentLabel(index: number): string {
return `Segment ${String(index).padStart(3, '0')}`
}
function makeLongNoteMarkdown(): string {
const paragraphs = Array.from({ length: PARAGRAPH_COUNT }, (_, paragraphIndex) => {
const segment = segmentLabel(paragraphIndex + 1)
const words = Array.from({ length: WORDS_PER_PARAGRAPH }, (_, wordIndex) =>
`scale${String(paragraphIndex + 1).padStart(3, '0')}_${String(wordIndex + 1).padStart(2, '0')}`,
)
return `${segment} ${words.join(' ')}`
})
return `---\ntype: note\n---\n\n# ${LONG_NOTE_TITLE}\n\n${paragraphs.join('\n\n')}\n`
}
async function openNote(page: Page, title: string): Promise<void> {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 8_000 })
}
async function appendTextToSegment(page: Page, segment: string, text: string): Promise<void> {
const paragraph = page.locator('.bn-editor p').filter({ hasText: segment }).first()
await paragraph.scrollIntoViewIfNeeded()
await paragraph.click()
await page.keyboard.press('End')
await page.keyboard.type(text)
await expect(page.locator('.bn-editor')).toContainText(text.trim(), { timeout: 5_000 })
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(120_000)
tempVaultDir = createFixtureVaultCopy()
longNotePath = path.join(tempVaultDir, LONG_NOTE_RELATIVE_PATH)
fs.writeFileSync(longNotePath, makeLongNoteMarkdown(), 'utf8')
await openFixtureVaultDesktopHarness(page, tempVaultDir, { expectedReadyTitle: LONG_NOTE_TITLE })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('long rich-editor notes stay editable across typing, wikilinks, save, and note switches', async ({ page }) => {
const beginningEdit = ` beginning edit ${Date.now()}`
const middleEdit = ` middle edit ${Date.now()}`
const endEdit = ` end edit ${Date.now()}`
await openNote(page, LONG_NOTE_TITLE)
await appendTextToSegment(page, segmentLabel(1), beginningEdit)
await appendTextToSegment(page, segmentLabel(41), middleEdit)
await appendTextToSegment(page, segmentLabel(82), endEdit)
const wikilinkParagraph = page.locator('.bn-editor p').filter({ hasText: segmentLabel(41) }).first()
await wikilinkParagraph.click()
await page.keyboard.press('End')
await page.keyboard.type(' [[Alpha')
const wikilinkMenu = page.locator('.wikilink-menu')
await expect(wikilinkMenu).toBeVisible({ timeout: 5_000 })
await expect(wikilinkMenu).toContainText('Alpha Project')
await page.keyboard.press('Enter')
await expect(page.locator('.bn-editor .wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible()
await page.keyboard.press('PageUp')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('PageDown')
await sendShortcut(page, 's', ['Control'])
await openNote(page, 'Note B')
await openNote(page, LONG_NOTE_TITLE)
const editor = page.locator('.bn-editor')
await expect(editor).toContainText(beginningEdit.trim(), { timeout: 5_000 })
await expect(editor).toContainText(middleEdit.trim(), { timeout: 5_000 })
await expect(editor).toContainText(endEdit.trim(), { timeout: 5_000 })
await expect(editor.locator('.wikilink').filter({ hasText: 'Alpha Project' }).last()).toBeVisible()
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(beginningEdit.trim())
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(middleEdit.trim())
await expect.poll(() => fs.readFileSync(longNotePath, 'utf8'), { timeout: 5_000 }).toContain(endEdit.trim())
})

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
@@ -19,6 +19,21 @@ const SECOND_DIAGRAM = [
' Alice->>Bob: Hello',
'```',
].join('\n')
const REPORTED_THEME_DIAGRAM = [
'```mermaid',
"%%{init: {'theme':'base','themeVariables':{'primaryColor':'#dbe7ff','primaryTextColor':'#0b1220','primaryBorderColor':'#1f3a8a','lineColor':'#1f3a8a','secondaryColor':'#fff4d6','tertiaryColor':'#ffe0e0','fontSize':'14px'}}}%%",
'flowchart TD',
' A(["Employee clocks in"]) --> B{"Linked to a planned shift?"}',
'',
' B -- "Yes" --> C["Other path<br/>for scheduled shift"]',
' C --> D["Contract path<br/>for scheduled shift"]',
'',
' B -- "No (unscheduled)" --> E["other path<br/>for unscheduled clocking"]',
'',
' classDef path fill:#fff4d6,stroke:#7a5b00,stroke-width:2px,color:#3a2c00;',
' class C,D,E,F path;',
'```',
].join('\n')
const SYSTEM_OVERVIEW_DIAGRAM = [
'```mermaid',
'flowchart TD',
@@ -77,6 +92,15 @@ const INVALID_DIAGRAM = [
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
fs.writeFileSync(
path.join(tempVaultDir, 'note', 'mermaid-reported.md'),
[
'# Mermaid Reported',
'',
REPORTED_THEME_DIAGRAM,
'',
].join('\n'),
)
await openFixtureVault(page, tempVaultDir)
})
@@ -162,10 +186,72 @@ async function countLargeBlackFilledShapes(page: Page, diagramIndex: number): Pr
})
}
function mermaidSvg(page: Page, diagramIndex: number): Locator {
return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex)
}
function mermaidNode(page: Page, diagramIndex: number, text: string): Locator {
return mermaidSvg(page, diagramIndex).locator('.node').filter({ hasText: text }).first()
}
async function computedCss(locator: Locator, property: 'color' | 'fill' | 'stroke'): Promise<string> {
return locator.evaluate((element, styleProperty) => (
getComputedStyle(element).getPropertyValue(styleProperty)
), property)
}
async function labelFitsNode(node: Locator): Promise<boolean> {
return node.evaluate((element) => {
const shape = element.querySelector<SVGGraphicsElement>('rect, polygon, circle, ellipse, path')!
const label = element.querySelector<HTMLElement>('.nodeLabel')!
const shapeBox = shape.getBoundingClientRect()
const labelBox = label.getBoundingClientRect()
return [
labelBox.width <= shapeBox.width + 2,
labelBox.height <= shapeBox.height + 2,
].every(Boolean)
})
}
async function readReportedDiagramMetrics(page: Page, diagramIndex: number) {
const svg = mermaidSvg(page, diagramIndex)
const scheduledNode = mermaidNode(page, diagramIndex, 'Other path')
const scheduledShape = scheduledNode.locator('rect, polygon, circle, ellipse, path').first()
const scheduledLabel = scheduledNode.locator('.nodeLabel').first()
return {
connectorStroke: await computedCss(svg.locator('.flowchart-link').first(), 'stroke'),
markerFill: await computedCss(svg.locator('marker path').first(), 'fill'),
scheduledFill: await computedCss(scheduledShape, 'fill'),
scheduledStroke: await computedCss(scheduledShape, 'stroke'),
scheduledTextColor: await computedCss(scheduledLabel, 'color'),
labelsFit: [
await labelFitsNode(scheduledNode),
await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')),
await labelFitsNode(mermaidNode(page, diagramIndex, 'unscheduled clocking')),
].every(Boolean),
text: await svg.evaluate((element) => element.textContent ?? ''),
}
}
function readNoteBFile(): string {
return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8')
}
test('Mermaid diagrams render when opening saved notes directly', async ({ page }) => {
await openNote(page, 'Mermaid Reported')
await expectRenderedDiagramCount(page, 1)
await expect.poll(() => readReportedDiagramMetrics(page, 0)).toMatchObject({
connectorStroke: 'rgb(31, 58, 138)',
markerFill: 'rgb(31, 58, 138)',
scheduledFill: 'rgb(255, 244, 214)',
scheduledStroke: 'rgb(122, 91, 0)',
scheduledTextColor: 'rgb(58, 44, 0)',
labelsFit: true,
text: expect.stringContaining('Linked to a planned shift?'),
})
})
test('Mermaid diagrams render, fall back, and round-trip through raw mode', async ({ page }) => {
await openNote(page, 'Note B')
await toggleRawMode(page, '.cm-content')
@@ -175,6 +261,8 @@ test('Mermaid diagrams render, fall back, and round-trip through raw mode', asyn
${FIRST_DIAGRAM}
${REPORTED_THEME_DIAGRAM}
${INVALID_DIAGRAM}
${SECOND_DIAGRAM}
@@ -186,19 +274,29 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
await expect.poll(readNoteBFile).toContain(FIRST_DIAGRAM)
await toggleRawMode(page, '.bn-editor')
await expectRenderedDiagramCount(page, 3)
await expect.poll(() => countLargeBlackFilledShapes(page, 2)).toBe(0)
await expectRenderedDiagramCount(page, 4)
await expect.poll(() => countLargeBlackFilledShapes(page, 3)).toBe(0)
await expect.poll(() => readReportedDiagramMetrics(page, 1)).toMatchObject({
connectorStroke: 'rgb(31, 58, 138)',
markerFill: 'rgb(31, 58, 138)',
scheduledFill: 'rgb(255, 244, 214)',
scheduledStroke: 'rgb(122, 91, 0)',
scheduledTextColor: 'rgb(58, 44, 0)',
labelsFit: true,
text: expect.stringContaining('Linked to a planned shift?'),
})
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toHaveCount(1)
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('not a diagram')
await page.locator('[data-testid="mermaid-diagram"]').nth(2).hover()
await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(2).click()
await page.locator('[data-testid="mermaid-diagram"]').nth(3).hover()
await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(3).click()
await expect(page.locator('[data-testid="mermaid-diagram-dialog-viewport"] svg')).toBeVisible()
await page.keyboard.press('Escape')
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
expect(rawAfterRichMode).toContain(FIRST_DIAGRAM)
expect(rawAfterRichMode).toContain(REPORTED_THEME_DIAGRAM)
expect(rawAfterRichMode).toContain(INVALID_DIAGRAM)
expect(rawAfterRichMode).toContain(SECOND_DIAGRAM)
expect(rawAfterRichMode).toContain(SYSTEM_OVERVIEW_DIAGRAM)
@@ -207,7 +305,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
await expect.poll(readNoteBFile).toContain(UPDATED_FIRST_DIAGRAM)
await toggleRawMode(page, '.bn-editor')
await expectRenderedDiagramCount(page, 3)
await expectRenderedDiagramCount(page, 4)
await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Published')
await openNote(page, 'Note C')
@@ -216,6 +314,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
const reopenedRaw = await getRawEditorContent(page)
expect(reopenedRaw).toContain(UPDATED_FIRST_DIAGRAM)
expect(reopenedRaw).toContain(REPORTED_THEME_DIAGRAM)
expect(reopenedRaw).toContain(INVALID_DIAGRAM)
expect(reopenedRaw).toContain(SECOND_DIAGRAM)
expect(reopenedRaw).toContain(SYSTEM_OVERVIEW_DIAGRAM)

View 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')
})