Compare commits
4 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb9ebaadad | ||
|
|
c295c9f2b5 | ||
|
|
a4b11089c0 | ||
|
|
6f29542528 |
@@ -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
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
@@ -32,8 +33,10 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
|
||||
- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
|
||||
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -194,7 +197,7 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
@@ -66,6 +66,10 @@ files:
|
||||
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
|
||||
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
|
||||
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
|
||||
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
|
||||
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
|
||||
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
|
||||
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
|
||||
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
|
||||
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
|
||||
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
|
||||
@@ -316,6 +320,8 @@ files:
|
||||
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
|
||||
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
|
||||
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
|
||||
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
|
||||
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
|
||||
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
|
||||
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
|
||||
|
||||
@@ -6,6 +6,7 @@ const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
|
||||
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -21,6 +22,7 @@ pub struct Settings {
|
||||
pub release_channel: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub ui_language: Option<String>,
|
||||
pub note_width_mode: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
pub hide_gitignored_files: Option<bool>,
|
||||
@@ -65,6 +67,13 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
|
||||
settings
|
||||
.hide_gitignored_files
|
||||
@@ -123,6 +132,7 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
|
||||
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
|
||||
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
|
||||
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
|
||||
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
|
||||
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
|
||||
hide_gitignored_files: settings.hide_gitignored_files,
|
||||
@@ -266,6 +276,7 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
@@ -294,6 +305,7 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
@@ -307,6 +319,7 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
assert_eq!(loaded.hide_gitignored_files, Some(false));
|
||||
@@ -332,6 +345,7 @@ mod tests {
|
||||
release_channel: Some(" alpha ".to_string()),
|
||||
theme_mode: Some(" dark ".to_string()),
|
||||
ui_language: Some(" zh-cn ".to_string()),
|
||||
note_width_mode: Some(" WIDE ".to_string()),
|
||||
default_ai_agent: Some(" codex ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -339,6 +353,7 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
@@ -416,6 +431,15 @@ mod tests {
|
||||
assert!(loaded.theme_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_note_width_mode_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
note_width_mode: Some("expanded".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.note_width_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ui_language_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct VaultEntry {
|
||||
/// Default view mode for the note list when viewing instances of this Type.
|
||||
/// Stored as a string: "all", "editor-list", or "editor-only".
|
||||
pub view: Option<String>,
|
||||
/// Rich-editor width mode for this note. None means use the app default.
|
||||
#[serde(rename = "noteWidth")]
|
||||
pub note_width: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note has been explicitly organized (removed from Inbox).
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) struct Frontmatter {
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(rename = "_width", alias = "width", default)]
|
||||
pub note_width: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
@@ -203,6 +205,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_sort",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"notion_id",
|
||||
"Status",
|
||||
@@ -238,6 +242,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"status",
|
||||
"_organized",
|
||||
@@ -332,6 +338,16 @@ pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
|
||||
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
|
||||
match note_width
|
||||
.and_then(StringOrList::into_scalar)
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
{
|
||||
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert gray_matter::Pod to serde_json::Value
|
||||
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
match pod {
|
||||
|
||||
@@ -41,7 +41,7 @@ pub use views::{
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a};
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_width};
|
||||
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -151,6 +151,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
template: frontmatter.template.and_then(|v| v.into_scalar()),
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
note_width: resolve_note_width(frontmatter.note_width),
|
||||
visible: frontmatter.visible,
|
||||
organized: frontmatter.organized.unwrap_or(false),
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
|
||||
@@ -71,3 +71,29 @@ fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_note_width_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: wide\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width.as_deref(), Some("wide"));
|
||||
assert!(!entry.properties.contains_key("_width"));
|
||||
assert!(!entry.relationships.contains_key("_width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_note_width_modes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width, None);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -32,6 +32,7 @@ import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteWidthMode } from './hooks/useNoteWidthMode'
|
||||
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
|
||||
import { useThemeMode } from './hooks/useThemeMode'
|
||||
import type { ThemeMode } from './lib/themeMode'
|
||||
@@ -40,7 +41,6 @@ import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useNoteLayout } from './hooks/useNoteLayout'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -327,25 +327,25 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — note layout toggle', () => {
|
||||
it('shows the left-align layout action while centered', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
|
||||
describe('BreadcrumbBar — note width toggle', () => {
|
||||
it('shows the wide width action while normal', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to wide note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the centered layout action while left-aligned', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
|
||||
it('shows the normal width action while wide', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="wide" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to normal note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleNoteLayout when the layout button is clicked', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
|
||||
it('calls onToggleNoteWidth when the width button is clicked', () => {
|
||||
const onToggleNoteWidth = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={onToggleNoteWidth} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to wide note width' }))
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { NoteLayout, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
TextAlignCenter,
|
||||
TextAlignLeft,
|
||||
ArrowsInLineHorizontal,
|
||||
ArrowsOutLineHorizontal,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
@@ -50,8 +50,8 @@ interface BreadcrumbBarProps {
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
locale?: AppLocale
|
||||
@@ -257,27 +257,27 @@ function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: bo
|
||||
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
|
||||
}
|
||||
|
||||
function NoteLayoutAction({
|
||||
noteLayout = 'centered',
|
||||
function NoteWidthAction({
|
||||
noteWidth = 'normal',
|
||||
locale = 'en',
|
||||
onToggleNoteLayout,
|
||||
onToggleNoteWidth,
|
||||
}: {
|
||||
noteLayout?: NoteLayout
|
||||
noteWidth?: NoteWidthMode
|
||||
locale?: AppLocale
|
||||
onToggleNoteLayout?: () => void
|
||||
onToggleNoteWidth?: () => void
|
||||
}) {
|
||||
if (!onToggleNoteLayout) return null
|
||||
if (!onToggleNoteWidth) return null
|
||||
|
||||
const isLeftAligned = noteLayout === 'left'
|
||||
const isWide = noteWidth === 'wide'
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
|
||||
onClick={onToggleNoteLayout}
|
||||
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
|
||||
copy={{ label: translate(locale, isWide ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide') }}
|
||||
onClick={onToggleNoteWidth}
|
||||
className={cn(isWide ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
{isLeftAligned
|
||||
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
{isWide
|
||||
? <ArrowsInLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <ArrowsOutLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -624,8 +624,8 @@ function BreadcrumbActions({
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
@@ -651,7 +651,7 @@ function BreadcrumbActions({
|
||||
locale={locale}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
|
||||
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
|
||||
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
|
||||
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
|
||||
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
|
||||
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
|
||||
@@ -204,9 +204,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: clamp(16px, 6%, 96px);
|
||||
margin-right: auto;
|
||||
.editor-content-width--wide .editor-content-wrapper {
|
||||
max-width: min(100%, 1280px);
|
||||
padding-left: clamp(24px, 4vw, 72px);
|
||||
padding-right: clamp(24px, 4vw, 72px);
|
||||
}
|
||||
|
||||
.raw-editor-codemirror {
|
||||
@@ -332,10 +333,3 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container editor (max-width: 900px) {
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { EditorContentLayout } from './EditorContentLayout'
|
||||
|
||||
vi.mock('../BreadcrumbBar', () => ({
|
||||
BreadcrumbBar: ({ noteLayout }: { noteLayout?: string }) => <div data-testid="breadcrumb-bar" data-note-layout={noteLayout} />,
|
||||
BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) => <div data-testid="breadcrumb-bar" data-note-width={noteWidth} />,
|
||||
}))
|
||||
|
||||
vi.mock('../ArchivedNoteBanner', () => ({
|
||||
@@ -63,8 +63,8 @@ function createModel(overrides: Record<string, unknown> = {}) {
|
||||
onEditorChange: vi.fn(),
|
||||
isDeletedPreview: false,
|
||||
rawLatestContentRef: { current: null },
|
||||
noteLayout: 'centered',
|
||||
onToggleNoteLayout: vi.fn(),
|
||||
noteWidth: 'normal',
|
||||
onToggleNoteWidth: vi.fn(),
|
||||
forceRawMode: false,
|
||||
showAIChat: false,
|
||||
onToggleAIChat: vi.fn(),
|
||||
@@ -102,18 +102,18 @@ describe('EditorContentLayout', () => {
|
||||
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the editor content root and breadcrumb with the note layout preference', () => {
|
||||
const { container } = render(<EditorContentLayout {...createModel({ noteLayout: 'left' })} />)
|
||||
it('marks the editor content root and breadcrumb with the note width mode', () => {
|
||||
const { container } = render(<EditorContentLayout {...createModel({ noteWidth: 'wide' })} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('editor-content-layout--left')
|
||||
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left')
|
||||
expect(container.firstElementChild).toHaveClass('editor-content-width--wide')
|
||||
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide')
|
||||
})
|
||||
|
||||
it('keeps raw mode out of the centered rich-editor content wrapper', () => {
|
||||
it('keeps raw mode out of the rich-editor content wrapper', () => {
|
||||
render(<EditorContentLayout {...createModel({
|
||||
effectiveRawMode: true,
|
||||
showEditor: false,
|
||||
noteLayout: 'centered',
|
||||
noteWidth: 'normal',
|
||||
})} />)
|
||||
|
||||
const rawEditor = screen.getByTestId('raw-editor-view')
|
||||
|
||||
@@ -34,8 +34,8 @@ type BreadcrumbActions = Pick<
|
||||
| 'onArchiveNote'
|
||||
| 'onUnarchiveNote'
|
||||
| 'onRenameFilename'
|
||||
| 'noteLayout'
|
||||
| 'onToggleNoteLayout'
|
||||
| 'noteWidth'
|
||||
| 'onToggleNoteWidth'
|
||||
>
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -149,8 +149,8 @@ function ActiveTabBreadcrumb({
|
||||
onArchive={bindPath(actions.onArchiveNote, path)}
|
||||
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
|
||||
onRenameFilename={actions.onRenameFilename}
|
||||
noteLayout={actions.noteLayout}
|
||||
onToggleNoteLayout={actions.onToggleNoteLayout}
|
||||
noteWidth={actions.noteWidth}
|
||||
onToggleNoteWidth={actions.onToggleNoteWidth}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
@@ -287,13 +287,13 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
isDeletedPreview,
|
||||
rawLatestContentRef,
|
||||
rawModeContent,
|
||||
noteLayout,
|
||||
noteWidth,
|
||||
findRequest,
|
||||
locale,
|
||||
} = model
|
||||
const rootClassName = cn(
|
||||
'flex flex-1 flex-col min-w-0 min-h-0',
|
||||
noteLayout === 'left' ? 'editor-content-layout--left' : 'editor-content-layout--centered',
|
||||
noteWidth === 'wide' ? 'editor-content-width--wide' : 'editor-content-width--normal',
|
||||
)
|
||||
|
||||
if (!activeTab) {
|
||||
@@ -332,8 +332,8 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onArchiveNote: model.onArchiveNote,
|
||||
onUnarchiveNote: model.onUnarchiveNote,
|
||||
onRenameFilename: model.onRenameFilename,
|
||||
noteLayout: model.noteLayout,
|
||||
onToggleNoteLayout: model.onToggleNoteLayout,
|
||||
noteWidth: model.noteWidth,
|
||||
onToggleNoteWidth: model.onToggleNoteWidth,
|
||||
}}
|
||||
/>
|
||||
<EditorChrome
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from 'react'
|
||||
import { useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { AppLocale } from '../../lib/i18n'
|
||||
import type { NoteLayout, NoteStatus, VaultEntry } from '../../types'
|
||||
import type { NoteWidthMode, NoteStatus, VaultEntry } from '../../types'
|
||||
import { useEditorTheme } from '../../hooks/useTheme'
|
||||
import { deriveEditorContentState } from './editorContentState'
|
||||
import type { RawEditorFindRequest } from '../RawEditorFindBar'
|
||||
@@ -45,8 +45,8 @@ export interface EditorContentProps {
|
||||
findRequest?: RawEditorFindRequest | null
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
|
||||
84
src/components/editorContentFlushRegistration.ts
Normal file
84
src/components/editorContentFlushRegistration.ts
Normal 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])
|
||||
}
|
||||
25
src/components/editorSchema.mermaid.test.ts
Normal file
25
src/components/editorSchema.mermaid.test.ts
Normal 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```',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -49,6 +49,10 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
|
||||
'toggle-inspector': 'command.view.toggleProperties',
|
||||
'toggle-diff': 'command.view.toggleDiff',
|
||||
'toggle-raw-editor': 'command.view.toggleRaw',
|
||||
'set-note-width-normal': 'command.view.noteWidthNormal',
|
||||
'set-note-width-wide': 'command.view.noteWidthWide',
|
||||
'set-default-note-width-normal': 'command.view.defaultNoteWidthNormal',
|
||||
'set-default-note-width-wide': 'command.view.defaultNoteWidthWide',
|
||||
'toggle-ai-panel': 'command.view.toggleAiPanel',
|
||||
'new-ai-chat': 'command.view.newAiChat',
|
||||
'toggle-backlinks': 'command.view.toggleBacklinks',
|
||||
@@ -116,7 +120,6 @@ function localizeMoveSavedViewCommand(command: CommandAction, t: Translate, dire
|
||||
}
|
||||
|
||||
const VIEW_STATE_LABELERS: Partial<Record<string, CommandLabeler>> = {
|
||||
'toggle-note-layout': (command, t) => t(command.label === 'Use Left-Aligned Note Layout' ? 'command.view.leftLayout' : 'command.view.centerLayout'),
|
||||
'zoom-in': (command, t) => t('command.view.zoomIn', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
|
||||
'zoom-out': (command, t) => t('command.view.zoomOut', { zoom: parenthesizedSuffix(command.label)?.replace('%', '') ?? '' }),
|
||||
'customize-note-list-columns': localizeColumnsCommand,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import type { NoteLayout } from '../../types'
|
||||
import type { NoteWidthMode } from '../../types'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
import { DEFAULT_NOTE_WIDTH_MODE } from '../../utils/noteWidth'
|
||||
|
||||
const NOTE_LAYOUT_COMMAND_LABELS: Record<NoteLayout, string> = {
|
||||
centered: 'Use Left-Aligned Note Layout',
|
||||
left: 'Use Centered Note Layout',
|
||||
const NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
|
||||
normal: 'Use Normal Note Width',
|
||||
wide: 'Use Wide Note Width',
|
||||
}
|
||||
|
||||
const DEFAULT_NOTE_WIDTH_COMMAND_LABELS: Record<NoteWidthMode, string> = {
|
||||
normal: 'Use Normal Note Width by Default',
|
||||
wide: 'Use Wide Note Width by Default',
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
@@ -18,8 +24,10 @@ interface ViewCommandsConfig {
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onToggleAIChat?: () => void
|
||||
zoomLevel: number
|
||||
onZoomIn: () => void
|
||||
@@ -35,14 +43,34 @@ interface ViewCommandsConfig {
|
||||
canMoveSelectedViewDown?: boolean
|
||||
}
|
||||
|
||||
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
|
||||
function buildSetNoteWidthCommand(
|
||||
mode: NoteWidthMode,
|
||||
activeMode: NoteWidthMode,
|
||||
hasActiveNote: boolean,
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void,
|
||||
): CommandAction {
|
||||
return {
|
||||
id: 'toggle-note-layout',
|
||||
label: NOTE_LAYOUT_COMMAND_LABELS[noteLayout],
|
||||
id: `set-note-width-${mode}`,
|
||||
label: NOTE_WIDTH_COMMAND_LABELS[mode],
|
||||
group: 'View',
|
||||
keywords: ['layout', 'note', 'column', 'wide', 'left', 'centered', 'reading'],
|
||||
enabled: Boolean(onToggleNoteLayout),
|
||||
execute: onToggleNoteLayout ?? noop,
|
||||
keywords: ['layout', 'note', 'column', 'width', mode, 'reading'],
|
||||
enabled: hasActiveNote && Boolean(onSetNoteWidth) && activeMode !== mode,
|
||||
execute: onSetNoteWidth ? () => onSetNoteWidth(mode) : noop,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSetDefaultNoteWidthCommand(
|
||||
mode: NoteWidthMode,
|
||||
defaultMode: NoteWidthMode,
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void,
|
||||
): CommandAction {
|
||||
return {
|
||||
id: `set-default-note-width-${mode}`,
|
||||
label: DEFAULT_NOTE_WIDTH_COMMAND_LABELS[mode],
|
||||
group: 'View',
|
||||
keywords: ['layout', 'note', 'column', 'width', mode, 'default', 'reading'],
|
||||
enabled: Boolean(onSetDefaultNoteWidth) && defaultMode !== mode,
|
||||
execute: onSetDefaultNoteWidth ? () => onSetDefaultNoteWidth(mode) : noop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +95,9 @@ function buildMoveSavedViewCommand(
|
||||
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
hasActiveNote, activeNoteModified,
|
||||
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
|
||||
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor,
|
||||
noteWidth = DEFAULT_NOTE_WIDTH_MODE, defaultNoteWidth = DEFAULT_NOTE_WIDTH_MODE,
|
||||
onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
|
||||
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
|
||||
@@ -81,7 +111,10 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
buildNoteLayoutCommand(noteLayout, onToggleNoteLayout),
|
||||
buildSetNoteWidthCommand('normal', noteWidth, hasActiveNote, onSetNoteWidth),
|
||||
buildSetNoteWidthCommand('wide', noteWidth, hasActiveNote, onSetNoteWidth),
|
||||
buildSetDefaultNoteWidthCommand('normal', defaultNoteWidth, onSetDefaultNoteWidth),
|
||||
buildSetDefaultNoteWidthCommand('wide', defaultNoteWidth, onSetDefaultNoteWidth),
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
|
||||
75
src/hooks/editorChangeDebounce.ts
Normal file
75
src/hooks/editorChangeDebounce.ts
Normal 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()
|
||||
}
|
||||
@@ -14,6 +14,8 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', true, { archived: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
['_width', 'wide', { noteWidth: 'wide' }],
|
||||
['width', 'normal', { noteWidth: 'normal' }],
|
||||
['visible', false, { visible: false }],
|
||||
['visible', true, { visible: null }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
@@ -68,6 +70,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
['_width', { noteWidth: null }],
|
||||
['visible', { visible: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
@@ -165,6 +168,11 @@ describe('contentToEntryPatch', () => {
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', properties: { custom: 'value' } })
|
||||
})
|
||||
|
||||
it('extracts note width without leaking it into properties', () => {
|
||||
const content = '---\ntype: Note\n_width: wide\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note', noteWidth: 'wide' })
|
||||
})
|
||||
|
||||
it('preserves _favorite_index as a number (not null)', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { canonicalSystemMetadataKey, isSystemMetadataKey } from '../utils/systemMetadata'
|
||||
import { normalizeNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
type FrontmatterCommand = 'update_frontmatter' | 'delete_frontmatter_property'
|
||||
type FrontmatterKey = string
|
||||
@@ -23,7 +24,8 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
_archived: { archived: false }, archived: { archived: false },
|
||||
_order: { order: null },
|
||||
template: { template: null }, _sort: { sort: null }, visible: { visible: null },
|
||||
template: { template: null }, _sort: { sort: null }, view: { view: null },
|
||||
_width: { noteWidth: null }, visible: { visible: null },
|
||||
_organized: { organized: false },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
_list_properties_display: { listPropertiesDisplay: [] },
|
||||
@@ -110,6 +112,7 @@ function knownFrontmatterUpdates(value: FrontmatterValue | undefined): Record<Fr
|
||||
template: { template: str },
|
||||
_sort: { sort: str },
|
||||
view: { view: str },
|
||||
_width: { noteWidth: normalizeNoteWidthMode(value) },
|
||||
visible: { visible: visibleValue(value) },
|
||||
_organized: { organized: Boolean(value) },
|
||||
_favorite: { favorite: Boolean(value) },
|
||||
@@ -181,17 +184,48 @@ async function invokeFrontmatter(command: FrontmatterCommand, args: Record<strin
|
||||
return invoke<string>(command, args)
|
||||
}
|
||||
|
||||
function seedMockContent(path: VaultPath, content: MarkdownContent): void {
|
||||
updateMockContent(path, content)
|
||||
}
|
||||
|
||||
async function loadMockContent(path: VaultPath): Promise<MarkdownContent> {
|
||||
try {
|
||||
return await mockInvoke<MarkdownContent>('get_note_content', { path })
|
||||
} catch {
|
||||
return typeof window === 'undefined' ? '' : window.__mockContent?.[path] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
async function persistMockContent(path: VaultPath, content: MarkdownContent): Promise<void> {
|
||||
try {
|
||||
await mockInvoke('save_note_content', { path, content })
|
||||
} finally {
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
}
|
||||
}
|
||||
|
||||
function applyMockFrontmatterUpdate(path: VaultPath, key: FrontmatterKey, value: FrontmatterValue): MarkdownContent {
|
||||
const content = updateMockFrontmatter(path, key, value)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
function applyMockFrontmatterDelete(path: VaultPath, key: FrontmatterKey): MarkdownContent {
|
||||
const content = deleteMockFrontmatterProperty(path, key)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
async function executeMockFrontmatterOp(
|
||||
op: FrontmatterOp,
|
||||
path: VaultPath,
|
||||
key: FrontmatterKey,
|
||||
value?: FrontmatterValue,
|
||||
): Promise<MarkdownContent> {
|
||||
seedMockContent(path, await loadMockContent(path))
|
||||
const content = op === 'update'
|
||||
? applyMockFrontmatterUpdate(path, key, value!)
|
||||
: applyMockFrontmatterDelete(path, key)
|
||||
await persistMockContent(path, content)
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -202,9 +236,13 @@ async function executeFrontmatterOp(
|
||||
value?: FrontmatterValue,
|
||||
): Promise<MarkdownContent> {
|
||||
if (op === 'update') {
|
||||
return isTauri() ? invokeFrontmatter('update_frontmatter', { path, key, value }) : applyMockFrontmatterUpdate(path, key, value!)
|
||||
return isTauri()
|
||||
? invokeFrontmatter('update_frontmatter', { path, key, value })
|
||||
: executeMockFrontmatterOp(op, path, key, value)
|
||||
}
|
||||
return isTauri() ? invokeFrontmatter('delete_frontmatter_property', { path, key }) : applyMockFrontmatterDelete(path, key)
|
||||
return isTauri()
|
||||
? invokeFrontmatter('delete_frontmatter_property', { path, key })
|
||||
: executeMockFrontmatterOp(op, path, key)
|
||||
}
|
||||
|
||||
export interface FrontmatterOpOptions {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { useKeyboardNavigation } from './useKeyboardNavigation'
|
||||
import { useMenuEvents } from './useMenuEvents'
|
||||
import type { NoteLayout, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, SidebarSelection, SidebarFilter, VaultEntry } from '../types'
|
||||
import { requestAddRemote } from '../utils/addRemoteEvents'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
@@ -47,8 +47,10 @@ interface AppCommandsConfig {
|
||||
onMoveSelectedViewDown?: () => void
|
||||
canMoveSelectedViewUp?: boolean
|
||||
canMoveSelectedViewDown?: boolean
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
activeNoteModified: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -167,8 +169,10 @@ type CommandRegistryCoreActions = Pick<
|
||||
| 'onMoveSelectedViewDown'
|
||||
| 'canMoveSelectedViewUp'
|
||||
| 'canMoveSelectedViewDown'
|
||||
| 'noteLayout'
|
||||
| 'onToggleNoteLayout'
|
||||
| 'noteWidth'
|
||||
| 'defaultNoteWidth'
|
||||
| 'onSetNoteWidth'
|
||||
| 'onSetDefaultNoteWidth'
|
||||
| 'onToggleAIChat'
|
||||
>
|
||||
type CommandRegistryVaultActions = Pick<
|
||||
@@ -437,8 +441,10 @@ function createCommandRegistryCoreConfig(
|
||||
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
|
||||
onFindInNote: config.onFindInNote,
|
||||
onReplaceInNote: config.onReplaceInNote,
|
||||
noteLayout: config.noteLayout,
|
||||
onToggleNoteLayout: config.onToggleNoteLayout,
|
||||
noteWidth: config.noteWidth,
|
||||
defaultNoteWidth: config.defaultNoteWidth,
|
||||
onSetNoteWidth: config.onSetNoteWidth,
|
||||
onSetDefaultNoteWidth: config.onSetDefaultNoteWidth,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -->')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,8 +25,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onToggleInspector: vi.fn(),
|
||||
onToggleDiff: vi.fn(),
|
||||
onToggleRawEditor: vi.fn(),
|
||||
noteLayout: 'centered',
|
||||
onToggleNoteLayout: vi.fn(),
|
||||
noteWidth: 'normal',
|
||||
defaultNoteWidth: 'normal',
|
||||
onSetNoteWidth: vi.fn(),
|
||||
onSetDefaultNoteWidth: vi.fn(),
|
||||
onToggleAIChat: vi.fn(),
|
||||
onOpenVault: vi.fn(),
|
||||
activeNoteModified: false,
|
||||
@@ -343,20 +345,20 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'toggle-raw-editor')?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes a command palette action for the note layout preference', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
const config = makeConfig({ noteLayout: 'centered', onToggleNoteLayout })
|
||||
it('exposes command palette actions for note width modes', () => {
|
||||
const onSetNoteWidth = vi.fn()
|
||||
const config = makeConfig({ noteWidth: 'normal', onSetNoteWidth })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'toggle-note-layout')
|
||||
const cmd = findCommand(result.current, 'set-note-width-wide')
|
||||
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
expect(cmd!.label).toBe('Use Left-Aligned Note Layout')
|
||||
expect(cmd!.label).toBe('Use Wide Note Width')
|
||||
expect(cmd!.keywords).toContain('wide')
|
||||
|
||||
cmd!.execute()
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
expect(onSetNoteWidth).toHaveBeenCalledWith('wide')
|
||||
})
|
||||
|
||||
it('exposes command palette actions for moving the selected saved view', () => {
|
||||
@@ -406,11 +408,28 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('updates note layout command copy when left alignment is active', () => {
|
||||
const config = makeConfig({ noteLayout: 'left' })
|
||||
it('disables the command for the active note width mode', () => {
|
||||
const config = makeConfig({ noteWidth: 'wide' })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
|
||||
expect(findCommand(result.current, 'toggle-note-layout')?.label).toBe('Use Centered Note Layout')
|
||||
expect(findCommand(result.current, 'set-note-width-wide')?.enabled).toBe(false)
|
||||
expect(findCommand(result.current, 'set-note-width-normal')?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes command palette actions for the default note width', () => {
|
||||
const onSetDefaultNoteWidth = vi.fn()
|
||||
const config = makeConfig({ defaultNoteWidth: 'normal', onSetDefaultNoteWidth })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-default-note-width-wide')
|
||||
|
||||
expect(cmd).toMatchObject({
|
||||
label: 'Use Wide Note Width by Default',
|
||||
group: 'View',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
cmd!.execute()
|
||||
expect(onSetDefaultNoteWidth).toHaveBeenCalledWith('wide')
|
||||
})
|
||||
|
||||
it('exposes command palette actions for light and dark mode', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
|
||||
import type { ThemeMode } from '../lib/themeMode'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import type { NoteLayout, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { buildNavigationCommands } from './commands/navigationCommands'
|
||||
@@ -92,8 +92,10 @@ interface CommandRegistryConfig {
|
||||
canMoveSelectedViewDown?: boolean
|
||||
onFindInNote?: () => void
|
||||
onReplaceInNote?: () => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
defaultNoteWidth?: NoteWidthMode
|
||||
onSetNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onSetDefaultNoteWidth?: (mode: NoteWidthMode) => void
|
||||
onToggleAIChat?: () => void
|
||||
activeNoteModified: boolean
|
||||
onCheckForUpdates?: () => void
|
||||
@@ -125,7 +127,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote,
|
||||
noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
@@ -220,12 +223,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
|
||||
const viewCommands = useMemo(() => buildViewCommands({
|
||||
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
|
||||
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
}), [
|
||||
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
|
||||
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
|
||||
onToggleDiff, onToggleRawEditor, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat,
|
||||
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
|
||||
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
165
src/hooks/useNoteWidthMode.ts
Normal file
165
src/hooks/useNoteWidthMode.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { NoteWidthMode, Settings, VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { canPersistNoteWidthMode, resolveNoteWidthMode, toggleNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
type VaultPath = VaultEntry['path']
|
||||
type MarkdownContent = string
|
||||
type ToastMessage = string | null
|
||||
|
||||
interface EditorTab {
|
||||
entry: VaultEntry
|
||||
content: MarkdownContent
|
||||
}
|
||||
|
||||
interface ReadWidthContentRequest {
|
||||
path: VaultPath
|
||||
fallbackContent: MarkdownContent
|
||||
}
|
||||
|
||||
interface ActiveTabRequest {
|
||||
tabs: EditorTab[]
|
||||
activeTabPath: VaultPath | null
|
||||
}
|
||||
|
||||
interface CurrentWidthRequest {
|
||||
activeTab: EditorTab | null
|
||||
defaultNoteWidth: NoteWidthMode
|
||||
transientNoteWidths: Record<VaultPath, NoteWidthMode>
|
||||
}
|
||||
|
||||
interface UseNoteWidthModeOptions {
|
||||
tabs: EditorTab[]
|
||||
activeTabPath: VaultPath | null
|
||||
settings: Settings
|
||||
saveSettings: (settings: Settings) => Promise<void>
|
||||
updateFrontmatter: (
|
||||
path: VaultPath,
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
options?: FrontmatterOpOptions,
|
||||
) => Promise<void>
|
||||
setToastMessage: (message: ToastMessage) => void
|
||||
}
|
||||
|
||||
interface PersistWidthRequest {
|
||||
activeTab: EditorTab | null
|
||||
mode: NoteWidthMode
|
||||
updateFrontmatter: UseNoteWidthModeOptions['updateFrontmatter']
|
||||
rememberTransientWidth: (path: VaultPath, mode: NoteWidthMode) => void
|
||||
setToastMessage: (message: ToastMessage) => void
|
||||
}
|
||||
|
||||
function resolveActiveTab({ tabs, activeTabPath }: ActiveTabRequest): EditorTab | null {
|
||||
return tabs.find((tab) => tab.entry.path === activeTabPath) ?? null
|
||||
}
|
||||
|
||||
function resolveCurrentWidth({
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
transientNoteWidths,
|
||||
}: CurrentWidthRequest): NoteWidthMode {
|
||||
const path = activeTab?.entry.path
|
||||
if (!path) return defaultNoteWidth
|
||||
return resolveNoteWidthMode(transientNoteWidths[path] ?? activeTab.entry.noteWidth, defaultNoteWidth)
|
||||
}
|
||||
|
||||
async function readNoteContentForWidthPersistence({
|
||||
path,
|
||||
fallbackContent,
|
||||
}: ReadWidthContentRequest): Promise<MarkdownContent> {
|
||||
try {
|
||||
return isTauri()
|
||||
? await invoke<MarkdownContent>('get_note_content', { path })
|
||||
: await mockInvoke<MarkdownContent>('get_note_content', { path })
|
||||
} catch {
|
||||
return fallbackContent
|
||||
}
|
||||
}
|
||||
|
||||
async function persistOrRememberNoteWidth({
|
||||
activeTab,
|
||||
mode,
|
||||
updateFrontmatter,
|
||||
rememberTransientWidth,
|
||||
setToastMessage,
|
||||
}: PersistWidthRequest): Promise<void> {
|
||||
const path = activeTab?.entry.path
|
||||
if (!path) return
|
||||
|
||||
const persistedContent = await readNoteContentForWidthPersistence({
|
||||
path,
|
||||
fallbackContent: activeTab.content,
|
||||
})
|
||||
if (!canPersistNoteWidthMode(persistedContent)) {
|
||||
rememberTransientWidth(path, mode)
|
||||
trackEvent('note_width_mode_changed', { mode, scope: 'transient' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateFrontmatter(path, '_width', mode, { silent: true })
|
||||
rememberTransientWidth(path, mode)
|
||||
trackEvent('note_width_mode_changed', { mode, scope: 'note' })
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to update note width: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNoteWidthMode({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
settings,
|
||||
saveSettings,
|
||||
updateFrontmatter,
|
||||
setToastMessage,
|
||||
}: UseNoteWidthModeOptions) {
|
||||
const [transientNoteWidths, setTransientNoteWidths] = useState<Record<VaultPath, NoteWidthMode>>({})
|
||||
const activeTab = useMemo(
|
||||
() => resolveActiveTab({ tabs, activeTabPath }),
|
||||
[activeTabPath, tabs],
|
||||
)
|
||||
const defaultNoteWidth = useMemo(
|
||||
() => resolveNoteWidthMode(settings.note_width_mode, null),
|
||||
[settings.note_width_mode],
|
||||
)
|
||||
const noteWidth = useMemo(() => {
|
||||
return resolveCurrentWidth({ activeTab, defaultNoteWidth, transientNoteWidths })
|
||||
}, [activeTab, defaultNoteWidth, transientNoteWidths])
|
||||
|
||||
const rememberTransientWidth = useCallback((path: VaultPath, mode: NoteWidthMode) => {
|
||||
setTransientNoteWidths((previous) => (
|
||||
previous[path] === mode ? previous : { ...previous, [path]: mode }
|
||||
))
|
||||
}, [])
|
||||
|
||||
const setNoteWidth = useCallback((mode: NoteWidthMode) => persistOrRememberNoteWidth({
|
||||
activeTab,
|
||||
mode,
|
||||
updateFrontmatter,
|
||||
rememberTransientWidth,
|
||||
setToastMessage,
|
||||
}), [activeTab, rememberTransientWidth, setToastMessage, updateFrontmatter])
|
||||
|
||||
const toggleNoteWidth = useCallback(() => {
|
||||
void setNoteWidth(toggleNoteWidthMode(noteWidth))
|
||||
}, [noteWidth, setNoteWidth])
|
||||
|
||||
const setDefaultNoteWidth = useCallback(async (mode: NoteWidthMode) => {
|
||||
await saveSettings({ ...settings, note_width_mode: mode })
|
||||
trackEvent('note_width_default_changed', { mode })
|
||||
}, [saveSettings, settings])
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
noteWidth,
|
||||
setDefaultNoteWidth,
|
||||
setNoteWidth,
|
||||
toggleNoteWidth,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const defaultSettings: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -38,6 +39,7 @@ const savedSettings: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -88,6 +90,7 @@ function changedSettings(): Settings {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: 'zh-CN',
|
||||
note_width_mode: 'wide',
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: false,
|
||||
}
|
||||
@@ -150,6 +153,16 @@ describe('useSettings', () => {
|
||||
expect(settings.ui_language).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes unsupported note width modes on load', async () => {
|
||||
mockSettingsStore = {
|
||||
...savedSettings,
|
||||
note_width_mode: 'expanded' as Settings['note_width_mode'],
|
||||
}
|
||||
|
||||
const settings = await renderLoadedSettings()
|
||||
expect(settings.note_width_mode).toBeNull()
|
||||
})
|
||||
|
||||
it('saves settings via backend', async () => {
|
||||
const { result } = renderHook(() => useSettings())
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { serializeUiLanguagePreference } from '../lib/i18n'
|
||||
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
|
||||
import { normalizeThemeMode } from '../lib/themeMode'
|
||||
import type { Settings } from '../types'
|
||||
import { normalizeNoteWidthMode } from '../utils/noteWidth'
|
||||
|
||||
async function invokeNativeIfAvailable<T>(command: string, tauriArgs: Record<string, unknown>): Promise<T | undefined> {
|
||||
try {
|
||||
@@ -43,6 +44,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
}
|
||||
@@ -55,6 +57,7 @@ function normalizeSettings(settings: Settings): Settings {
|
||||
),
|
||||
theme_mode: normalizeThemeMode(settings.theme_mode),
|
||||
ui_language: serializeUiLanguagePreference(settings.ui_language),
|
||||
note_width_mode: normalizeNoteWidthMode(settings.note_width_mode),
|
||||
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
|
||||
hide_gitignored_files: settings.hide_gitignored_files ?? null,
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Eigenschaften-Panel ein-/ausblenden",
|
||||
"command.view.toggleDiff": "Diff-Modus umschalten",
|
||||
"command.view.toggleRaw": "Raw-Editor umschalten",
|
||||
"command.view.noteWidthNormal": "Normale Notizenbreite verwenden",
|
||||
"command.view.noteWidthWide": "Breite Notizenbreite verwenden",
|
||||
"command.view.defaultNoteWidthNormal": "Standardmäßig normale Notenbreite verwenden",
|
||||
"command.view.defaultNoteWidthWide": "Standardmäßig breite Notenbreite verwenden",
|
||||
"command.view.leftLayout": "Linksbündiges Notizenlayout verwenden",
|
||||
"command.view.centerLayout": "Zentriertes Notizen-Layout verwenden",
|
||||
"command.view.toggleAiPanel": "KI-Panel umschalten",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Alle",
|
||||
"editor.toolbar.rawReturn": "Zum Editor zurückkehren",
|
||||
"editor.toolbar.rawOpen": "Raw-Editor öffnen",
|
||||
"editor.toolbar.noteWidthNormal": "Zur normalen Notenbreite wechseln",
|
||||
"editor.toolbar.noteWidthWide": "Zur breiten Notenbreite wechseln",
|
||||
"editor.toolbar.centerLayout": "Zum zentrierten Notizen-Layout wechseln",
|
||||
"editor.toolbar.leftLayout": "Zum linksbündigen Notizen-Layout wechseln",
|
||||
"editor.toolbar.removeFavorite": "Aus Favoriten entfernen",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Toggle Properties Panel",
|
||||
"command.view.toggleDiff": "Toggle Diff Mode",
|
||||
"command.view.toggleRaw": "Toggle Raw Editor",
|
||||
"command.view.noteWidthNormal": "Use Normal Note Width",
|
||||
"command.view.noteWidthWide": "Use Wide Note Width",
|
||||
"command.view.defaultNoteWidthNormal": "Use Normal Note Width by Default",
|
||||
"command.view.defaultNoteWidthWide": "Use Wide Note Width by Default",
|
||||
"command.view.leftLayout": "Use Left-Aligned Note Layout",
|
||||
"command.view.centerLayout": "Use Centered Note Layout",
|
||||
"command.view.toggleAiPanel": "Toggle AI Panel",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "All",
|
||||
"editor.toolbar.rawReturn": "Return to the editor",
|
||||
"editor.toolbar.rawOpen": "Open the raw editor",
|
||||
"editor.toolbar.noteWidthNormal": "Switch to normal note width",
|
||||
"editor.toolbar.noteWidthWide": "Switch to wide note width",
|
||||
"editor.toolbar.centerLayout": "Switch to centered note layout",
|
||||
"editor.toolbar.leftLayout": "Switch to left-aligned note layout",
|
||||
"editor.toolbar.removeFavorite": "Remove from favorites",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
|
||||
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
|
||||
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
|
||||
"command.view.noteWidthNormal": "Usar ancho de nota normal",
|
||||
"command.view.noteWidthWide": "Usar ancho de nota amplio",
|
||||
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
|
||||
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
|
||||
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
|
||||
"command.view.centerLayout": "Usar diseño de nota centrado",
|
||||
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todo",
|
||||
"editor.toolbar.rawReturn": "Volver al editor",
|
||||
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
|
||||
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
|
||||
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
|
||||
"editor.toolbar.removeFavorite": "Eliminar de favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activar/desactivar el panel de propiedades",
|
||||
"command.view.toggleDiff": "Activar/desactivar el modo de diferencias",
|
||||
"command.view.toggleRaw": "Activar/desactivar el editor sin formato",
|
||||
"command.view.noteWidthNormal": "Usar ancho de nota normal",
|
||||
"command.view.noteWidthWide": "Usar ancho de nota amplio",
|
||||
"command.view.defaultNoteWidthNormal": "Usar el ancho de nota normal de forma predeterminada",
|
||||
"command.view.defaultNoteWidthWide": "Usar ancho de nota amplio de forma predeterminada",
|
||||
"command.view.leftLayout": "Usar diseño de nota alineado a la izquierda",
|
||||
"command.view.centerLayout": "Usar diseño de nota centrado",
|
||||
"command.view.toggleAiPanel": "Activar/desactivar el panel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todo",
|
||||
"editor.toolbar.rawReturn": "Volver al editor",
|
||||
"editor.toolbar.rawOpen": "Abrir el editor sin formato",
|
||||
"editor.toolbar.noteWidthNormal": "Cambiar a ancho de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Cambiar a ancho de nota amplio",
|
||||
"editor.toolbar.centerLayout": "Cambiar al diseño de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Cambiar al diseño de nota alineado a la izquierda",
|
||||
"editor.toolbar.removeFavorite": "Eliminar de favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Activer/désactiver le panneau des propriétés",
|
||||
"command.view.toggleDiff": "Activer/désactiver le mode Diff",
|
||||
"command.view.toggleRaw": "Activer/désactiver l'éditeur Raw",
|
||||
"command.view.noteWidthNormal": "Utiliser la largeur de note normale",
|
||||
"command.view.noteWidthWide": "Utiliser la largeur de note large",
|
||||
"command.view.defaultNoteWidthNormal": "Utiliser la largeur de note normale par défaut",
|
||||
"command.view.defaultNoteWidthWide": "Utiliser la largeur de note large par défaut",
|
||||
"command.view.leftLayout": "Utiliser la mise en page des notes alignée à gauche",
|
||||
"command.view.centerLayout": "Utiliser la mise en page centrée pour les notes",
|
||||
"command.view.toggleAiPanel": "Activer/désactiver le panneau d'IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Tout",
|
||||
"editor.toolbar.rawReturn": "Retourner à l'éditeur",
|
||||
"editor.toolbar.rawOpen": "Ouvrir l'éditeur Raw",
|
||||
"editor.toolbar.noteWidthNormal": "Passer à la largeur de note normale",
|
||||
"editor.toolbar.noteWidthWide": "Passer à la largeur de note large",
|
||||
"editor.toolbar.centerLayout": "Passer à la mise en page centrée de la note",
|
||||
"editor.toolbar.leftLayout": "Passer à mise en page de la note en alignement à gauche",
|
||||
"editor.toolbar.removeFavorite": "Supprimer des favoris",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Attiva/disattiva pannello Proprietà",
|
||||
"command.view.toggleDiff": "Attiva/disattiva modalità Diff",
|
||||
"command.view.toggleRaw": "Attiva/disattiva editor RAW",
|
||||
"command.view.noteWidthNormal": "Usa larghezza normale per le note",
|
||||
"command.view.noteWidthWide": "Usa larghezza nota ampia",
|
||||
"command.view.defaultNoteWidthNormal": "Usa larghezza normale delle note per impostazione predefinita",
|
||||
"command.view.defaultNoteWidthWide": "Usa larghezza nota ampia per impostazione predefinita",
|
||||
"command.view.leftLayout": "Usa layout delle note allineato a sinistra",
|
||||
"command.view.centerLayout": "Usa layout delle note centrato",
|
||||
"command.view.toggleAiPanel": "Attiva/disattiva pannello IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Tutti",
|
||||
"editor.toolbar.rawReturn": "Torna all'editor",
|
||||
"editor.toolbar.rawOpen": "Apri l'editor raw",
|
||||
"editor.toolbar.noteWidthNormal": "Passa alla larghezza normale delle note",
|
||||
"editor.toolbar.noteWidthWide": "Passa alla larghezza delle note ampia",
|
||||
"editor.toolbar.centerLayout": "Passa al layout della nota centrato",
|
||||
"editor.toolbar.leftLayout": "Passa al layout della nota allineato a sinistra",
|
||||
"editor.toolbar.removeFavorite": "Rimuovi dai preferiti",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "プロパティパネルの切り替え",
|
||||
"command.view.toggleDiff": "差分モードの切り替え",
|
||||
"command.view.toggleRaw": "Rawエディターの切り替え",
|
||||
"command.view.noteWidthNormal": "通常のノート幅を使用",
|
||||
"command.view.noteWidthWide": "広いノート幅を使用",
|
||||
"command.view.defaultNoteWidthNormal": "デフォルトで通常のノート幅を使用",
|
||||
"command.view.defaultNoteWidthWide": "デフォルトで広いノート幅を使用",
|
||||
"command.view.leftLayout": "左揃えのノートレイアウトを使用",
|
||||
"command.view.centerLayout": "メモの中央揃えレイアウトを使用",
|
||||
"command.view.toggleAiPanel": "AIパネルの切り替え",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "すべて",
|
||||
"editor.toolbar.rawReturn": "エディターに戻る",
|
||||
"editor.toolbar.rawOpen": "RAWエディターを開く",
|
||||
"editor.toolbar.noteWidthNormal": "通常のノート幅に切り替える",
|
||||
"editor.toolbar.noteWidthWide": "ワイドノート幅に切り替える",
|
||||
"editor.toolbar.centerLayout": "ノートの中央揃えレイアウトに切り替える",
|
||||
"editor.toolbar.leftLayout": "左揃えのノートレイアウトに切り替える",
|
||||
"editor.toolbar.removeFavorite": "お気に入りから削除",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "속성 패널 켜기/끄기",
|
||||
"command.view.toggleDiff": "차이 모드 전환",
|
||||
"command.view.toggleRaw": "Raw Editor 켜기/끄기",
|
||||
"command.view.noteWidthNormal": "일반 노트 너비 사용",
|
||||
"command.view.noteWidthWide": "넓은 노트 너비 사용",
|
||||
"command.view.defaultNoteWidthNormal": "기본적으로 일반 노트 너비 사용",
|
||||
"command.view.defaultNoteWidthWide": "기본적으로 넓은 노트 너비 사용",
|
||||
"command.view.leftLayout": "왼쪽 정렬 노트 레이아웃 사용",
|
||||
"command.view.centerLayout": "중앙 정렬 노트 레이아웃 사용",
|
||||
"command.view.toggleAiPanel": "AI 패널 켜기/끄기",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "모두",
|
||||
"editor.toolbar.rawReturn": "편집기로 돌아가기",
|
||||
"editor.toolbar.rawOpen": "Raw 편집기 열기",
|
||||
"editor.toolbar.noteWidthNormal": "일반 노트 너비로 전환",
|
||||
"editor.toolbar.noteWidthWide": "넓은 노트 너비로 전환",
|
||||
"editor.toolbar.centerLayout": "노트 중앙 정렬 레이아웃으로 전환",
|
||||
"editor.toolbar.leftLayout": "왼쪽 정렬 노트 레이아웃으로 전환",
|
||||
"editor.toolbar.removeFavorite": "즐겨찾기에서 삭제",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
|
||||
"command.view.toggleDiff": "Ativar/desativar modo de comparação",
|
||||
"command.view.toggleRaw": "Ativar/desativar editor Raw",
|
||||
"command.view.noteWidthNormal": "Usar largura normal de nota",
|
||||
"command.view.noteWidthWide": "Usar largura de nota ampla",
|
||||
"command.view.defaultNoteWidthNormal": "Usar largura de nota normal por padrão",
|
||||
"command.view.defaultNoteWidthWide": "Usar largura de nota ampla por padrão",
|
||||
"command.view.leftLayout": "Usar layout de nota alinhado à esquerda",
|
||||
"command.view.centerLayout": "Usar layout de nota centralizado",
|
||||
"command.view.toggleAiPanel": "Alternar painel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todas",
|
||||
"editor.toolbar.rawReturn": "Voltar ao editor",
|
||||
"editor.toolbar.rawOpen": "Abrir o editor Raw",
|
||||
"editor.toolbar.noteWidthNormal": "Alternar para largura de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Mudar para largura de nota ampla",
|
||||
"editor.toolbar.centerLayout": "Alternar para o layout de nota centralizado",
|
||||
"editor.toolbar.leftLayout": "Alternar para o layout de nota alinhado à esquerda",
|
||||
"editor.toolbar.removeFavorite": "Remover dos favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Ativar/desativar painel de propriedades",
|
||||
"command.view.toggleDiff": "Ativar/desativar modo de diferenças",
|
||||
"command.view.toggleRaw": "Ativar/desativar editor RAW",
|
||||
"command.view.noteWidthNormal": "Utilizar largura de nota normal",
|
||||
"command.view.noteWidthWide": "Utilizar largura de nota ampla",
|
||||
"command.view.defaultNoteWidthNormal": "Utilizar largura de nota normal por predefinição",
|
||||
"command.view.defaultNoteWidthWide": "Utilizar largura de nota ampla por predefinição",
|
||||
"command.view.leftLayout": "Utilizar layout de nota alinhado à esquerda",
|
||||
"command.view.centerLayout": "Utilizar layout de nota centrado",
|
||||
"command.view.toggleAiPanel": "Ativar/desativar painel de IA",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Todas",
|
||||
"editor.toolbar.rawReturn": "Voltar ao editor",
|
||||
"editor.toolbar.rawOpen": "Abrir o editor RAW",
|
||||
"editor.toolbar.noteWidthNormal": "Mudar para largura de nota normal",
|
||||
"editor.toolbar.noteWidthWide": "Mudar para largura de nota larga",
|
||||
"editor.toolbar.centerLayout": "Mudar para o layout de nota centrado",
|
||||
"editor.toolbar.leftLayout": "Mudar para o layout de nota alinhado à esquerda",
|
||||
"editor.toolbar.removeFavorite": "Remover dos favoritos",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "Переключить панель свойств",
|
||||
"command.view.toggleDiff": "Переключить режим сравнения",
|
||||
"command.view.toggleRaw": "Переключить редактор Raw",
|
||||
"command.view.noteWidthNormal": "Использовать обычную ширину заметки",
|
||||
"command.view.noteWidthWide": "Использовать широкую ширину заметки",
|
||||
"command.view.defaultNoteWidthNormal": "По умолчанию использовать нормальную ширину заметки",
|
||||
"command.view.defaultNoteWidthWide": "По умолчанию использовать широкую ширину нот",
|
||||
"command.view.leftLayout": "Использовать левостороннее расположение заметок",
|
||||
"command.view.centerLayout": "Использовать центрированный макет заметки",
|
||||
"command.view.toggleAiPanel": "Переключить панель ИИ",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "Все",
|
||||
"editor.toolbar.rawReturn": "Вернуться в редактор",
|
||||
"editor.toolbar.rawOpen": "Открыть RAW-редактор",
|
||||
"editor.toolbar.noteWidthNormal": "Переключиться на обычную ширину нот",
|
||||
"editor.toolbar.noteWidthWide": "Переключиться на широкую ширину нот",
|
||||
"editor.toolbar.centerLayout": "Переключиться на центрированный макет заметки",
|
||||
"editor.toolbar.leftLayout": "Переключиться на макет заметки с выравниванием по левому краю",
|
||||
"editor.toolbar.removeFavorite": "Удалить из избранного",
|
||||
|
||||
@@ -64,6 +64,10 @@
|
||||
"command.view.toggleProperties": "切换属性面板",
|
||||
"command.view.toggleDiff": "切换差异模式",
|
||||
"command.view.toggleRaw": "切换源码编辑器",
|
||||
"command.view.noteWidthNormal": "使用普通笔记宽度",
|
||||
"command.view.noteWidthWide": "使用宽笔记宽度",
|
||||
"command.view.defaultNoteWidthNormal": "默认使用普通笔记宽度",
|
||||
"command.view.defaultNoteWidthWide": "默认使用宽笔记宽度",
|
||||
"command.view.leftLayout": "使用左对齐笔记布局",
|
||||
"command.view.centerLayout": "使用居中笔记布局",
|
||||
"command.view.toggleAiPanel": "切换 AI 面板",
|
||||
@@ -314,6 +318,8 @@
|
||||
"editor.find.replaceAll": "全部",
|
||||
"editor.toolbar.rawReturn": "返回编辑器",
|
||||
"editor.toolbar.rawOpen": "打开源码编辑器",
|
||||
"editor.toolbar.noteWidthNormal": "切换为普通笔记宽度",
|
||||
"editor.toolbar.noteWidthWide": "切换为宽笔记宽度",
|
||||
"editor.toolbar.centerLayout": "切换到居中笔记布局",
|
||||
"editor.toolbar.leftLayout": "切换到左对齐笔记布局",
|
||||
"editor.toolbar.removeFavorite": "从收藏中移除",
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface VaultEntry {
|
||||
sort: string | null
|
||||
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
|
||||
view: string | null
|
||||
/** Rich-editor note width mode from `_width` frontmatter. null means use the default. */
|
||||
noteWidth?: NoteWidthMode | null
|
||||
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
|
||||
visible: boolean | null
|
||||
/** Whether this note has been explicitly organized (removed from Inbox). */
|
||||
@@ -94,6 +96,7 @@ export interface Settings {
|
||||
release_channel: string | null
|
||||
theme_mode?: ThemeMode | null
|
||||
ui_language?: AppLocale | null
|
||||
note_width_mode?: NoteWidthMode | null
|
||||
initial_h1_auto_rename_enabled?: boolean | null
|
||||
default_ai_agent?: AiAgentId | null
|
||||
hide_gitignored_files?: boolean | null
|
||||
@@ -156,6 +159,8 @@ export interface AllNotesConfig {
|
||||
/** Vault-scoped UI configuration stored locally per vault path. */
|
||||
export type NoteLayout = 'centered' | 'left'
|
||||
|
||||
export type NoteWidthMode = 'normal' | 'wide'
|
||||
|
||||
/** Vault-scoped UI configuration stored locally per vault path. */
|
||||
export interface VaultConfig {
|
||||
zoom: number | null
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
33
src/utils/noteWidth.test.ts
Normal file
33
src/utils/noteWidth.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canPersistNoteWidthMode,
|
||||
normalizeNoteWidthMode,
|
||||
resolveNoteWidthMode,
|
||||
toggleNoteWidthMode,
|
||||
} from './noteWidth'
|
||||
|
||||
describe('noteWidth', () => {
|
||||
it('normalizes supported width modes', () => {
|
||||
expect(normalizeNoteWidthMode(' wide ')).toBe('wide')
|
||||
expect(normalizeNoteWidthMode('NORMAL')).toBe('normal')
|
||||
expect(normalizeNoteWidthMode('expanded')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves note override before the default', () => {
|
||||
expect(resolveNoteWidthMode('wide', 'normal')).toBe('wide')
|
||||
expect(resolveNoteWidthMode(null, 'wide')).toBe('wide')
|
||||
expect(resolveNoteWidthMode(null, 'expanded')).toBe('normal')
|
||||
})
|
||||
|
||||
it('toggles between normal and wide', () => {
|
||||
expect(toggleNoteWidthMode('normal')).toBe('wide')
|
||||
expect(toggleNoteWidthMode('wide')).toBe('normal')
|
||||
})
|
||||
|
||||
it('only persists width when frontmatter already exists', () => {
|
||||
expect(canPersistNoteWidthMode('---\ntype: Note\n---\n# Note')).toBe(true)
|
||||
expect(canPersistNoteWidthMode('---\n---\n# Note')).toBe(true)
|
||||
expect(canPersistNoteWidthMode('# Note')).toBe(false)
|
||||
expect(canPersistNoteWidthMode('---\nnot yaml\n---\n# Note')).toBe(false)
|
||||
})
|
||||
})
|
||||
26
src/utils/noteWidth.ts
Normal file
26
src/utils/noteWidth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { NoteWidthMode } from '../types'
|
||||
import { detectFrontmatterState } from './frontmatter'
|
||||
|
||||
export const DEFAULT_NOTE_WIDTH_MODE: NoteWidthMode = 'normal'
|
||||
|
||||
export function normalizeNoteWidthMode(value: unknown): NoteWidthMode | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized === 'normal' || normalized === 'wide' ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveNoteWidthMode(noteWidth: unknown, defaultWidth: unknown): NoteWidthMode {
|
||||
return normalizeNoteWidthMode(noteWidth)
|
||||
?? normalizeNoteWidthMode(defaultWidth)
|
||||
?? DEFAULT_NOTE_WIDTH_MODE
|
||||
}
|
||||
|
||||
export function toggleNoteWidthMode(width: unknown): NoteWidthMode {
|
||||
return resolveNoteWidthMode(width, DEFAULT_NOTE_WIDTH_MODE) === 'wide' ? 'normal' : 'wide'
|
||||
}
|
||||
|
||||
export function canPersistNoteWidthMode(content: string | null): boolean {
|
||||
const frontmatterState = detectFrontmatterState(content)
|
||||
return frontmatterState === 'valid' || frontmatterState === 'empty'
|
||||
}
|
||||
@@ -3,6 +3,7 @@ const SYSTEM_METADATA_ALIAS_GROUPS = {
|
||||
_order: ['_order', 'order'],
|
||||
_sidebar_label: ['_sidebar_label', 'sidebar_label', 'sidebar label'],
|
||||
_sort: ['_sort', 'sort'],
|
||||
_width: ['_width', 'width'],
|
||||
} as const
|
||||
|
||||
const CANONICAL_SYSTEM_METADATA_KEYS = Object.keys(SYSTEM_METADATA_ALIAS_GROUPS)
|
||||
|
||||
101
tests/smoke/long-note-editor-scaling.spec.ts
Normal file
101
tests/smoke/long-note-editor-scaling.spec.ts
Normal 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())
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
54
tests/smoke/note-width-modes.spec.ts
Normal file
54
tests/smoke/note-width-modes.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { executeCommand, openCommandPalette } from './helpers'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function alphaProjectPath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'project', 'alpha-project.md')
|
||||
}
|
||||
|
||||
function plainNotePath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'plain-width-note.md')
|
||||
}
|
||||
|
||||
async function openNote(page: Page, title: string) {
|
||||
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function executePaletteCommand(page: Page, label: string) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, label)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
fs.writeFileSync(plainNotePath(tempVaultDir), '# Plain Width Note\n\nNo frontmatter here.\n')
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('note width modes persist only when frontmatter already exists', async ({ page }) => {
|
||||
await openNote(page, 'Alpha Project')
|
||||
|
||||
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
|
||||
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?wide"?/)
|
||||
|
||||
await executePaletteCommand(page, 'Use Normal Note Width')
|
||||
await expect(page.locator('.editor-content-width--normal')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(() => fs.readFileSync(alphaProjectPath(tempVaultDir), 'utf8')).toMatch(/_width:\s+"?normal"?/)
|
||||
|
||||
await openNote(page, 'Plain Width Note')
|
||||
await page.getByRole('button', { name: 'Switch to wide note width' }).click()
|
||||
await expect(page.locator('.editor-content-width--wide')).toBeVisible({ timeout: 5_000 })
|
||||
expect(fs.readFileSync(plainNotePath(tempVaultDir), 'utf8')).toBe('# Plain Width Note\n\nNo frontmatter here.\n')
|
||||
})
|
||||
Reference in New Issue
Block a user