diff --git a/Cargo.lock b/Cargo.lock
index 9ca6b4f..483bbb5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12113,6 +12113,7 @@ dependencies = [
"wasm-bindgen-futures",
"weaver-api",
"weaver-common",
+ "weaver-editor-browser",
"weaver-editor-core",
"weaver-renderer",
"web-sys",
diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml
index d242b8e..a953ca7 100644
--- a/crates/weaver-app/Cargo.toml
+++ b/crates/weaver-app/Cargo.toml
@@ -49,6 +49,7 @@ dashmap = "6.1.0"
dioxus = { version = "0.7.1", features = ["router"] }
weaver-common = { path = "../weaver-common" }
weaver-editor-core = { path = "../weaver-editor-core" }
+weaver-editor-browser = { path = "../weaver-editor-browser" }
jacquard = { workspace = true}#, features = ["streaming"] }
jacquard-lexicon = { workspace = true }
jacquard-identity = { workspace = true }
diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs
index 072502d..a08165a 100644
--- a/crates/weaver-app/src/components/editor/actions.rs
+++ b/crates/weaver-app/src/components/editor/actions.rs
@@ -1,747 +1,132 @@
//! Editor actions and keybinding system.
//!
-//! This module defines all editor operations as an enum, providing a clean
-//! abstraction layer between input events and document mutations. This enables:
-//!
-//! - Configurable keybindings (user can remap shortcuts)
-//! - Platform-specific defaults (Cmd vs Ctrl, etc.)
-//! - Consistent action handling regardless of input source
-//! - Potential for command palette, macros, etc.
-
-use std::collections::HashMap;
+//! This module re-exports core types and provides Dioxus-specific conversions
+//! and the concrete execute_action implementation for EditorDocument.
use dioxus::prelude::*;
-use jacquard::smol_str::SmolStr;
use super::document::EditorDocument;
use super::platform::Platform;
-/// A range in the document, measured in character offsets.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Range {
- pub start: usize,
- pub end: usize,
-}
-
-impl Range {
- pub fn new(start: usize, end: usize) -> Self {
- Self { start, end }
- }
-
- pub fn caret(offset: usize) -> Self {
- Self {
- start: offset,
- end: offset,
- }
- }
-
- pub fn is_caret(&self) -> bool {
- self.start == self.end
- }
-
- pub fn len(&self) -> usize {
- self.end.saturating_sub(self.start)
- }
-
- #[allow(dead_code)]
- pub fn is_empty(&self) -> bool {
- self.len() == 0
- }
-
- /// Normalize range so start <= end.
- pub fn normalize(self) -> Self {
- if self.start <= self.end {
- self
- } else {
- Self {
- start: self.end,
- end: self.start,
- }
- }
- }
-}
-
-/// All possible editor actions.
-///
-/// These represent semantic operations on the document, decoupled from
-/// how they're triggered (keyboard, mouse, touch, voice, etc.).
-#[derive(Debug, Clone, PartialEq)]
-#[allow(dead_code)]
-pub enum EditorAction {
- // === Text Insertion ===
- /// Insert text at the given range (replacing any selected content).
- Insert { text: String, range: Range },
-
- /// Insert a soft line break (Shift+Enter, `
` equivalent).
- InsertLineBreak { range: Range },
-
- /// Insert a paragraph break (Enter).
- InsertParagraph { range: Range },
-
- // === Deletion ===
- /// Delete content backward (Backspace).
- DeleteBackward { range: Range },
-
- /// Delete content forward (Delete key).
- DeleteForward { range: Range },
-
- /// Delete word backward (Ctrl/Alt+Backspace).
- DeleteWordBackward { range: Range },
-
- /// Delete word forward (Ctrl/Alt+Delete).
- DeleteWordForward { range: Range },
-
- /// Delete to start of line (Cmd+Backspace on Mac).
- DeleteToLineStart { range: Range },
-
- /// Delete to end of line (Cmd+Delete on Mac).
- DeleteToLineEnd { range: Range },
-
- /// Delete to start of soft line (visual line in wrapped text).
- DeleteSoftLineBackward { range: Range },
-
- /// Delete to end of soft line.
- DeleteSoftLineForward { range: Range },
-
- // === History ===
- /// Undo the last change.
- Undo,
-
- /// Redo the last undone change.
- Redo,
-
- // === Formatting ===
- /// Toggle bold on selection.
- ToggleBold,
-
- /// Toggle italic on selection.
- ToggleItalic,
-
- /// Toggle inline code on selection.
- ToggleCode,
-
- /// Toggle strikethrough on selection.
- ToggleStrikethrough,
-
- /// Insert/wrap with link.
- InsertLink,
-
- // === Clipboard ===
- /// Cut selection to clipboard.
- Cut,
-
- /// Copy selection to clipboard.
- Copy,
-
- /// Paste from clipboard at range.
- Paste { range: Range },
-
- /// Copy selection as rendered HTML.
- CopyAsHtml,
-
- // === Selection ===
- /// Select all content.
- SelectAll,
-
- // === Navigation (for command palette / programmatic use) ===
- /// Move cursor to position.
- MoveCursor { offset: usize },
-
- /// Extend selection to position.
- ExtendSelection { offset: usize },
-}
-
-impl EditorAction {
- /// Update the range in actions that use one.
- /// Actions without a range are returned unchanged.
- pub fn with_range(self, range: Range) -> Self {
- match self {
- Self::Insert { text, .. } => Self::Insert { text, range },
- Self::InsertLineBreak { .. } => Self::InsertLineBreak { range },
- Self::InsertParagraph { .. } => Self::InsertParagraph { range },
- Self::DeleteBackward { .. } => Self::DeleteBackward { range },
- Self::DeleteForward { .. } => Self::DeleteForward { range },
- Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range },
- Self::DeleteWordForward { .. } => Self::DeleteWordForward { range },
- Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range },
- Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range },
- Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range },
- Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range },
- Self::Paste { .. } => Self::Paste { range },
- // Actions without range stay unchanged
- other => other,
- }
+// Re-export core types.
+pub use weaver_editor_core::{
+ EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range,
+};
+
+// === Dioxus conversion helpers ===
+
+/// Convert a dioxus keyboard_types::Key to our Key type.
+pub fn key_from_dioxus(key: dioxus::prelude::keyboard_types::Key) -> Key {
+ use dioxus::prelude::keyboard_types::Key as KT;
+
+ match key {
+ KT::Character(s) => Key::character(s.as_str()),
+ KT::Unidentified => Key::Unidentified,
+ KT::Backspace => Key::Backspace,
+ KT::Delete => Key::Delete,
+ KT::Enter => Key::Enter,
+ KT::Tab => Key::Tab,
+ KT::Escape => Key::Escape,
+ KT::Insert => Key::Insert,
+ KT::Clear => Key::Clear,
+ KT::ArrowLeft => Key::ArrowLeft,
+ KT::ArrowRight => Key::ArrowRight,
+ KT::ArrowUp => Key::ArrowUp,
+ KT::ArrowDown => Key::ArrowDown,
+ KT::Home => Key::Home,
+ KT::End => Key::End,
+ KT::PageUp => Key::PageUp,
+ KT::PageDown => Key::PageDown,
+ KT::Alt => Key::Alt,
+ KT::AltGraph => Key::AltGraph,
+ KT::CapsLock => Key::CapsLock,
+ KT::Control => Key::Control,
+ KT::Fn => Key::Fn,
+ KT::FnLock => Key::FnLock,
+ KT::Meta => Key::Meta,
+ KT::NumLock => Key::NumLock,
+ KT::ScrollLock => Key::ScrollLock,
+ KT::Shift => Key::Shift,
+ KT::Symbol => Key::Symbol,
+ KT::SymbolLock => Key::SymbolLock,
+ KT::Hyper => Key::Hyper,
+ KT::Super => Key::Super,
+ KT::F1 => Key::F1,
+ KT::F2 => Key::F2,
+ KT::F3 => Key::F3,
+ KT::F4 => Key::F4,
+ KT::F5 => Key::F5,
+ KT::F6 => Key::F6,
+ KT::F7 => Key::F7,
+ KT::F8 => Key::F8,
+ KT::F9 => Key::F9,
+ KT::F10 => Key::F10,
+ KT::F11 => Key::F11,
+ KT::F12 => Key::F12,
+ KT::F13 => Key::F13,
+ KT::F14 => Key::F14,
+ KT::F15 => Key::F15,
+ KT::F16 => Key::F16,
+ KT::F17 => Key::F17,
+ KT::F18 => Key::F18,
+ KT::F19 => Key::F19,
+ KT::F20 => Key::F20,
+ KT::ContextMenu => Key::ContextMenu,
+ KT::PrintScreen => Key::PrintScreen,
+ KT::Pause => Key::Pause,
+ KT::Help => Key::Help,
+ KT::Copy => Key::Copy,
+ KT::Cut => Key::Cut,
+ KT::Paste => Key::Paste,
+ KT::Undo => Key::Undo,
+ KT::Redo => Key::Redo,
+ KT::Find => Key::Find,
+ KT::Select => Key::Select,
+ KT::MediaPlayPause => Key::MediaPlayPause,
+ KT::MediaStop => Key::MediaStop,
+ KT::MediaTrackNext => Key::MediaTrackNext,
+ KT::MediaTrackPrevious => Key::MediaTrackPrevious,
+ KT::AudioVolumeDown => Key::AudioVolumeDown,
+ KT::AudioVolumeUp => Key::AudioVolumeUp,
+ KT::AudioVolumeMute => Key::AudioVolumeMute,
+ KT::Compose => Key::Compose,
+ KT::Convert => Key::Convert,
+ KT::NonConvert => Key::NonConvert,
+ KT::Dead => Key::Dead,
+ KT::HangulMode => Key::HangulMode,
+ KT::HanjaMode => Key::HanjaMode,
+ KT::JunjaMode => Key::JunjaMode,
+ KT::Eisu => Key::Eisu,
+ KT::Hankaku => Key::Hankaku,
+ KT::Hiragana => Key::Hiragana,
+ KT::HiraganaKatakana => Key::HiraganaKatakana,
+ KT::KanaMode => Key::KanaMode,
+ KT::KanjiMode => Key::KanjiMode,
+ KT::Katakana => Key::Katakana,
+ KT::Romaji => Key::Romaji,
+ KT::Zenkaku => Key::Zenkaku,
+ KT::ZenkakuHankaku => Key::ZenkakuHankaku,
+ _ => Key::Unidentified,
}
}
-/// Key values for keyboard input.
-///
-/// Mirrors the keyboard-types crate's Key enum structure. Character keys use
-/// SmolStr to efficiently handle both single characters and composed sequences
-/// (from dead keys, IME, etc.).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-#[allow(dead_code)]
-pub enum Key {
- /// A character key. The string corresponds to the character typed,
- /// taking into account locale, modifiers, and keyboard mapping.
- /// May be multiple characters for composed sequences.
- Character(SmolStr),
-
- /// Unknown/unidentified key.
- Unidentified,
-
- // === Whitespace / editing ===
- Backspace,
- Delete,
- Enter,
- Tab,
- Escape,
- Space,
- Insert,
- Clear,
-
- // === Navigation ===
- ArrowLeft,
- ArrowRight,
- ArrowUp,
- ArrowDown,
- Home,
- End,
- PageUp,
- PageDown,
-
- // === Modifiers ===
- Alt,
- AltGraph,
- CapsLock,
- Control,
- Fn,
- FnLock,
- Meta,
- NumLock,
- ScrollLock,
- Shift,
- Symbol,
- SymbolLock,
- Hyper,
- Super,
-
- // === Function keys ===
- F1,
- F2,
- F3,
- F4,
- F5,
- F6,
- F7,
- F8,
- F9,
- F10,
- F11,
- F12,
- F13,
- F14,
- F15,
- F16,
- F17,
- F18,
- F19,
- F20,
-
- // === UI keys ===
- ContextMenu,
- PrintScreen,
- Pause,
- Help,
-
- // === Clipboard / editing commands ===
- Copy,
- Cut,
- Paste,
- Undo,
- Redo,
- Find,
- Select,
-
- // === Media keys ===
- MediaPlayPause,
- MediaStop,
- MediaTrackNext,
- MediaTrackPrevious,
- AudioVolumeDown,
- AudioVolumeUp,
- AudioVolumeMute,
-
- // === IME / composition ===
- Compose,
- Convert,
- NonConvert,
- Dead,
-
- // === CJK IME keys ===
- HangulMode,
- HanjaMode,
- JunjaMode,
- Eisu,
- Hankaku,
- Hiragana,
- HiraganaKatakana,
- KanaMode,
- KanjiMode,
- Katakana,
- Romaji,
- Zenkaku,
- ZenkakuHankaku,
-}
-
-impl Key {
- /// Create a character key from a string.
- pub fn character(s: impl Into) -> Self {
- Self::Character(s.into())
- }
-
- /// Convert from a dioxus keyboard_types::Key.
- pub fn from_keyboard_types(key: dioxus::prelude::keyboard_types::Key) -> Self {
- use dioxus::prelude::keyboard_types::Key as KT;
-
- match key {
- KT::Character(s) => Self::Character(s.as_str().into()),
- KT::Unidentified => Self::Unidentified,
-
- // Whitespace / editing
- KT::Backspace => Self::Backspace,
- KT::Delete => Self::Delete,
- KT::Enter => Self::Enter,
- KT::Tab => Self::Tab,
- KT::Escape => Self::Escape,
- KT::Insert => Self::Insert,
- KT::Clear => Self::Clear,
-
- // Navigation
- KT::ArrowLeft => Self::ArrowLeft,
- KT::ArrowRight => Self::ArrowRight,
- KT::ArrowUp => Self::ArrowUp,
- KT::ArrowDown => Self::ArrowDown,
- KT::Home => Self::Home,
- KT::End => Self::End,
- KT::PageUp => Self::PageUp,
- KT::PageDown => Self::PageDown,
-
- // Modifiers
- KT::Alt => Self::Alt,
- KT::AltGraph => Self::AltGraph,
- KT::CapsLock => Self::CapsLock,
- KT::Control => Self::Control,
- KT::Fn => Self::Fn,
- KT::FnLock => Self::FnLock,
- KT::Meta => Self::Meta,
- KT::NumLock => Self::NumLock,
- KT::ScrollLock => Self::ScrollLock,
- KT::Shift => Self::Shift,
- KT::Symbol => Self::Symbol,
- KT::SymbolLock => Self::SymbolLock,
- KT::Hyper => Self::Hyper,
- KT::Super => Self::Super,
-
- // Function keys
- KT::F1 => Self::F1,
- KT::F2 => Self::F2,
- KT::F3 => Self::F3,
- KT::F4 => Self::F4,
- KT::F5 => Self::F5,
- KT::F6 => Self::F6,
- KT::F7 => Self::F7,
- KT::F8 => Self::F8,
- KT::F9 => Self::F9,
- KT::F10 => Self::F10,
- KT::F11 => Self::F11,
- KT::F12 => Self::F12,
- KT::F13 => Self::F13,
- KT::F14 => Self::F14,
- KT::F15 => Self::F15,
- KT::F16 => Self::F16,
- KT::F17 => Self::F17,
- KT::F18 => Self::F18,
- KT::F19 => Self::F19,
- KT::F20 => Self::F20,
-
- // UI keys
- KT::ContextMenu => Self::ContextMenu,
- KT::PrintScreen => Self::PrintScreen,
- KT::Pause => Self::Pause,
- KT::Help => Self::Help,
-
- // Clipboard / editing commands
- KT::Copy => Self::Copy,
- KT::Cut => Self::Cut,
- KT::Paste => Self::Paste,
- KT::Undo => Self::Undo,
- KT::Redo => Self::Redo,
- KT::Find => Self::Find,
- KT::Select => Self::Select,
-
- // Media keys
- KT::MediaPlayPause => Self::MediaPlayPause,
- KT::MediaStop => Self::MediaStop,
- KT::MediaTrackNext => Self::MediaTrackNext,
- KT::MediaTrackPrevious => Self::MediaTrackPrevious,
- KT::AudioVolumeDown => Self::AudioVolumeDown,
- KT::AudioVolumeUp => Self::AudioVolumeUp,
- KT::AudioVolumeMute => Self::AudioVolumeMute,
-
- // IME / composition
- KT::Compose => Self::Compose,
- KT::Convert => Self::Convert,
- KT::NonConvert => Self::NonConvert,
- KT::Dead => Self::Dead,
-
- // CJK IME keys
- KT::HangulMode => Self::HangulMode,
- KT::HanjaMode => Self::HanjaMode,
- KT::JunjaMode => Self::JunjaMode,
- KT::Eisu => Self::Eisu,
- KT::Hankaku => Self::Hankaku,
- KT::Hiragana => Self::Hiragana,
- KT::HiraganaKatakana => Self::HiraganaKatakana,
- KT::KanaMode => Self::KanaMode,
- KT::KanjiMode => Self::KanjiMode,
- KT::Katakana => Self::Katakana,
- KT::Romaji => Self::Romaji,
- KT::Zenkaku => Self::Zenkaku,
- KT::ZenkakuHankaku => Self::ZenkakuHankaku,
-
- // Everything else falls through to Unidentified
- _ => Self::Unidentified,
- }
- }
-
- /// Check if this is a navigation key that should pass through to browser.
- pub fn is_navigation(&self) -> bool {
- matches!(
- self,
- Self::ArrowLeft
- | Self::ArrowRight
- | Self::ArrowUp
- | Self::ArrowDown
- | Self::Home
- | Self::End
- | Self::PageUp
- | Self::PageDown
- )
- }
-
- /// Check if this is a modifier key.
- pub fn is_modifier(&self) -> bool {
- matches!(
- self,
- Self::Alt
- | Self::AltGraph
- | Self::CapsLock
- | Self::Control
- | Self::Fn
- | Self::FnLock
- | Self::Meta
- | Self::NumLock
- | Self::ScrollLock
- | Self::Shift
- | Self::Symbol
- | Self::SymbolLock
- | Self::Hyper
- | Self::Super
- )
- }
-}
-
-/// Modifier key state for a key combination.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
-pub struct Modifiers {
- pub ctrl: bool,
- pub alt: bool,
- pub shift: bool,
- pub meta: bool,
- pub hyper: bool,
- pub super_: bool, // `super` is a keyword
-}
-
-#[allow(dead_code)]
-impl Modifiers {
- pub const NONE: Self = Self {
- ctrl: false,
- alt: false,
- shift: false,
- meta: false,
- hyper: false,
- super_: false,
- };
- pub const CTRL: Self = Self {
- ctrl: true,
- alt: false,
- shift: false,
- meta: false,
+/// Create a KeyCombo from a dioxus keyboard event.
+pub fn keycombo_from_dioxus_event(event: &dioxus::events::KeyboardData) -> KeyCombo {
+ let key = key_from_dioxus(event.key());
+ let modifiers = Modifiers {
+ ctrl: event.modifiers().ctrl(),
+ alt: event.modifiers().alt(),
+ shift: event.modifiers().shift(),
+ meta: event.modifiers().meta(),
hyper: false,
super_: false,
};
- pub const ALT: Self = Self {
- ctrl: false,
- alt: true,
- shift: false,
- meta: false,
- hyper: false,
- super_: false,
- };
- pub const SHIFT: Self = Self {
- ctrl: false,
- alt: false,
- shift: true,
- meta: false,
- hyper: false,
- super_: false,
- };
- pub const META: Self = Self {
- ctrl: false,
- alt: false,
- shift: false,
- meta: true,
- hyper: false,
- super_: false,
- };
- pub const HYPER: Self = Self {
- ctrl: false,
- alt: false,
- shift: false,
- meta: false,
- hyper: true,
- super_: false,
- };
- pub const SUPER: Self = Self {
- ctrl: false,
- alt: false,
- shift: false,
- meta: false,
- hyper: false,
- super_: true,
- };
- pub const CTRL_SHIFT: Self = Self {
- ctrl: true,
- alt: false,
- shift: true,
- meta: false,
- hyper: false,
- super_: false,
- };
- pub const META_SHIFT: Self = Self {
- ctrl: false,
- alt: false,
- shift: true,
- meta: true,
- hyper: false,
- super_: false,
- };
-
- /// Get the platform's primary modifier (Cmd on Mac, Ctrl elsewhere).
- pub fn cmd_or_ctrl(platform: &Platform) -> Self {
- if platform.mac { Self::META } else { Self::CTRL }
- }
-
- /// Get the platform's primary modifier + Shift.
- pub fn cmd_or_ctrl_shift(platform: &Platform) -> Self {
- if platform.mac {
- Self::META_SHIFT
- } else {
- Self::CTRL_SHIFT
- }
- }
+ KeyCombo::with_modifiers(key, modifiers)
}
-/// A key combination for triggering an action.
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct KeyCombo {
- pub key: Key,
- pub modifiers: Modifiers,
-}
-
-impl KeyCombo {
- pub fn new(key: Key) -> Self {
- Self {
- key,
- modifiers: Modifiers::NONE,
- }
- }
-
- pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self {
- Self { key, modifiers }
- }
-
- pub fn ctrl(key: Key) -> Self {
- Self {
- key,
- modifiers: Modifiers::CTRL,
- }
- }
-
- pub fn meta(key: Key) -> Self {
- Self {
- key,
- modifiers: Modifiers::META,
- }
- }
-
- pub fn shift(key: Key) -> Self {
- Self {
- key,
- modifiers: Modifiers::SHIFT,
- }
- }
-
- pub fn cmd_or_ctrl(key: Key, platform: &Platform) -> Self {
- Self {
- key,
- modifiers: Modifiers::cmd_or_ctrl(platform),
- }
- }
-
- pub fn cmd_or_ctrl_shift(key: Key, platform: &Platform) -> Self {
- Self {
- key,
- modifiers: Modifiers::cmd_or_ctrl_shift(platform),
- }
- }
-
- /// Create a KeyCombo from a dioxus keyboard event.
- pub fn from_keyboard_event(event: &dioxus::events::KeyboardData) -> Self {
- let key = Key::from_keyboard_types(event.key());
- let modifiers = Modifiers {
- ctrl: event.modifiers().ctrl(),
- alt: event.modifiers().alt(),
- shift: event.modifiers().shift(),
- meta: event.modifiers().meta(),
- // dioxus doesn't expose hyper/super separately, they typically map to meta
- hyper: false,
- super_: false,
- };
- Self { key, modifiers }
- }
-}
-
-/// Keybinding configuration for the editor.
-///
-/// Uses a HashMap for O(1) keybinding lookup.
-#[derive(Debug, Clone)]
-pub struct KeybindingConfig {
- bindings: HashMap,
-}
-
-impl KeybindingConfig {
- /// Create default keybindings for the given platform.
- pub fn default_for_platform(platform: &Platform) -> Self {
- let mut bindings = HashMap::new();
-
- // === Formatting ===
- bindings.insert(
- KeyCombo::cmd_or_ctrl(Key::character("b"), platform),
- EditorAction::ToggleBold,
- );
- bindings.insert(
- KeyCombo::cmd_or_ctrl(Key::character("i"), platform),
- EditorAction::ToggleItalic,
- );
- bindings.insert(
- KeyCombo::cmd_or_ctrl(Key::character("e"), platform),
- EditorAction::CopyAsHtml,
- );
-
- // === History ===
- bindings.insert(
- KeyCombo::cmd_or_ctrl(Key::character("z"), platform),
- EditorAction::Undo,
- );
-
- // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere
- if platform.mac {
- bindings.insert(
- KeyCombo::cmd_or_ctrl_shift(Key::character("Z"), platform),
- EditorAction::Redo,
- );
- } else {
- bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo);
- bindings.insert(
- KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT),
- EditorAction::Redo,
- );
- }
-
- // === Selection ===
- bindings.insert(
- KeyCombo::cmd_or_ctrl(Key::character("a"), platform),
- EditorAction::SelectAll,
- );
-
- // === Line deletion ===
- if platform.mac {
- bindings.insert(
- KeyCombo::meta(Key::Backspace),
- EditorAction::DeleteToLineStart {
- range: Range::caret(0),
- },
- );
- bindings.insert(
- KeyCombo::meta(Key::Delete),
- EditorAction::DeleteToLineEnd {
- range: Range::caret(0),
- },
- );
- }
-
- // === Enter behaviour ===
- // Enter = soft break (single newline)
- bindings.insert(
- KeyCombo::new(Key::Enter),
- EditorAction::InsertLineBreak {
- range: Range::caret(0),
- },
- );
- // Shift+Enter = paragraph break (double newline)
- bindings.insert(
- KeyCombo::shift(Key::Enter),
- EditorAction::InsertParagraph {
- range: Range::caret(0),
- },
- );
-
- bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo);
- bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo);
- bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy);
- bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut);
- bindings.insert(
- KeyCombo::new(Key::Paste),
- EditorAction::Paste {
- range: Range::caret(0),
- },
- );
- bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
-
- Self { bindings }
- }
-
- /// Look up an action for the given key combo, with the current range applied.
- pub fn lookup(&self, combo: KeyCombo, range: Range) -> Option {
- self.bindings
- .get(&combo)
- .cloned()
- .map(|a| a.with_range(range))
- }
-
- /// Add or replace a keybinding.
- #[allow(dead_code)]
- pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
- self.bindings.insert(combo, action);
- }
-
- /// Remove a keybinding.
- #[allow(dead_code)]
- pub fn unbind(&mut self, combo: KeyCombo) {
- self.bindings.remove(&combo);
- }
+/// Create a default KeybindingConfig for the given platform.
+pub fn default_keybindings(platform: &Platform) -> KeybindingConfig {
+ KeybindingConfig::default_for_platform(platform.mac)
}
/// Execute an editor action on a document.
@@ -1248,17 +633,6 @@ fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize {
pos
}
-/// Result of handling a keydown event.
-#[derive(Debug, Clone, PartialEq)]
-pub enum KeydownResult {
- /// Event was handled, prevent default.
- Handled,
- /// Event was not a keybinding, let browser/beforeinput handle it.
- NotHandled,
- /// Event should be passed through (navigation, etc.).
- PassThrough,
-}
-
/// Handle a keydown event using the keybinding configuration.
///
/// This handles keyboard shortcuts only. Text input and deletion
@@ -1271,7 +645,7 @@ pub fn handle_keydown_with_bindings(
range: Range,
) -> KeydownResult {
// Look up keybinding (range is applied by lookup)
- if let Some(action) = config.lookup(combo.clone(), range) {
+ if let Some(action) = config.lookup(&combo, range) {
execute_action(doc, &action);
return KeydownResult::Handled;
}
diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs
index 5f444b1..8a59e07 100644
--- a/crates/weaver-app/src/components/editor/beforeinput.rs
+++ b/crates/weaver-app/src/components/editor/beforeinput.rs
@@ -16,209 +16,15 @@
use dioxus::prelude::*;
-use super::actions::{EditorAction, Range, execute_action};
+use super::actions::{EditorAction, execute_action};
use super::document::EditorDocument;
use super::platform::Platform;
-// Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it.
-// StaticRange is returned by InputEvent.getTargetRanges() and represents
-// a fixed range that doesn't update when the DOM changes.
-// https://developer.mozilla.org/en-US/docs/Web/API/StaticRange
-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-mod static_range {
- use wasm_bindgen::prelude::*;
-
- #[wasm_bindgen]
- extern "C" {
- /// The StaticRange interface represents a static range of text in the DOM.
- pub type StaticRange;
-
- #[wasm_bindgen(method, getter, structural)]
- pub fn startContainer(this: &StaticRange) -> web_sys::Node;
-
- #[wasm_bindgen(method, getter, structural)]
- pub fn startOffset(this: &StaticRange) -> u32;
-
- #[wasm_bindgen(method, getter, structural)]
- pub fn endContainer(this: &StaticRange) -> web_sys::Node;
-
- #[wasm_bindgen(method, getter, structural)]
- pub fn endOffset(this: &StaticRange) -> u32;
-
- #[wasm_bindgen(method, getter, structural)]
- pub fn collapsed(this: &StaticRange) -> bool;
- }
-}
+// Re-export types from extracted crates.
+pub use weaver_editor_core::{InputType, Range};
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-pub use static_range::StaticRange;
-
-/// Input types from the beforeinput event.
-///
-/// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes
-#[derive(Debug, Clone, PartialEq, Eq)]
-#[allow(dead_code)]
-pub enum InputType {
- // === Insertion ===
- /// Insert typed text.
- InsertText,
- /// Insert text from IME composition.
- InsertCompositionText,
- /// Insert a line break (`
`, Shift+Enter).
- InsertLineBreak,
- /// Insert a paragraph break (Enter).
- InsertParagraph,
- /// Insert from paste operation.
- InsertFromPaste,
- /// Insert from drop operation.
- InsertFromDrop,
- /// Insert replacement text (e.g., spell check correction).
- InsertReplacementText,
- /// Insert from voice input or other source.
- InsertFromYank,
- /// Insert a horizontal rule.
- InsertHorizontalRule,
- /// Insert an ordered list.
- InsertOrderedList,
- /// Insert an unordered list.
- InsertUnorderedList,
- /// Insert a link.
- InsertLink,
-
- // === Deletion ===
- /// Delete content backward (Backspace).
- DeleteContentBackward,
- /// Delete content forward (Delete key).
- DeleteContentForward,
- /// Delete word backward (Ctrl/Alt+Backspace).
- DeleteWordBackward,
- /// Delete word forward (Ctrl/Alt+Delete).
- DeleteWordForward,
- /// Delete to soft line boundary backward.
- DeleteSoftLineBackward,
- /// Delete to soft line boundary forward.
- DeleteSoftLineForward,
- /// Delete to hard line boundary backward (Cmd+Backspace on Mac).
- DeleteHardLineBackward,
- /// Delete to hard line boundary forward (Cmd+Delete on Mac).
- DeleteHardLineForward,
- /// Delete by cut operation.
- DeleteByCut,
- /// Delete by drag operation.
- DeleteByDrag,
- /// Generic content deletion.
- DeleteContent,
- /// Delete entire word backward (Ctrl+W on some systems).
- DeleteEntireWordBackward,
- /// Delete entire word forward.
- DeleteEntireWordForward,
-
- // === History ===
- /// Undo.
- HistoryUndo,
- /// Redo.
- HistoryRedo,
-
- // === Formatting (rarely used, most apps handle via shortcuts) ===
- FormatBold,
- FormatItalic,
- FormatUnderline,
- FormatStrikethrough,
- FormatSuperscript,
- FormatSubscript,
-
- // === Unknown ===
- /// Unrecognized input type.
- Unknown(String),
-}
-
-#[allow(dead_code)]
-impl InputType {
- /// Parse from the browser's inputType string.
- pub fn from_str(s: &str) -> Self {
- match s {
- // Insertion
- "insertText" => Self::InsertText,
- "insertCompositionText" => Self::InsertCompositionText,
- "insertLineBreak" => Self::InsertLineBreak,
- "insertParagraph" => Self::InsertParagraph,
- "insertFromPaste" => Self::InsertFromPaste,
- "insertFromDrop" => Self::InsertFromDrop,
- "insertReplacementText" => Self::InsertReplacementText,
- "insertFromYank" => Self::InsertFromYank,
- "insertHorizontalRule" => Self::InsertHorizontalRule,
- "insertOrderedList" => Self::InsertOrderedList,
- "insertUnorderedList" => Self::InsertUnorderedList,
- "insertLink" => Self::InsertLink,
-
- // Deletion
- "deleteContentBackward" => Self::DeleteContentBackward,
- "deleteContentForward" => Self::DeleteContentForward,
- "deleteWordBackward" => Self::DeleteWordBackward,
- "deleteWordForward" => Self::DeleteWordForward,
- "deleteSoftLineBackward" => Self::DeleteSoftLineBackward,
- "deleteSoftLineForward" => Self::DeleteSoftLineForward,
- "deleteHardLineBackward" => Self::DeleteHardLineBackward,
- "deleteHardLineForward" => Self::DeleteHardLineForward,
- "deleteByCut" => Self::DeleteByCut,
- "deleteByDrag" => Self::DeleteByDrag,
- "deleteContent" => Self::DeleteContent,
- "deleteEntireSoftLine" => Self::DeleteSoftLineBackward, // Treat as soft line
- "deleteEntireWordBackward" => Self::DeleteEntireWordBackward,
- "deleteEntireWordForward" => Self::DeleteEntireWordForward,
-
- // History
- "historyUndo" => Self::HistoryUndo,
- "historyRedo" => Self::HistoryRedo,
-
- // Formatting
- "formatBold" => Self::FormatBold,
- "formatItalic" => Self::FormatItalic,
- "formatUnderline" => Self::FormatUnderline,
- "formatStrikethrough" => Self::FormatStrikethrough,
- "formatSuperscript" => Self::FormatSuperscript,
- "formatSubscript" => Self::FormatSubscript,
-
- // Unknown
- other => Self::Unknown(other.to_string()),
- }
- }
-
- /// Whether this input type is a deletion operation.
- pub fn is_deletion(&self) -> bool {
- matches!(
- self,
- Self::DeleteContentBackward
- | Self::DeleteContentForward
- | Self::DeleteWordBackward
- | Self::DeleteWordForward
- | Self::DeleteSoftLineBackward
- | Self::DeleteSoftLineForward
- | Self::DeleteHardLineBackward
- | Self::DeleteHardLineForward
- | Self::DeleteByCut
- | Self::DeleteByDrag
- | Self::DeleteContent
- | Self::DeleteEntireWordBackward
- | Self::DeleteEntireWordForward
- )
- }
-
- /// Whether this input type is an insertion operation.
- pub fn is_insertion(&self) -> bool {
- matches!(
- self,
- Self::InsertText
- | Self::InsertCompositionText
- | Self::InsertLineBreak
- | Self::InsertParagraph
- | Self::InsertFromPaste
- | Self::InsertFromDrop
- | Self::InsertReplacementText
- | Self::InsertFromYank
- )
- }
-}
+pub use weaver_editor_browser::StaticRange;
/// Result of handling a beforeinput event.
#[derive(Debug, Clone)]
diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs
index 15f6ef3..8527324 100644
--- a/crates/weaver-app/src/components/editor/component.rs
+++ b/crates/weaver-app/src/components/editor/component.rs
@@ -1238,7 +1238,7 @@ fn MarkdownEditorInner(
onkeydown: {
let mut doc = document.clone();
- let keybindings = KeybindingConfig::default_for_platform(&platform::platform());
+ let keybindings = super::actions::default_keybindings(platform::platform());
move |evt| {
use dioxus::prelude::keyboard_types::Key;
use std::time::Duration;
@@ -1284,7 +1284,7 @@ fn MarkdownEditorInner(
}
// Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.)
- let combo = KeyCombo::from_keyboard_event(&evt.data());
+ let combo = super::actions::keycombo_from_dioxus_event(&evt.data());
let cursor_offset = doc.cursor.read().offset;
let selection = *doc.selection.read();
let range = selection
diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs
index 0869ea5..5d5cf72 100644
--- a/crates/weaver-app/src/components/editor/cursor.rs
+++ b/crates/weaver-app/src/components/editor/cursor.rs
@@ -8,6 +8,7 @@
//! 4. Setting cursor with web_sys Selection API
use weaver_editor_core::OffsetMapping;
+pub use weaver_editor_core::{CursorRect, SelectionRect};
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position};
@@ -194,13 +195,7 @@ fn find_text_node_at_offset(
Err("no text node found in container".into())
}
-/// Screen coordinates for a cursor position.
-#[derive(Debug, Clone, Copy)]
-pub struct CursorRect {
- pub x: f64,
- pub y: f64,
- pub height: f64,
-}
+// CursorRect is imported from weaver_editor_core.
/// Get screen coordinates for a character offset in the editor.
///
@@ -290,14 +285,7 @@ pub fn get_cursor_rect_relative(
None
}
-/// A rectangle for part of a selection (one per line).
-#[derive(Debug, Clone, Copy)]
-pub struct SelectionRect {
- pub x: f64,
- pub y: f64,
- pub width: f64,
- pub height: f64,
-}
+// SelectionRect is imported from weaver_editor_core.
/// Get screen rectangles for a selection range, relative to editor.
///
diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs
index ce7432d..9e44d17 100644
--- a/crates/weaver-app/src/components/editor/document.rs
+++ b/crates/weaver-app/src/components/editor/document.rs
@@ -28,6 +28,9 @@ use jacquard::types::string::AtUri;
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
use weaver_api::sh_weaver::embed::images::Image;
use weaver_api::sh_weaver::embed::records::RecordEmbed;
+pub use weaver_editor_core::{
+ Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
+};
use weaver_api::sh_weaver::notebook::entry::Entry;
/// Helper for working with editor images.
@@ -151,65 +154,8 @@ pub struct EditorDocument {
pub collected_refs: Signal>,
}
-/// Cursor state including position and affinity.
-#[derive(Clone, Debug, Copy)]
-pub struct CursorState {
- /// Character offset in text (NOT byte offset!)
- pub offset: usize,
-
- /// Prefer left/right when at boundary (for vertical cursor movement)
- pub affinity: Affinity,
-}
-
-/// Cursor affinity for vertical movement.
-#[derive(Clone, Debug, Copy, PartialEq, Eq)]
-pub enum Affinity {
- Before,
- After,
-}
-
-/// Text selection with anchor and head positions.
-#[derive(Clone, Debug, Copy)]
-pub struct Selection {
- /// Where selection started
- pub anchor: usize,
- /// Where cursor is now
- pub head: usize,
-}
-
-/// IME composition state (for international text input).
-#[derive(Clone, Debug)]
-pub struct CompositionState {
- pub start_offset: usize,
- pub text: String,
-}
-
-/// Information about the most recent edit, used for incremental rendering optimization.
-/// Derives PartialEq so it can be used with Dioxus memos for change detection.
-#[derive(Clone, Debug, PartialEq)]
-pub struct EditInfo {
- /// Character offset where the edit occurred
- pub edit_char_pos: usize,
- /// Number of characters inserted
- pub inserted_len: usize,
- /// Number of characters deleted
- pub deleted_len: usize,
- /// Whether the edit contains a newline (boundary-affecting)
- pub contains_newline: bool,
- /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
- /// Edits here could affect block-level syntax like headings, lists, code fences.
- pub in_block_syntax_zone: bool,
- /// Document length (in chars) after this edit was applied.
- /// Used to detect stale edit info - if current doc length doesn't match,
- /// the edit info is from a previous render cycle and shouldn't be used.
- pub doc_len_after: usize,
- /// When this edit occurred. Used for idle detection in collaborative sync.
- pub timestamp: web_time::Instant,
-}
-
-/// Max distance from line start where block syntax can appear.
-/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
-const BLOCK_SYNTAX_ZONE: usize = 6;
+// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE
+// are imported from weaver_editor_core.
/// Pre-loaded document state that can be created outside of reactive context.
///
diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs
index cb6d976..338c632 100644
--- a/crates/weaver-app/src/components/editor/formatting.rs
+++ b/crates/weaver-app/src/components/editor/formatting.rs
@@ -5,20 +5,8 @@ use super::document::EditorDocument;
use super::input::{ListContext, detect_list_context, find_line_end};
use dioxus::prelude::*;
-/// Formatting actions available in the editor.
-#[derive(Clone, Debug, PartialEq)]
-pub enum FormatAction {
- Bold,
- Italic,
- Strikethrough,
- Code,
- Link,
- Image,
- Heading(u8), // 1-6
- BulletList,
- NumberedList,
- Quote,
-}
+// FormatAction is imported from core.
+pub use weaver_editor_core::FormatAction;
/// Find word boundaries around cursor position.
///
@@ -165,6 +153,9 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) {
doc.cursor.write().offset = cursor_offset + 2;
doc.selection.set(None);
}
+ _ => {
+ tracing::warn!(?action, "unhandled format action");
+ }
}
}
diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs
index 9212179..3807386 100644
--- a/crates/weaver-app/src/components/editor/paragraph.rs
+++ b/crates/weaver-app/src/components/editor/paragraph.rs
@@ -1,54 +1,14 @@
//! Paragraph-level rendering for incremental updates.
//!
-//! Paragraphs are discovered during markdown rendering by tracking
-//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
+//! Re-exports core types and provides Loro-specific helpers.
use loro::LoroText;
use std::ops::Range;
-use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo};
-/// A rendered paragraph with its source range and offset mappings.
-#[derive(Debug, Clone, PartialEq)]
-pub struct ParagraphRender {
- /// Stable content-based ID for DOM diffing (format: `p-{hash_prefix}-{collision_idx}`)
- pub id: String,
+// Re-export core types.
+pub use weaver_editor_core::{ParagraphRender, hash_source, make_paragraph_id};
- /// Source byte range in the rope
- pub byte_range: Range,
-
- /// Source char range in the rope
- pub char_range: Range,
-
- /// Rendered HTML content (without wrapper div)
- pub html: String,
-
- /// Offset mappings for this paragraph
- pub offset_map: Vec,
-
- /// Syntax spans for conditional visibility
- pub syntax_spans: Vec,
-
- /// Hash of source text for quick change detection
- pub source_hash: u64,
-}
-
-/// Simple hash function for source text comparison
-pub fn hash_source(text: &str) -> u64 {
- use std::collections::hash_map::DefaultHasher;
- use std::hash::{Hash, Hasher};
-
- let mut hasher = DefaultHasher::new();
- text.hash(&mut hasher);
- hasher.finish()
-}
-
-/// Generate a paragraph ID from monotonic counter.
-/// IDs are stable across content changes - only position/cursor determines identity.
-pub fn make_paragraph_id(id: usize) -> String {
- format!("p-{}", id)
-}
-
-/// Extract substring from LoroText as String
+/// Extract substring from LoroText as String.
pub fn text_slice_to_string(text: &LoroText, range: Range) -> String {
text.slice(range.start, range.end).unwrap_or_default()
}
diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs
index 6b68429..14b3bd2 100644
--- a/crates/weaver-app/src/components/editor/render.rs
+++ b/crates/weaver-app/src/components/editor/render.rs
@@ -2,169 +2,84 @@
//!
//! Phase 2: Paragraph-level incremental rendering with formatting characters visible.
//!
-//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
+//! This module provides a thin wrapper around the core rendering logic,
+//! adapting it to LoroText and adding app-specific features like timing.
-use super::document::EditInfo;
-use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string};
+use super::paragraph::ParagraphRender;
use super::writer::embed::EditorImageResolver;
use loro::LoroText;
-use markdown_weaver::Parser;
-use std::ops::Range;
use weaver_common::{EntryIndex, ResolvedContent};
-use weaver_editor_core::{
- EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SyntaxSpanInfo,
-};
+use weaver_editor_core::{EditInfo, SmolStr, TextBuffer};
-/// Cache for incremental paragraph rendering.
-/// Stores previously rendered paragraphs to avoid re-rendering unchanged content.
-#[derive(Clone, Debug, Default)]
-pub struct RenderCache {
- /// Cached paragraph renders (content paragraphs only, gaps computed fresh)
- pub paragraphs: Vec,
- /// Next available node ID for fresh renders
- pub next_node_id: usize,
- /// Next available syntax span ID for fresh renders
- pub next_syn_id: usize,
- /// Next available paragraph ID (monotonic counter)
- pub next_para_id: usize,
-}
+// Re-export core types.
+pub use weaver_editor_core::RenderCache;
-/// A cached paragraph render that can be reused if source hasn't changed.
-#[derive(Clone, Debug)]
-pub struct CachedParagraph {
- /// Stable monotonic ID for DOM element identity
- pub id: String,
- /// Hash of paragraph source text for change detection
- pub source_hash: u64,
- /// Byte range in source document
- pub byte_range: Range,
- /// Char range in source document
- pub char_range: Range,
- /// Rendered HTML
- pub html: String,
- /// Offset mappings for cursor positioning
- pub offset_map: Vec,
- /// Syntax spans for conditional visibility
- pub syntax_spans: Vec,
- /// Collected refs (wikilinks, AT embeds) from this paragraph
- pub collected_refs: Vec,
-}
+/// Adapter to make LoroText implement TextBuffer-like interface for core.
+/// temporary until weaver-editor-crdt crate complete
+struct LoroTextAdapter<'a>(&'a LoroText);
-/// Check if an edit affects paragraph boundaries.
-///
-/// Edits that don't contain newlines and aren't in the block-syntax zone
-/// are considered "safe" and can skip boundary rediscovery.
-fn is_boundary_affecting(edit: &EditInfo) -> bool {
- // Newlines always affect boundaries (paragraph splits/joins)
- if edit.contains_newline {
- return true;
+impl TextBuffer for LoroTextAdapter<'_> {
+ fn len_chars(&self) -> usize {
+ self.0.len_unicode()
}
- // Edits in the block-syntax zone (first ~6 chars of line) could affect
- // headings, lists, blockquotes, code fences, etc.
- if edit.in_block_syntax_zone {
- return true;
+ fn len_bytes(&self) -> usize {
+ self.0.len_utf8()
}
- false
-}
-
-/// Apply a signed delta to a usize, saturating at 0 on underflow.
-fn apply_delta(val: usize, delta: isize) -> usize {
- if delta >= 0 {
- val.saturating_add(delta as usize)
- } else {
- val.saturating_sub((-delta) as usize)
+ fn slice(&self, range: std::ops::Range) -> Option {
+ self.0
+ .slice(range.start, range.end)
+ .ok()
+ .map(|s| SmolStr::new(&s))
}
-}
-/// Insert gap paragraphs for extra whitespace between blocks.
-fn add_gap_paragraphs(
- paragraphs: Vec,
- text: &LoroText,
- source: &str,
-) -> Vec {
- const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n
+ fn char_at(&self, offset: usize) -> Option {
+ self.0
+ .slice(offset, offset + 1)
+ .ok()
+ .and_then(|s| s.chars().next())
+ }
- let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
- let mut prev_end_char = 0usize;
- let mut prev_end_byte = 0usize;
+ fn char_to_byte(&self, char_offset: usize) -> usize {
+ // LoroText doesn't expose this directly, so we compute it.
+ let slice = self.0.slice(0, char_offset).unwrap_or_default();
+ slice.len()
+ }
- for para in paragraphs {
- // Gap insertion commented out - with break-spaces CSS, whitespace renders naturally
- // let gap_size = para.char_range.start.saturating_sub(prev_end_char);
- // if gap_size > MIN_PARAGRAPH_BREAK_INCR {
- // let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR;
- // let gap_end_char = para.char_range.start;
- // let gap_end_byte = para.byte_range.start;
- //
- // let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
- // let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}');
- //
- // paragraphs_with_gaps.push(ParagraphRender {
- // id: gap_node_id.clone(),
- // byte_range: prev_end_byte..gap_end_byte,
- // char_range: prev_end_char..gap_end_char,
- // html: gap_html,
- // offset_map: vec![OffsetMapping {
- // byte_range: prev_end_byte..gap_end_byte,
- // char_range: prev_end_char..gap_end_char,
- // node_id: gap_node_id,
- // char_offset_in_node: 0,
- // child_index: None,
- // utf16_len: 1,
- // }],
- // syntax_spans: vec![],
- // source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)),
- // });
- // }
+ fn byte_to_char(&self, byte_offset: usize) -> usize {
+ // LoroText doesn't expose this directly, so we compute it.
+ let full = self.0.to_string();
+ full[..byte_offset.min(full.len())].chars().count()
+ }
- prev_end_char = para.char_range.end;
- prev_end_byte = para.byte_range.end;
- paragraphs_with_gaps.push(para);
+ fn insert(&mut self, _offset: usize, _text: &str) {
+ // Read-only adapter - this should never be called during rendering.
+ panic!("LoroTextAdapter is read-only");
}
- // Trailing gap insertion commented out - with break-spaces CSS, whitespace renders naturally
- // let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
- // if has_trailing_newlines {
- // let doc_end_char = text.len_unicode();
- // let doc_end_byte = text.len_utf8();
- //
- // if doc_end_char > prev_end_char {
- // let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char);
- // let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}');
- //
- // paragraphs_with_gaps.push(ParagraphRender {
- // id: trailing_node_id.clone(),
- // byte_range: prev_end_byte..doc_end_byte,
- // char_range: prev_end_char..doc_end_char,
- // html: trailing_html,
- // offset_map: vec![OffsetMapping {
- // byte_range: prev_end_byte..doc_end_byte,
- // char_range: prev_end_char..doc_end_char,
- // node_id: trailing_node_id,
- // char_offset_in_node: 0,
- // child_index: None,
- // utf16_len: 1,
- // }],
- // syntax_spans: vec![],
- // source_hash: 0,
- // });
- // }
- // }
+ fn delete(&mut self, _range: std::ops::Range) {
+ // Read-only adapter - this should never be called during rendering.
+ panic!("LoroTextAdapter is read-only");
+ }
- paragraphs_with_gaps
+ fn to_string(&self) -> String {
+ self.0.to_string()
+ }
}
/// Render markdown with incremental caching.
///
/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
+/// This is a thin wrapper around the core rendering logic that adds timing.
///
/// # Parameters
-/// - `cursor_offset`: Current cursor position (for finding which NEW paragraph is the cursor para)
-/// - `edit`: Edit info for stable ID assignment. Uses `edit_char_pos` to find which OLD cached
-/// paragraph to reuse the ID from (since cursor may have moved after the edit).
-/// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes)
+/// - `text`: The LoroText to render
+/// - `cache`: Optional previous render cache
+/// - `cursor_offset`: Current cursor position
+/// - `edit`: Edit info for stable ID assignment
+/// - `image_resolver`: Optional image URL resolver
+/// - `entry_index`: Optional index for wikilink validation
/// - `resolved_content`: Pre-resolved embed content for sync rendering
///
/// # Returns
@@ -183,716 +98,27 @@ pub fn render_paragraphs_incremental(
Vec,
) {
let fn_start = crate::perf::now();
- let source = text.to_string();
-
- // Log source entering renderer to detect ZWC/space issues
- if tracing::enabled!(target: "weaver::render", tracing::Level::TRACE) {
- tracing::trace!(
- target: "weaver::render",
- source_len = source.len(),
- source_chars = source.chars().count(),
- source_content = %source.escape_debug(),
- "render_paragraphs: source entering renderer"
- );
- }
-
- // Handle empty document
- if source.is_empty() {
- let empty_node_id = "n0".to_string();
- let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}');
- let para_id = make_paragraph_id(0);
-
- let para = ParagraphRender {
- id: para_id.clone(),
- byte_range: 0..0,
- char_range: 0..0,
- html: empty_html.clone(),
- offset_map: vec![],
- syntax_spans: vec![],
- source_hash: 0,
- };
-
- let new_cache = RenderCache {
- paragraphs: vec![CachedParagraph {
- id: para_id,
- source_hash: 0,
- byte_range: 0..0,
- char_range: 0..0,
- html: empty_html,
- offset_map: vec![],
- syntax_spans: vec![],
- collected_refs: vec![],
- }],
- next_node_id: 1,
- next_syn_id: 0,
- next_para_id: 1,
- };
-
- return (vec![para], new_cache, vec![]);
- }
-
- // Determine if we can use fast path (skip boundary discovery)
- // Need cache and non-boundary-affecting edit info (for edit position)
- let current_len = text.len_unicode();
- let current_byte_len = text.len_utf8();
-
- // If we have cache but no edit, just return cached data (no re-render needed)
- // This happens on cursor position changes, clicks, etc.
- if let (Some(c), None) = (cache, edit) {
- // Verify cache is still valid (document length matches)
- let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
- if cached_len == current_len {
- tracing::trace!(
- target: "weaver::render",
- "no edit, returning cached paragraphs"
- );
- let paragraphs: Vec = c
- .paragraphs
- .iter()
- .map(|p| ParagraphRender {
- id: p.id.clone(),
- byte_range: p.byte_range.clone(),
- char_range: p.char_range.clone(),
- html: p.html.clone(),
- offset_map: p.offset_map.clone(),
- syntax_spans: p.syntax_spans.clone(),
- source_hash: p.source_hash,
- })
- .collect();
- let paragraphs = add_gap_paragraphs(paragraphs, text, &source);
- return (
- paragraphs,
- c.clone(),
- c.paragraphs
- .iter()
- .flat_map(|p| p.collected_refs.clone())
- .collect(),
- );
- }
- }
-
- let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
-
- tracing::debug!(
- target: "weaver::render",
- use_fast_path,
- has_cache = cache.is_some(),
- has_edit = edit.is_some(),
- boundary_affecting = edit.map(is_boundary_affecting),
- current_len,
- "render path decision"
- );
-
- // Get paragraph boundaries
- let paragraph_ranges = if use_fast_path {
- // Fast path: adjust cached boundaries based on actual length change
- let cache = cache.unwrap();
- let edit = edit.unwrap();
-
- // Find which paragraph the edit falls into
- let edit_pos = edit.edit_char_pos;
-
- // Compute delta from actual length difference, not edit info
- // This handles stale edits gracefully (delta = 0 if lengths match)
- let (cached_len, cached_byte_len) = cache
- .paragraphs
- .last()
- .map(|p| (p.char_range.end, p.byte_range.end))
- .unwrap_or((0, 0));
- let char_delta = current_len as isize - cached_len as isize;
- let byte_delta = current_byte_len as isize - cached_byte_len as isize;
-
- // Adjust each cached paragraph's range
- cache
- .paragraphs
- .iter()
- .map(|p| {
- if p.char_range.end < edit_pos {
- // Before edit - no change (edit is strictly after this paragraph)
- (p.byte_range.clone(), p.char_range.clone())
- } else if p.char_range.start > edit_pos {
- // After edit - shift by delta (edit is strictly before this paragraph)
- (
- apply_delta(p.byte_range.start, byte_delta)
- ..apply_delta(p.byte_range.end, byte_delta),
- apply_delta(p.char_range.start, char_delta)
- ..apply_delta(p.char_range.end, char_delta),
- )
- } else {
- // Edit is at or within this paragraph - expand its end
- (
- p.byte_range.start..apply_delta(p.byte_range.end, byte_delta),
- p.char_range.start..apply_delta(p.char_range.end, char_delta),
- )
- }
- })
- .collect::>()
- } else {
- vec![] // Will be populated by slow path below
- };
-
- // Validate fast path results - if any ranges are invalid, use slow path
- let use_fast_path = if !paragraph_ranges.is_empty() {
- let all_valid = paragraph_ranges
- .iter()
- .all(|(_, char_range)| char_range.start <= char_range.end);
- if !all_valid {
- tracing::debug!(
- target: "weaver::render",
- "fast path produced invalid ranges, falling back to slow path"
- );
- false
- } else {
- true
- }
- } else {
- false
- };
-
- // ============ FAST PATH ============
- // Reuse cached paragraphs with offset adjustment, only re-render cursor paragraph
- if use_fast_path {
- let fast_start = crate::perf::now();
- let cache = cache.unwrap();
- let edit = edit.unwrap();
- let edit_pos = edit.edit_char_pos;
-
- // Compute deltas
- let (cached_len, cached_byte_len) = cache
- .paragraphs
- .last()
- .map(|p| (p.char_range.end, p.byte_range.end))
- .unwrap_or((0, 0));
- let char_delta = current_len as isize - cached_len as isize;
- let byte_delta = current_byte_len as isize - cached_byte_len as isize;
-
- // Find cursor paragraph index
- let cursor_para_idx = cache
- .paragraphs
- .iter()
- .position(|p| p.char_range.start <= edit_pos && edit_pos <= p.char_range.end);
-
- let mut paragraphs = Vec::with_capacity(cache.paragraphs.len());
- let mut new_cached = Vec::with_capacity(cache.paragraphs.len());
- let mut all_refs: Vec = Vec::new();
-
- for (idx, cached_para) in cache.paragraphs.iter().enumerate() {
- let is_cursor_para = Some(idx) == cursor_para_idx;
-
- // Adjust ranges based on position relative to edit
- let (byte_range, char_range) = if cached_para.char_range.end < edit_pos {
- // Before edit - no change
- (
- cached_para.byte_range.clone(),
- cached_para.char_range.clone(),
- )
- } else if cached_para.char_range.start > edit_pos {
- // After edit - shift by delta
- (
- apply_delta(cached_para.byte_range.start, byte_delta)
- ..apply_delta(cached_para.byte_range.end, byte_delta),
- apply_delta(cached_para.char_range.start, char_delta)
- ..apply_delta(cached_para.char_range.end, char_delta),
- )
- } else {
- // Contains edit - expand end
- (
- cached_para.byte_range.start
- ..apply_delta(cached_para.byte_range.end, byte_delta),
- cached_para.char_range.start
- ..apply_delta(cached_para.char_range.end, char_delta),
- )
- };
-
- let para_source = text_slice_to_string(text, char_range.clone());
- let source_hash = hash_source(¶_source);
-
- if is_cursor_para {
- // Re-render cursor paragraph for fresh syntax detection
- let resolver = image_resolver.cloned().unwrap_or_default();
- let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options())
- .into_offset_iter();
-
- let para_rope = EditorRope::from(para_source.as_str());
-
- let mut writer =
- EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new(
- ¶_source,
- ¶_rope,
- parser,
- )
- .with_node_id_prefix(&cached_para.id)
- .with_image_resolver(&resolver)
- .with_embed_provider(resolved_content);
-
- if let Some(idx) = entry_index {
- writer = writer.with_entry_index(idx);
- }
-
- let (html, offset_map, syntax_spans, para_refs) = match writer.run() {
- Ok(result) => {
- // Adjust offsets to be document-absolute
- let mut offset_map = result
- .offset_maps_by_paragraph
- .into_iter()
- .next()
- .unwrap_or_default();
- for m in &mut offset_map {
- m.char_range.start += char_range.start;
- m.char_range.end += char_range.start;
- m.byte_range.start += byte_range.start;
- m.byte_range.end += byte_range.start;
- }
- let mut syntax_spans = result
- .syntax_spans_by_paragraph
- .into_iter()
- .next()
- .unwrap_or_default();
- for s in &mut syntax_spans {
- s.adjust_positions(char_range.start as isize);
- }
- let para_refs = result
- .collected_refs_by_paragraph
- .into_iter()
- .next()
- .unwrap_or_default();
- let html = result.html_segments.into_iter().next().unwrap_or_default();
- (html, offset_map, syntax_spans, para_refs)
- }
- Err(_) => (String::new(), Vec::new(), Vec::new(), Vec::new()),
- };
-
- all_refs.extend(para_refs.clone());
-
- new_cached.push(CachedParagraph {
- id: cached_para.id.clone(),
- source_hash,
- byte_range: byte_range.clone(),
- char_range: char_range.clone(),
- html: html.clone(),
- offset_map: offset_map.clone(),
- syntax_spans: syntax_spans.clone(),
- collected_refs: para_refs.clone(),
- });
-
- paragraphs.push(ParagraphRender {
- id: cached_para.id.clone(),
- byte_range,
- char_range,
- html,
- offset_map,
- syntax_spans,
- source_hash,
- });
- } else {
- // Reuse cached with adjusted offsets
- let mut offset_map = cached_para.offset_map.clone();
- let mut syntax_spans = cached_para.syntax_spans.clone();
-
- if cached_para.char_range.start > edit_pos {
- // After edit - adjust offsets
- for m in &mut offset_map {
- m.char_range.start = apply_delta(m.char_range.start, char_delta);
- m.char_range.end = apply_delta(m.char_range.end, char_delta);
- m.byte_range.start = apply_delta(m.byte_range.start, byte_delta);
- m.byte_range.end = apply_delta(m.byte_range.end, byte_delta);
- }
- for s in &mut syntax_spans {
- s.adjust_positions(char_delta);
- }
- }
-
- all_refs.extend(cached_para.collected_refs.clone());
-
- new_cached.push(CachedParagraph {
- id: cached_para.id.clone(),
- source_hash,
- byte_range: byte_range.clone(),
- char_range: char_range.clone(),
- html: cached_para.html.clone(),
- offset_map: offset_map.clone(),
- syntax_spans: syntax_spans.clone(),
- collected_refs: cached_para.collected_refs.clone(),
- });
-
- paragraphs.push(ParagraphRender {
- id: cached_para.id.clone(),
- byte_range,
- char_range,
- html: cached_para.html.clone(),
- offset_map,
- syntax_spans,
- source_hash,
- });
- }
- }
-
- // Add gaps (reuse gap logic from below)
- let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source);
- let new_cache = RenderCache {
- paragraphs: new_cached,
- next_node_id: 0,
- next_syn_id: 0,
- next_para_id: cache.next_para_id,
- };
+ // Create adapter for LoroText to use with core rendering.
+ let adapter = LoroTextAdapter(text);
- let fast_ms = crate::perf::now() - fast_start;
- tracing::debug!(
- fast_ms,
- paragraphs = paragraphs_with_gaps.len(),
- cursor_para_idx,
- "fast path render timing"
- );
-
- return (paragraphs_with_gaps, new_cache, all_refs);
- }
-
- // ============ SLOW PATH ============
- // Partial render: reuse cached paragraphs before edit, parse from affected to end
- let render_start = crate::perf::now();
-
- // Try partial parse if we have cache and edit info
- let (reused_paragraphs, parse_start_byte, parse_start_char) =
- if let (Some(c), Some(e)) = (cache, edit) {
- // Find the first cached paragraph that contains or is after the edit
- let edit_pos = e.edit_char_pos;
- let affected_idx = c
- .paragraphs
- .iter()
- .position(|p| p.char_range.end >= edit_pos);
-
- if let Some(mut idx) = affected_idx {
- // If edit is near the start of a paragraph (within first few chars),
- // the previous paragraph is also affected (e.g., backspace to join)
- const BOUNDARY_SLOP: usize = 3;
- let para_start = c.paragraphs[idx].char_range.start;
- if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP {
- idx -= 1;
- }
-
- if idx > 0 {
- // Reuse paragraphs before the affected one
- let reused: Vec<_> = c.paragraphs[..idx].to_vec();
- let last_reused = &c.paragraphs[idx - 1];
- tracing::trace!(
- reused_count = idx,
- parse_start_byte = last_reused.byte_range.end,
- parse_start_char = last_reused.char_range.end,
- "slow path: partial parse from affected paragraph"
- );
- (
- reused,
- last_reused.byte_range.end,
- last_reused.char_range.end,
- )
- } else {
- // Edit is in first paragraph, parse everything
- (Vec::new(), 0, 0)
- }
- } else {
- // Edit is after all paragraphs (appending), parse from end
- if let Some(last) = c.paragraphs.last() {
- let reused = c.paragraphs.clone();
- (reused, last.byte_range.end, last.char_range.end)
- } else {
- (Vec::new(), 0, 0)
- }
- }
- } else {
- // No cache or no edit info, parse everything
- (Vec::new(), 0, 0)
- };
-
- // Parse from the start point to end of document
- let parse_slice = &source[parse_start_byte..];
- let parser =
- Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter();
-
- // Use provided resolver or empty default
- let resolver = image_resolver.cloned().unwrap_or_default();
-
- // Create EditorRope for efficient offset conversions
- let slice_rope = EditorRope::from(parse_slice);
-
- // Determine starting paragraph ID for freshly parsed paragraphs
- // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML
- let reused_count = reused_paragraphs.len();
-
- // If reused_count = 0 (full re-render), start from 0 for DOM stability
- // Otherwise, use next_para_id to avoid collisions with reused paragraphs
- let parsed_para_id_start = if reused_count == 0 {
- 0
- } else {
- cache.map(|c| c.next_para_id).unwrap_or(0)
- };
-
- tracing::trace!(
- parsed_para_id_start,
- reused_count,
- "slow path: paragraph ID allocation"
- );
-
- // Find if cursor paragraph is being re-parsed (not reused)
- // If so, we want it to keep its cached prefix for DOM/offset_map stability
- let cursor_para_override: Option<(usize, String)> = cache.and_then(|c| {
- // Find cached paragraph containing cursor
- let cached_cursor_idx = c.paragraphs.iter().position(|p| {
- p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end
- })?;
-
- // If cursor paragraph is reused (not being re-parsed), no override needed
- if cached_cursor_idx < reused_count {
- return None;
- }
-
- // Cursor paragraph is being re-parsed - use its cached ID
- let cached_para = &c.paragraphs[cached_cursor_idx];
- let parsed_index = cached_cursor_idx - reused_count;
-
- tracing::trace!(
- cached_cursor_idx,
- reused_count,
- parsed_index,
- cached_id = %cached_para.id,
- "slow path: cursor paragraph override"
- );
-
- Some((parsed_index, cached_para.id.clone()))
- });
-
- // Build writer with all resolvers and auto-incrementing paragraph prefixes
- let mut writer = EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new(
- parse_slice,
- &slice_rope,
- parser,
- )
- .with_auto_incrementing_prefix(parsed_para_id_start)
- .with_image_resolver(&resolver)
- .with_embed_provider(resolved_content);
-
- // Apply cursor paragraph override if needed
- if let Some((idx, ref prefix)) = cursor_para_override {
- writer = writer.with_static_prefix_at_index(idx, prefix);
- }
-
- if let Some(idx) = entry_index {
- writer = writer.with_entry_index(idx);
- }
-
- let writer_result = match writer.run() {
- Ok(result) => result,
- Err(_) => return (Vec::new(), RenderCache::default(), vec![]),
- };
-
- // Get the final paragraph ID counter from the writer (accounts for all parsed paragraphs)
- let parsed_para_count = writer_result.paragraph_ranges.len();
-
- let render_ms = crate::perf::now() - render_start;
-
- // Adjust parsed paragraph ranges to be document-absolute
- let parsed_paragraph_ranges: Vec<_> = writer_result
- .paragraph_ranges
- .iter()
- .map(|(byte_range, char_range)| {
- (
- (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte),
- (char_range.start + parse_start_char)..(char_range.end + parse_start_char),
- )
- })
- .collect();
-
- // Combine reused ranges with parsed ranges
- let paragraph_ranges: Vec<_> = reused_paragraphs
- .iter()
- .map(|p| (p.byte_range.clone(), p.char_range.clone()))
- .chain(parsed_paragraph_ranges.clone())
- .collect();
-
- // Log discovered paragraphs (only if trace is enabled to avoid wasted work)
- if tracing::enabled!(tracing::Level::TRACE) {
- for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
- let preview: String = text_slice_to_string(text, char_range.clone())
- .chars()
- .take(30)
- .collect();
- tracing::trace!(
- target: "weaver::render",
- para_idx = i,
- char_range = ?char_range,
- byte_range = ?byte_range,
- preview = %preview,
- "paragraph boundary"
- );
- }
- }
-
- // Build paragraphs from render results
- let build_start = crate::perf::now();
- let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
- let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
- let mut all_refs: Vec = Vec::new();
- // next_para_id must account for all IDs allocated by the writer
- let next_para_id = parsed_para_id_start + parsed_para_count;
- let reused_count = reused_paragraphs.len();
-
- // Find which paragraph contains cursor (for stable ID assignment)
- let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| {
- char_range.start <= cursor_offset && cursor_offset <= char_range.end
- });
-
- tracing::trace!(
+ // Call the core rendering function.
+ let result = weaver_editor_core::render_paragraphs_incremental(
+ &adapter,
+ cache,
cursor_offset,
- ?cursor_para_idx,
- edit_char_pos = ?edit.map(|e| e.edit_char_pos),
- reused_count,
- parsed_count = parsed_paragraph_ranges.len(),
- "ID assignment: cursor and edit info"
+ edit,
+ image_resolver,
+ entry_index,
+ resolved_content,
);
- for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
- let para_source = text_slice_to_string(text, char_range.clone());
- let source_hash = hash_source(¶_source);
- let is_cursor_para = Some(idx) == cursor_para_idx;
-
- // Check if this is a reused paragraph or a freshly parsed one
- let is_reused = idx < reused_count;
-
- // ID assignment depends on whether this is reused or freshly parsed
- let para_id = if is_reused {
- // Reused paragraph: keep its existing ID (HTML already has matching prefixes)
- reused_paragraphs[idx].id.clone()
- } else {
- // Freshly parsed: ID MUST match what the writer used for node ID prefixes
- let parsed_idx = idx - reused_count;
-
- // Check if this is the cursor paragraph with an override
- let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override {
- if parsed_idx == override_idx {
- // Use the override prefix (matches what writer used)
- override_prefix.clone()
- } else {
- // Use auto-incremented ID (matches what writer used)
- make_paragraph_id(parsed_para_id_start + parsed_idx)
- }
- } else {
- // No override, use auto-incremented ID
- make_paragraph_id(parsed_para_id_start + parsed_idx)
- };
-
- if idx < 3 || is_cursor_para {
- tracing::trace!(
- idx,
- parsed_idx,
- is_cursor_para,
- para_id = %id,
- "slow path: assigned paragraph ID"
- );
- }
-
- id
- };
-
- // Get data either from reused cache or from fresh parse
- let (html, offset_map, syntax_spans, para_refs) = if is_reused {
- // Reused from cache - take directly
- let reused = &reused_paragraphs[idx];
- (
- reused.html.clone(),
- reused.offset_map.clone(),
- reused.syntax_spans.clone(),
- reused.collected_refs.clone(),
- )
- } else {
- // Freshly parsed - get from writer_result with offset adjustment
- let parsed_idx = idx - reused_count;
- let html = writer_result
- .html_segments
- .get(parsed_idx)
- .cloned()
- .unwrap_or_default();
-
- // Adjust offset maps to document-absolute positions
- let mut offset_map = writer_result
- .offset_maps_by_paragraph
- .get(parsed_idx)
- .cloned()
- .unwrap_or_default();
- for m in &mut offset_map {
- m.char_range.start += parse_start_char;
- m.char_range.end += parse_start_char;
- m.byte_range.start += parse_start_byte;
- m.byte_range.end += parse_start_byte;
- }
-
- // Adjust syntax spans to document-absolute positions
- let mut syntax_spans = writer_result
- .syntax_spans_by_paragraph
- .get(parsed_idx)
- .cloned()
- .unwrap_or_default();
- for s in &mut syntax_spans {
- s.adjust_positions(parse_start_char as isize);
- }
-
- let para_refs = writer_result
- .collected_refs_by_paragraph
- .get(parsed_idx)
- .cloned()
- .unwrap_or_default();
- (html, offset_map, syntax_spans, para_refs)
- };
-
- all_refs.extend(para_refs.clone());
-
- // Store in cache
- new_cached.push(CachedParagraph {
- id: para_id.clone(),
- source_hash,
- byte_range: byte_range.clone(),
- char_range: char_range.clone(),
- html: html.clone(),
- offset_map: offset_map.clone(),
- syntax_spans: syntax_spans.clone(),
- collected_refs: para_refs.clone(),
- });
-
- paragraphs.push(ParagraphRender {
- id: para_id,
- byte_range: byte_range.clone(),
- char_range: char_range.clone(),
- html,
- offset_map,
- syntax_spans,
- source_hash,
- });
- }
-
- let build_ms = crate::perf::now() - build_start;
- tracing::trace!(
- render_ms,
- build_ms,
- paragraphs = paragraph_ranges.len(),
- "single-pass render timing"
- );
-
- let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source);
-
- let new_cache = RenderCache {
- paragraphs: new_cached,
- next_node_id: 0, // Not used in single-pass mode
- next_syn_id: 0, // Not used in single-pass mode
- next_para_id,
- };
-
let total_ms = crate::perf::now() - fn_start;
tracing::debug!(
total_ms,
- render_ms,
- build_ms,
- paragraphs = paragraphs_with_gaps.len(),
+ paragraphs = result.paragraphs.len(),
"render_paragraphs_incremental timing"
);
- (paragraphs_with_gaps, new_cache, all_refs)
+ (result.paragraphs, result.cache, result.collected_refs)
}
diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs
index b9c801b..8972663 100644
--- a/crates/weaver-app/src/components/editor/visibility.rs
+++ b/crates/weaver-app/src/components/editor/visibility.rs
@@ -1,472 +1,9 @@
//! Conditional syntax visibility based on cursor position.
//!
-//! Implements Obsidian-style formatting character visibility: syntax markers
-//! are hidden when cursor is not near them, revealed when cursor approaches.
+//! Re-exports core visibility logic and browser DOM updates.
-use weaver_editor_core::SmolStr;
+// Core visibility calculation.
+pub use weaver_editor_core::VisibilityState;
-use super::document::Selection;
-use super::paragraph::ParagraphRender;
-use super::writer::{SyntaxSpanInfo, SyntaxType};
-use std::collections::HashSet;
-use std::ops::Range;
-
-/// Determines which syntax spans should be visible based on cursor/selection.
-#[derive(Debug, Clone, Default)]
-pub struct VisibilityState {
- /// Set of syn_ids that should be visible
- pub visible_span_ids: HashSet,
-}
-
-impl VisibilityState {
- /// Calculate visibility based on cursor position and selection.
- pub fn calculate(
- cursor_offset: usize,
- selection: Option<&Selection>,
- syntax_spans: &[SyntaxSpanInfo],
- paragraphs: &[ParagraphRender],
- ) -> Self {
- let mut visible = HashSet::new();
-
- for span in syntax_spans {
- // Find the paragraph containing this span for boundary clamping
- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
-
- let should_show = match span.syntax_type {
- SyntaxType::Inline => {
- // Show if cursor within formatted span content OR adjacent to markers
- // "Adjacent" means within 1 char of the syntax boundaries,
- // clamped to paragraph bounds (paragraphs are split by newlines,
- // so clamping to para bounds prevents cross-line extension)
- let extended_start =
- safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
- let extended_end =
- safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
- let extended_range = extended_start..extended_end;
-
- // Also show if cursor is anywhere in the formatted_range
- // (the region between paired opening/closing markers)
- // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
- // clamped to paragraph bounds.
- let in_formatted_region = span
- .formatted_range
- .as_ref()
- .map(|r| {
- let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
- let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
- cursor_offset >= ext_start && cursor_offset <= ext_end
- })
- .unwrap_or(false);
-
- let in_extended = extended_range.contains(&cursor_offset);
- let result = in_extended
- || in_formatted_region
- || selection_overlaps(selection, &span.char_range)
- || span
- .formatted_range
- .as_ref()
- .map(|r| selection_overlaps(selection, r))
- .unwrap_or(false);
-
- result
- }
- SyntaxType::Block => {
- // Show if cursor anywhere in same paragraph (with slop for edge cases)
- // The slop handles typing at the end of a heading like "# |"
- let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
- let in_paragraph = para_bounds
- .as_ref()
- .map(|p| {
- // Extend paragraph bounds by 1 char on each side for slop
- let ext_start = p.start.saturating_sub(1);
- let ext_end = p.end.saturating_add(1);
- cursor_offset >= ext_start && cursor_offset <= ext_end
- })
- .unwrap_or(false);
-
- in_paragraph || selection_overlaps(selection, &span.char_range)
- }
- };
-
- if should_show {
- visible.insert(span.syn_id.clone());
- }
- }
-
- tracing::debug!(
- target: "weaver::visibility",
- cursor_offset,
- total_spans = syntax_spans.len(),
- visible_count = visible.len(),
- "calculated visibility"
- );
-
- Self {
- visible_span_ids: visible,
- }
- }
-
- /// Check if a specific span should be visible.
- pub fn is_visible(&self, syn_id: &str) -> bool {
- self.visible_span_ids.contains(syn_id)
- }
-}
-
-/// Check if selection overlaps with a char range.
-fn selection_overlaps(selection: Option<&Selection>, range: &Range) -> bool {
- let Some(sel) = selection else {
- return false;
- };
-
- let sel_start = sel.anchor.min(sel.head);
- let sel_end = sel.anchor.max(sel.head);
-
- // Check if ranges overlap
- sel_start < range.end && sel_end > range.start
-}
-
-/// Check if cursor is in the same paragraph as a syntax span.
-#[allow(dead_code)]
-fn cursor_in_same_paragraph(
- cursor_offset: usize,
- syntax_range: &Range,
- paragraphs: &[ParagraphRender],
-) -> bool {
- // Find which paragraph contains the syntax span
- for para in paragraphs {
- // Skip gap paragraphs (they have no syntax spans)
- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
- continue;
- }
-
- // Check if this paragraph contains the syntax span
- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
- // Check if cursor is also in this paragraph
- return para.char_range.contains(&cursor_offset);
- }
- }
-
- false
-}
-
-/// Find the paragraph bounds containing a syntax span.
-fn find_paragraph_bounds(
- syntax_range: &Range,
- paragraphs: &[ParagraphRender],
-) -> Option> {
- for para in paragraphs {
- // Skip gap paragraphs
- if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
- continue;
- }
-
- if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
- return Some(para.char_range.clone());
- }
- }
- None
-}
-
-/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
-///
-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
-/// naturally prevents extending across line boundaries.
-fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize {
- let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
- pos.saturating_sub(amount).max(min_pos)
-}
-
-/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
-///
-/// Paragraphs are already split by newlines, so clamping to paragraph bounds
-/// naturally prevents extending across line boundaries.
-fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize {
- let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
- pos.saturating_add(amount).min(max_pos)
-}
-
-/// Update syntax span visibility in the DOM based on cursor position.
-///
-/// Toggles the "hidden" class on syntax spans based on calculated visibility.
-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-pub fn update_syntax_visibility(
- cursor_offset: usize,
- selection: Option<&Selection>,
- syntax_spans: &[SyntaxSpanInfo],
- paragraphs: &[ParagraphRender],
-) {
- use wasm_bindgen::JsCast;
-
- let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
-
- let Some(window) = web_sys::window() else {
- return;
- };
- let Some(document) = window.document() else {
- return;
- };
-
- // Single querySelectorAll instead of N individual queries
- let Ok(node_list) = document.query_selector_all("[data-syn-id]") else {
- return;
- };
-
- for i in 0..node_list.length() {
- let Some(node) = node_list.item(i) else {
- continue;
- };
-
- // Cast to Element to access attributes and class_list
- let Some(element) = node.dyn_ref::() else {
- continue;
- };
-
- let Some(syn_id) = element.get_attribute("data-syn-id") else {
- continue;
- };
-
- let class_list = element.class_list();
- if visibility.is_visible(&syn_id) {
- let _ = class_list.remove_1("hidden");
- } else {
- let _ = class_list.add_1("hidden");
- }
- }
-}
-
-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
-pub fn update_syntax_visibility(
- _cursor_offset: usize,
- _selection: Option<&Selection>,
- _syntax_spans: &[SyntaxSpanInfo],
- _paragraphs: &[ParagraphRender],
-) {
- // No-op on non-wasm
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn make_span(
- syn_id: &str,
- start: usize,
- end: usize,
- syntax_type: SyntaxType,
- ) -> SyntaxSpanInfo {
- SyntaxSpanInfo {
- syn_id: syn_id.into(),
- char_range: start..end,
- syntax_type,
- formatted_range: None,
- }
- }
-
- fn make_span_with_range(
- syn_id: &str,
- start: usize,
- end: usize,
- syntax_type: SyntaxType,
- formatted_range: Range,
- ) -> SyntaxSpanInfo {
- SyntaxSpanInfo {
- syn_id: syn_id.into(),
- char_range: start..end,
- syntax_type,
- formatted_range: Some(formatted_range),
- }
- }
-
- fn make_para(start: usize, end: usize, syntax_spans: Vec) -> ParagraphRender {
- ParagraphRender {
- id: format!("test-{}-{}", start, end),
- byte_range: start..end,
- char_range: start..end,
- html: String::new(),
- offset_map: vec![],
- syntax_spans,
- source_hash: 0,
- }
- }
-
- #[test]
- fn test_inline_visibility_cursor_inside() {
- // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
- // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
- // formatted_range is 0..8 (the whole **bold** region)
- let spans = vec![
- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
- ];
- let paras = vec![make_para(0, 8, spans.clone())];
-
- // Cursor at position 4 (middle of "bold", inside formatted region)
- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "opening ** should be visible when cursor inside formatted region"
- );
- assert!(
- vis.is_visible("s1"),
- "closing ** should be visible when cursor inside formatted region"
- );
-
- // Cursor at position 2 (adjacent to opening **, start of "bold")
- let vis = VisibilityState::calculate(2, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "opening ** should be visible when cursor adjacent at start of bold"
- );
-
- // Cursor at position 5 (adjacent to closing **, end of "bold")
- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
- assert!(
- vis.is_visible("s1"),
- "closing ** should be visible when cursor adjacent at end of bold"
- );
- }
-
- #[test]
- fn test_inline_visibility_without_formatted_range() {
- // Test without formatted_range - just adjacency-based visibility
- let spans = vec![
- make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
- make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
- ];
- let paras = vec![make_para(0, 8, spans.clone())];
-
- // Cursor at position 4 (middle of "bold", not adjacent to either marker)
- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
- assert!(
- !vis.is_visible("s0"),
- "opening ** should be hidden when no formatted_range and cursor not adjacent"
- );
- assert!(
- !vis.is_visible("s1"),
- "closing ** should be hidden when no formatted_range and cursor not adjacent"
- );
- }
-
- #[test]
- fn test_inline_visibility_cursor_adjacent() {
- // "test **bold** after"
- // 5 7
- let spans = vec![
- make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
- ];
- let paras = vec![make_para(0, 19, spans.clone())];
-
- // Cursor at position 4 (one before ** which starts at 5)
- let vis = VisibilityState::calculate(4, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "** should be visible when cursor adjacent"
- );
-
- // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
- let vis = VisibilityState::calculate(7, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "** should be visible when cursor adjacent after span"
- );
- }
-
- #[test]
- fn test_inline_visibility_cursor_far() {
- let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
- let paras = vec![make_para(0, 33, spans.clone())];
-
- // Cursor at position 0 (far from **)
- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
- assert!(
- !vis.is_visible("s0"),
- "** should be hidden when cursor far away"
- );
- }
-
- #[test]
- fn test_block_visibility_same_paragraph() {
- // # at start of heading
- let spans = vec![
- make_span("s0", 0, 2, SyntaxType::Block), // "# "
- ];
- let paras = vec![
- make_para(0, 10, spans.clone()), // heading paragraph
- make_para(12, 30, vec![]), // next paragraph
- ];
-
- // Cursor at position 5 (inside heading)
- let vis = VisibilityState::calculate(5, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "# should be visible when cursor in same paragraph"
- );
- }
-
- #[test]
- fn test_block_visibility_different_paragraph() {
- let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
- let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
-
- // Cursor at position 20 (in second paragraph)
- let vis = VisibilityState::calculate(20, None, &spans, ¶s);
- assert!(
- !vis.is_visible("s0"),
- "# should be hidden when cursor in different paragraph"
- );
- }
-
- #[test]
- fn test_selection_reveals_syntax() {
- let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
- let paras = vec![make_para(0, 24, spans.clone())];
-
- // Selection overlaps the syntax span
- let selection = Selection {
- anchor: 3,
- head: 10,
- };
- let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "** should be visible when selection overlaps"
- );
- }
-
- #[test]
- fn test_paragraph_boundary_blocks_extension() {
- // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
- // even if cursor is only 1 char after the paragraph boundary
- // (paragraph bounds clamp the extension)
- let spans = vec![
- make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
- make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
- ];
- let paras = vec![
- make_para(0, 8, spans.clone()), // "**bold**"
- make_para(9, 13, vec![]), // "text" (after newline)
- ];
-
- // Cursor at position 9 (start of second paragraph)
- // Should NOT reveal the closing ** because para bounds clamp extension
- let vis = VisibilityState::calculate(9, None, &spans, ¶s);
- assert!(
- !vis.is_visible("s1"),
- "closing ** should NOT be visible when cursor is in next paragraph"
- );
- }
-
- #[test]
- fn test_extension_clamps_to_paragraph() {
- // Syntax at very start of paragraph - extension left should stop at para start
- let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
- let paras = vec![make_para(0, 8, spans.clone())];
-
- // Cursor at position 0 - should still see the opening **
- let vis = VisibilityState::calculate(0, None, &spans, ¶s);
- assert!(
- vis.is_visible("s0"),
- "** at start should be visible when cursor at position 0"
- );
- }
-}
+// Browser DOM updates.
+pub use weaver_editor_browser::update_syntax_visibility;
diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs
index fbd092c..25c60ff 100644
--- a/crates/weaver-editor-browser/src/events.rs
+++ b/crates/weaver-editor-browser/src/events.rs
@@ -255,3 +255,194 @@ pub async fn read_clipboard_text() -> Result