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, JsValue> { Ok(result.as_string()) } + +// === BeforeInput handler === + +use weaver_editor_core::{EditorAction, EditorDocument, execute_action}; + +/// Handle a beforeinput event, dispatching to the appropriate action. +/// +/// This is the main entry point for beforeinput-based input handling. +/// The `current_range` parameter should be the current cursor/selection range +/// from the document when `ctx.target_range` is None. +/// +/// Returns the handling result indicating whether default should be prevented. +pub fn handle_beforeinput( + doc: &mut D, + ctx: &BeforeInputContext<'_>, + current_range: Range, +) -> BeforeInputResult { + // During composition, let the browser handle most things. + if ctx.is_composing { + match ctx.input_type { + InputType::HistoryUndo | InputType::HistoryRedo => { + // Handle undo/redo even during composition. + } + InputType::InsertCompositionText => { + return BeforeInputResult::PassThrough; + } + _ => { + return BeforeInputResult::PassThrough; + } + } + } + + // Use target range from event, or fall back to current range. + let range = ctx.target_range.unwrap_or(current_range); + + match ctx.input_type { + // === Insertion === + InputType::InsertText => { + if let Some(ref text) = ctx.data { + let action = EditorAction::Insert { + text: text.clone(), + range, + }; + execute_action(doc, &action); + BeforeInputResult::Handled + } else { + BeforeInputResult::PassThrough + } + } + + InputType::InsertLineBreak => { + let action = EditorAction::InsertLineBreak { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::InsertParagraph => { + let action = EditorAction::InsertParagraph { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::InsertFromPaste | InputType::InsertReplacementText => { + if let Some(ref text) = ctx.data { + let action = EditorAction::Insert { + text: text.clone(), + range, + }; + execute_action(doc, &action); + BeforeInputResult::Handled + } else { + BeforeInputResult::PassThrough + } + } + + InputType::InsertFromDrop => BeforeInputResult::PassThrough, + + InputType::InsertCompositionText => BeforeInputResult::PassThrough, + + // === Deletion === + InputType::DeleteContentBackward => { + // Android Chrome workaround: backspace sometimes doesn't work properly. + if ctx.is_android && ctx.is_chrome && range.is_caret() { + let action = EditorAction::DeleteBackward { range }; + return BeforeInputResult::DeferredCheck { + fallback_action: action, + }; + } + + let action = EditorAction::DeleteBackward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteContentForward => { + let action = EditorAction::DeleteForward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { + let action = EditorAction::DeleteWordBackward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteWordForward | InputType::DeleteEntireWordForward => { + let action = EditorAction::DeleteWordForward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteSoftLineBackward => { + let action = EditorAction::DeleteSoftLineBackward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteSoftLineForward => { + let action = EditorAction::DeleteSoftLineForward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteHardLineBackward => { + let action = EditorAction::DeleteToLineStart { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteHardLineForward => { + let action = EditorAction::DeleteToLineEnd { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } + + InputType::DeleteByCut => { + if !range.is_caret() { + let action = EditorAction::DeleteBackward { range }; + execute_action(doc, &action); + } + BeforeInputResult::Handled + } + + InputType::DeleteByDrag | InputType::DeleteContent => { + if !range.is_caret() { + let action = EditorAction::DeleteBackward { range }; + execute_action(doc, &action); + } + BeforeInputResult::Handled + } + + // === History === + InputType::HistoryUndo => { + execute_action(doc, &EditorAction::Undo); + BeforeInputResult::Handled + } + + InputType::HistoryRedo => { + execute_action(doc, &EditorAction::Redo); + BeforeInputResult::Handled + } + + // === Formatting === + InputType::FormatBold => { + execute_action(doc, &EditorAction::ToggleBold); + BeforeInputResult::Handled + } + + InputType::FormatItalic => { + execute_action(doc, &EditorAction::ToggleItalic); + BeforeInputResult::Handled + } + + InputType::FormatStrikethrough => { + execute_action(doc, &EditorAction::ToggleStrikethrough); + BeforeInputResult::Handled + } + + // === Not handled === + InputType::InsertFromYank + | InputType::InsertHorizontalRule + | InputType::InsertOrderedList + | InputType::InsertUnorderedList + | InputType::InsertLink + | InputType::FormatUnderline + | InputType::FormatSuperscript + | InputType::FormatSubscript + | InputType::Unknown(_) => BeforeInputResult::PassThrough, + } +} diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs index 6617e5b..98b46d6 100644 --- a/crates/weaver-editor-browser/src/lib.rs +++ b/crates/weaver-editor-browser/src/lib.rs @@ -24,6 +24,7 @@ pub mod cursor; pub mod dom_sync; pub mod events; pub mod platform; +pub mod visibility; // Browser cursor implementation pub use cursor::BrowserCursor; @@ -34,9 +35,12 @@ pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData}; // Event handling pub use events::{ BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, - get_input_type_from_event, get_target_range_from_event, is_composing, + get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, }; // Platform detection pub use platform::{Platform, platform}; + +// Visibility updates +pub use visibility::update_syntax_visibility; diff --git a/crates/weaver-editor-browser/src/visibility.rs b/crates/weaver-editor-browser/src/visibility.rs new file mode 100644 index 0000000..654cb33 --- /dev/null +++ b/crates/weaver-editor-browser/src/visibility.rs @@ -0,0 +1,90 @@ +//! DOM-based syntax visibility updates. +//! +//! This module applies visibility state to the DOM by toggling CSS classes +//! on syntax span elements. Works with the core `VisibilityState` calculation. +//! +//! # How it works +//! +//! 1. Core's `VisibilityState::calculate()` determines which syntax spans should be visible +//! 2. This module's `update_syntax_visibility()` applies that state to the DOM +//! 3. Elements with `data-syn-id` attributes get "hidden" class toggled +//! +//! # CSS Integration +//! +//! Your CSS should hide elements with the "hidden" class: +//! ```css +//! [data-syn-id].hidden { +//! opacity: 0; +//! /* or display: none, visibility: hidden, etc. */ +//! } +//! ``` + +use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState}; + +/// Update syntax span visibility in the DOM based on cursor position. +/// +/// Calculates which syntax spans should be visible using `VisibilityState::calculate()`, +/// then toggles the "hidden" class on matching DOM elements. +/// +/// # Parameters +/// - `cursor_offset`: Current cursor position in characters +/// - `selection`: Optional text selection +/// - `syntax_spans`: All syntax spans from rendered paragraphs +/// - `paragraphs`: Rendered paragraph data for boundary detection +/// +/// # DOM Requirements +/// Syntax span elements must have `data-syn-id` attributes matching `SyntaxSpanInfo.syn_id`. +#[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; + }; + + 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"); + } + } +} + +/// No-op on non-WASM targets. +#[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], +) { +} diff --git a/crates/weaver-editor-browser/tests/web.rs b/crates/weaver-editor-browser/tests/web.rs new file mode 100644 index 0000000..1142964 --- /dev/null +++ b/crates/weaver-editor-browser/tests/web.rs @@ -0,0 +1,212 @@ +//! WASM browser tests for weaver-editor-browser. +//! +//! Run with: `wasm-pack test --headless --firefox` or `--chrome` + +use wasm_bindgen_test::*; + +wasm_bindgen_test_configure!(run_in_browser); + +use weaver_editor_browser::{ + BeforeInputContext, BeforeInputResult, InputType, Range, handle_beforeinput, + parse_browser_input_type, platform, +}; +use weaver_editor_core::{EditorDocument, EditorRope, PlainEditor, UndoableBuffer}; + +type TestEditor = PlainEditor>; + +fn make_editor(content: &str) -> TestEditor { + let rope = EditorRope::from_str(content); + let buf = UndoableBuffer::new(rope, 100); + PlainEditor::new(buf) +} + +// === InputType parsing tests === + +#[wasm_bindgen_test] +fn test_parse_insert_text() { + assert_eq!( + parse_browser_input_type("insertText"), + InputType::InsertText + ); +} + +#[wasm_bindgen_test] +fn test_parse_delete_backward() { + assert_eq!( + parse_browser_input_type("deleteContentBackward"), + InputType::DeleteContentBackward + ); +} + +#[wasm_bindgen_test] +fn test_parse_unknown() { + match parse_browser_input_type("unknownType") { + InputType::Unknown(s) => assert_eq!(s, "unknownType"), + _ => panic!("Expected Unknown variant"), + } +} + +// === Platform detection tests === + +#[wasm_bindgen_test] +fn test_platform_detection() { + let plat = platform(); + // Just verify it returns something without panicking. + // Actual values depend on the browser running the test. + let _ = plat.mac; + let _ = plat.safari; + let _ = plat.chrome; + let _ = plat.gecko; + let _ = plat.android; + let _ = plat.ios; + let _ = plat.mobile; +} + +// === BeforeInput handler tests === + +#[wasm_bindgen_test] +fn test_handle_insert_text() { + let mut editor = make_editor("hello"); + editor.set_cursor_offset(5); + + let ctx = BeforeInputContext { + input_type: InputType::InsertText, + data: Some(" world".to_string()), + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); + assert!(matches!(result, BeforeInputResult::Handled)); + assert_eq!(editor.content_string(), "hello world"); +} + +#[wasm_bindgen_test] +fn test_handle_delete_backward() { + let mut editor = make_editor("hello"); + editor.set_cursor_offset(5); + + let ctx = BeforeInputContext { + input_type: InputType::DeleteContentBackward, + data: None, + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); + assert!(matches!(result, BeforeInputResult::Handled)); + assert_eq!(editor.content_string(), "hell"); +} + +#[wasm_bindgen_test] +fn test_handle_composition_passthrough() { + let mut editor = make_editor("hello"); + + let ctx = BeforeInputContext { + input_type: InputType::InsertText, + data: Some("x".to_string()), + target_range: None, + is_composing: true, // During composition + is_android: false, + is_chrome: false, + offset_map: &[], + }; + + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); + assert!(matches!(result, BeforeInputResult::PassThrough)); + // Document unchanged during composition passthrough. + assert_eq!(editor.content_string(), "hello"); +} + +#[wasm_bindgen_test] +fn test_handle_undo_redo() { + let mut editor = make_editor("hello"); + editor.set_cursor_offset(5); + + // Insert text first. + let insert_ctx = BeforeInputContext { + input_type: InputType::InsertText, + data: Some(" world".to_string()), + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + handle_beforeinput(&mut editor, &insert_ctx, Range::caret(5)); + assert_eq!(editor.content_string(), "hello world"); + + // Undo. + let undo_ctx = BeforeInputContext { + input_type: InputType::HistoryUndo, + data: None, + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + let result = handle_beforeinput(&mut editor, &undo_ctx, Range::caret(11)); + assert!(matches!(result, BeforeInputResult::Handled)); + assert_eq!(editor.content_string(), "hello"); + + // Redo. + let redo_ctx = BeforeInputContext { + input_type: InputType::HistoryRedo, + data: None, + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + let result = handle_beforeinput(&mut editor, &redo_ctx, Range::caret(5)); + assert!(matches!(result, BeforeInputResult::Handled)); + assert_eq!(editor.content_string(), "hello world"); +} + +#[wasm_bindgen_test] +fn test_handle_insert_paragraph() { + let mut editor = make_editor("hello"); + editor.set_cursor_offset(5); + + let ctx = BeforeInputContext { + input_type: InputType::InsertParagraph, + data: None, + target_range: None, + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); + assert!(matches!(result, BeforeInputResult::Handled)); + // InsertParagraph inserts double newline. + assert!(editor.content_string().contains("\n\n")); +} + +#[wasm_bindgen_test] +fn test_handle_selection_delete() { + let mut editor = make_editor("hello world"); + + let ctx = BeforeInputContext { + input_type: InputType::DeleteContentBackward, + data: None, + target_range: Some(Range::new(5, 11)), // Select " world" + is_composing: false, + is_android: false, + is_chrome: false, + offset_map: &[], + }; + + let result = handle_beforeinput(&mut editor, &ctx, Range::new(5, 11)); + assert!(matches!(result, BeforeInputResult::Handled)); + assert_eq!(editor.content_string(), "hello"); +} diff --git a/crates/weaver-editor-core/src/actions.rs b/crates/weaver-editor-core/src/actions.rs index e9e48df..f3ee9f5 100644 --- a/crates/weaver-editor-core/src/actions.rs +++ b/crates/weaver-editor-core/src/actions.rs @@ -181,6 +181,26 @@ impl InputType { } } +/// High-level formatting actions for markdown editing. +/// +/// These represent user-initiated formatting operations that wrap or modify +/// text with markdown syntax. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum FormatAction { + Bold, + Italic, + Strikethrough, + Code, + Link, + Image, + /// Heading level 1-6. + Heading(u8), + BulletList, + NumberedList, + Quote, +} + /// All possible editor actions. /// /// These represent semantic operations on the document, decoupled from diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index 4a92da4..088b096 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod offset_map; pub mod paragraph; pub mod platform; pub mod render; +pub mod render_cache; pub mod syntax; pub mod text; pub mod text_helpers; @@ -43,7 +44,8 @@ pub use visibility::VisibilityState; pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; pub use platform::{CursorPlatform, CursorSync, PlatformError}; pub use actions::{ - EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, + EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, + Modifiers, Range, }; pub use execute::execute_action; pub use text_helpers::{ @@ -51,3 +53,7 @@ pub use text_helpers::{ find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, is_zero_width_char, }; +pub use render_cache::{ + CachedParagraph, IncrementalRenderResult, RenderCache, apply_delta, is_boundary_affecting, + render_paragraphs_incremental, +}; diff --git a/crates/weaver-editor-core/src/render_cache.rs b/crates/weaver-editor-core/src/render_cache.rs new file mode 100644 index 0000000..338d8bd --- /dev/null +++ b/crates/weaver-editor-core/src/render_cache.rs @@ -0,0 +1,757 @@ +//! Render caching and incremental paragraph rendering. +//! +//! This module provides infrastructure for incremental markdown rendering, +//! caching paragraph renders to avoid re-rendering unchanged content. + +use std::ops::Range; + +use smol_str::SmolStr; + +use crate::offset_map::OffsetMapping; +use crate::paragraph::{ParagraphRender, hash_source, make_paragraph_id}; +use crate::syntax::SyntaxSpanInfo; +use crate::text::TextBuffer; +use crate::types::EditInfo; +use crate::writer::EditorWriter; +use crate::{EditorRope, EmbedContentProvider, ImageResolver}; + +use markdown_weaver::Parser; +use weaver_common::ExtractedRef; + +/// 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, +} + +/// 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: SmolStr, + /// 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, +} + +/// 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. +pub fn is_boundary_affecting(edit: &EditInfo) -> bool { + // Newlines always affect boundaries (paragraph splits/joins). + if edit.contains_newline { + return true; + } + + // 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; + } + + false +} + +/// Apply a signed delta to a usize, saturating at 0 on underflow. +pub fn apply_delta(val: usize, delta: isize) -> usize { + if delta >= 0 { + val.saturating_add(delta as usize) + } else { + val.saturating_sub((-delta) as usize) + } +} + +/// Result of incremental paragraph rendering. +pub struct IncrementalRenderResult { + /// Rendered paragraphs. + pub paragraphs: Vec, + /// Updated cache for next render. + pub cache: RenderCache, + /// Collected refs (wikilinks, AT embeds) found during render. + pub collected_refs: Vec, +} + +/// Render markdown with incremental caching. +/// +/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. +/// Generic over any `TextBuffer` implementation. +/// +/// # Parameters +/// - `text`: The text buffer to render +/// - `cache`: Optional previous render cache +/// - `cursor_offset`: Current cursor position (for finding which NEW paragraph is the cursor para) +/// - `edit`: Edit info for stable ID assignment +/// - `image_resolver`: Optional image URL resolver +/// - `entry_index`: Optional index for wikilink validation +/// - `embed_provider`: Provider for embed content +/// +/// # Returns +/// `IncrementalRenderResult` containing paragraphs, updated cache, and collected refs. +pub fn render_paragraphs_incremental( + text: &T, + cache: Option<&RenderCache>, + cursor_offset: usize, + edit: Option<&EditInfo>, + image_resolver: Option<&I>, + entry_index: Option<&weaver_common::EntryIndex>, + embed_provider: &E, +) -> IncrementalRenderResult +where + T: TextBuffer, + I: ImageResolver + Clone + Default, + E: EmbedContentProvider, +{ + 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 IncrementalRenderResult { + paragraphs: vec![para], + cache: new_cache, + collected_refs: vec![], + }; + } + + // Determine if we can use fast path (skip boundary discovery). + let current_len = text.len_chars(); + let current_byte_len = text.len_bytes(); + + // 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) { + 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(); + return IncrementalRenderResult { + paragraphs, + cache: c.clone(), + collected_refs: 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(); + + let edit_pos = edit.edit_char_pos; + + 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; + + cache + .paragraphs + .iter() + .map(|p| { + if p.char_range.end < edit_pos { + (p.byte_range.clone(), p.char_range.clone()) + } else if p.char_range.start > edit_pos { + ( + 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 { + ( + 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![] + }; + + // Validate fast path results. + 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 ============ + if use_fast_path { + let cache = cache.unwrap(); + let edit = edit.unwrap(); + let edit_pos = edit.edit_char_pos; + + 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; + + 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; + + let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { + ( + cached_para.byte_range.clone(), + cached_para.char_range.clone(), + ) + } else if cached_para.char_range.start > edit_pos { + ( + 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 { + ( + 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(char_range.clone()) + .map(|s| s.to_string()) + .unwrap_or_default(); + 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::<_, _, &E, &I, ()>::new( + ¶_source, + ¶_rope, + parser, + ) + .with_node_id_prefix(&cached_para.id) + .with_image_resolver(&resolver) + .with_embed_provider(embed_provider); + + 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) => { + 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 { + 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, + }); + } + } + + let new_cache = RenderCache { + paragraphs: new_cached, + next_node_id: 0, + next_syn_id: 0, + next_para_id: cache.next_para_id, + }; + + return IncrementalRenderResult { + paragraphs, + cache: new_cache, + collected_refs: all_refs, + }; + } + + // ============ SLOW PATH ============ + // Partial render: reuse cached paragraphs before edit, parse from affected to end. + + let (reused_paragraphs, parse_start_byte, parse_start_char) = + if let (Some(c), Some(e)) = (cache, 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 { + 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 { + 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 { + (Vec::new(), 0, 0) + } + } else { + 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 { + (Vec::new(), 0, 0) + }; + + let parse_slice = &source[parse_start_byte..]; + let parser = + Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); + + let resolver = image_resolver.cloned().unwrap_or_default(); + let slice_rope = EditorRope::from(parse_slice); + + let reused_count = reused_paragraphs.len(); + 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" + ); + + let cursor_para_override: Option<(usize, SmolStr)> = cache.and_then(|c| { + let cached_cursor_idx = c.paragraphs.iter().position(|p| { + p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end + })?; + + if cached_cursor_idx < reused_count { + return None; + } + + 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())) + }); + + let mut writer = EditorWriter::<_, _, &E, &I, ()>::new(parse_slice, &slice_rope, parser) + .with_auto_incrementing_prefix(parsed_para_id_start) + .with_image_resolver(&resolver) + .with_embed_provider(embed_provider); + + 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 IncrementalRenderResult { + paragraphs: Vec::new(), + cache: RenderCache::default(), + collected_refs: vec![], + } + } + }; + + let parsed_para_count = writer_result.paragraph_ranges.len(); + + 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(); + + let paragraph_ranges: Vec<_> = reused_paragraphs + .iter() + .map(|p| (p.byte_range.clone(), p.char_range.clone())) + .chain(parsed_paragraph_ranges.clone()) + .collect(); + + if tracing::enabled!(tracing::Level::TRACE) { + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { + let preview: String = text + .slice(char_range.clone()) + .map(|s| s.chars().take(30).collect()) + .unwrap_or_default(); + tracing::trace!( + target: "weaver::render", + para_idx = i, + char_range = ?char_range, + byte_range = ?byte_range, + preview = %preview, + "paragraph boundary" + ); + } + } + + 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(); + let next_para_id = parsed_para_id_start + parsed_para_count; + let reused_count = reused_paragraphs.len(); + + let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { + char_range.start <= cursor_offset && cursor_offset <= char_range.end + }); + + tracing::trace!( + 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" + ); + + for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { + let para_source = text + .slice(char_range.clone()) + .map(|s| s.to_string()) + .unwrap_or_default(); + let source_hash = hash_source(¶_source); + let is_cursor_para = Some(idx) == cursor_para_idx; + + let is_reused = idx < reused_count; + + let para_id = if is_reused { + reused_paragraphs[idx].id.clone() + } else { + let parsed_idx = idx - reused_count; + + let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override { + if parsed_idx == override_idx { + override_prefix.clone() + } else { + make_paragraph_id(parsed_para_id_start + parsed_idx) + } + } else { + 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 + }; + + let (html, offset_map, syntax_spans, para_refs) = if is_reused { + let reused = &reused_paragraphs[idx]; + ( + reused.html.clone(), + reused.offset_map.clone(), + reused.syntax_spans.clone(), + reused.collected_refs.clone(), + ) + } else { + let parsed_idx = idx - reused_count; + let html = writer_result + .html_segments + .get(parsed_idx) + .cloned() + .unwrap_or_default(); + + 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; + } + + 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()); + + 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 new_cache = RenderCache { + paragraphs: new_cached, + next_node_id: 0, + next_syn_id: 0, + next_para_id, + }; + + IncrementalRenderResult { + paragraphs, + cache: new_cache, + collected_refs: all_refs, + } +} diff --git a/docs/graph-data.json b/docs/graph-data.json index 39d5c92..74d1ea3 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1352,6 +1352,160 @@ "created_at": "2026-01-06T12:01:52.561290209-05:00", "updated_at": "2026-01-06T12:01:52.561290209-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 125, + "change_id": "5d4e888e-42c7-4c48-ba82-d9f18206f6e0", + "node_type": "action", + "title": "Added KeybindingConfig and hyper/super modifiers to core", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:04:23.569321382-05:00", + "updated_at": "2026-01-06T12:04:23.569321382-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 126, + "change_id": "bd54a469-fa5c-43bb-9796-3b4ccbee356f", + "node_type": "observation", + "title": "Embed worker is network fetch + cache, not DOM. Misplaced in browser crate plan. Options: stay in weaver-app, own micro-crate, or browser crate with network feature", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:08:08.392667991-05:00", + "updated_at": "2026-01-06T12:08:08.392667991-05:00", + "metadata_json": "{\"confidence\":80}" + }, + { + "id": 127, + "change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "node_type": "decision", + "title": "Where should embed worker live?", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:09:01.110078211-05:00", + "updated_at": "2026-01-06T12:09:01.110078211-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 128, + "change_id": "761683dd-e0b9-48e3-9695-4b0ddedcd226", + "node_type": "option", + "title": "weaver-renderer - already has fetch_and_render", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:09:01.127567526-05:00", + "updated_at": "2026-01-06T12:09:01.127567526-05:00", + "metadata_json": "{\"confidence\":60}" + }, + { + "id": 129, + "change_id": "229d61f2-352b-4a12-8b58-8f5f9ff5ee49", + "node_type": "option", + "title": "Own crate weaver-embed-worker - clean separation", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:09:01.144794901-05:00", + "updated_at": "2026-01-06T12:09:01.144794901-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 130, + "change_id": "795d4e93-df31-4a55-af7a-d4aee1813d22", + "node_type": "outcome", + "title": "Browser crate is fairly complete - cursor, dom_sync, events with handle_beforeinput, platform. App has duplicates that need removal.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:13:55.962679951-05:00", + "updated_at": "2026-01-06T12:13:55.962679951-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 131, + "change_id": "29cd0f9f-a61a-4102-bc0f-04ff35735d30", + "node_type": "action", + "title": "Added WASM test infrastructure with 10 passing tests", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:16:55.818922231-05:00", + "updated_at": "2026-01-06T12:16:55.818922231-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 132, + "change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "node_type": "action", + "title": "Deduplicating weaver-app types by importing from core/browser crates", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:22:03.641188187-05:00", + "updated_at": "2026-01-06T12:22:03.641188187-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 133, + "change_id": "381f7585-0b04-4106-ae9c-7fac2baa2f6a", + "node_type": "observation", + "title": "Core has: Range, EditorAction, Key, Modifiers, KeyCombo, KeybindingConfig, KeydownResult, ParagraphRender, CursorState, Selection, VisibilityState, text_helpers. Browser has: Platform, StaticRange, BeforeInputResult, BeforeInputContext. App has massive duplication of all these.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:22:11.819592056-05:00", + "updated_at": "2026-01-06T12:22:11.819592056-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 134, + "change_id": "1e8412cc-95de-473c-9ac7-f96510e7eb46", + "node_type": "outcome", + "title": "Deduplicated actions.rs and paragraph.rs - imported types from core, SmolStr consistency established", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:30:26.244030599-05:00", + "updated_at": "2026-01-06T12:30:26.244030599-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 135, + "change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", + "node_type": "action", + "title": "Moved render_cache.rs to core - generic incremental rendering over TextBuffer", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:48:54.654141279-05:00", + "updated_at": "2026-01-06T12:48:54.654141279-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 136, + "change_id": "33d19ce9-56be-4894-95ee-ce4f8d7dbbc8", + "node_type": "outcome", + "title": "Core render_cache complete: RenderCache, CachedParagraph, apply_delta, is_boundary_affecting, render_paragraphs_incremental", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:48:54.777507129-05:00", + "updated_at": "2026-01-06T12:48:54.777507129-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 137, + "change_id": "a5094bcf-151d-462d-85e1-b8550e65a0eb", + "node_type": "action", + "title": "Deduplicated document.rs - removed local Selection, CompositionState, EditInfo, CursorState, Affinity, BLOCK_SYNTAX_ZONE", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:49:01.242741800-05:00", + "updated_at": "2026-01-06T12:49:01.242741800-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 138, + "change_id": "439402ae-17dc-467e-9bf3-04f32a9f4931", + "node_type": "action", + "title": "Deduplicated cursor.rs - removed local CursorRect, SelectionRect", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:49:01.362223737-05:00", + "updated_at": "2026-01-06T12:49:01.362223737-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -2850,6 +3004,138 @@ "weight": 1.0, "rationale": "Execute action implementation outcome", "created_at": "2026-01-06T12:01:52.577885198-05:00" + }, + { + "id": 138, + "from_node_id": 122, + "to_node_id": 125, + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", + "to_change_id": "5d4e888e-42c7-4c48-ba82-d9f18206f6e0", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Part of extraction work", + "created_at": "2026-01-06T12:04:23.698114449-05:00" + }, + { + "id": 139, + "from_node_id": 126, + "to_node_id": 127, + "from_change_id": "bd54a469-fa5c-43bb-9796-3b4ccbee356f", + "to_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Decision follows observation", + "created_at": "2026-01-06T12:09:01.163893115-05:00" + }, + { + "id": 140, + "from_node_id": 127, + "to_node_id": 128, + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "to_change_id": "761683dd-e0b9-48e3-9695-4b0ddedcd226", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Option A", + "created_at": "2026-01-06T12:09:05.444869523-05:00" + }, + { + "id": 141, + "from_node_id": 127, + "to_node_id": 129, + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "to_change_id": "229d61f2-352b-4a12-8b58-8f5f9ff5ee49", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Option B - preferred", + "created_at": "2026-01-06T12:09:05.461293286-05:00" + }, + { + "id": 142, + "from_node_id": 122, + "to_node_id": 130, + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", + "to_change_id": "795d4e93-df31-4a55-af7a-d4aee1813d22", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Extraction progress", + "created_at": "2026-01-06T12:13:55.979279744-05:00" + }, + { + "id": 143, + "from_node_id": 122, + "to_node_id": 131, + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", + "to_change_id": "29cd0f9f-a61a-4102-bc0f-04ff35735d30", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Test setup for browser crate", + "created_at": "2026-01-06T12:16:55.834996178-05:00" + }, + { + "id": 144, + "from_node_id": 120, + "to_node_id": 132, + "from_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", + "to_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Part of phase 1 extraction - removing app duplicates", + "created_at": "2026-01-06T12:22:11.802027921-05:00" + }, + { + "id": 145, + "from_node_id": 132, + "to_node_id": 134, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "1e8412cc-95de-473c-9ac7-f96510e7eb46", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Deduplication completed", + "created_at": "2026-01-06T12:30:26.260547276-05:00" + }, + { + "id": 146, + "from_node_id": 132, + "to_node_id": 135, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "render_cache migration is part of deduplication effort", + "created_at": "2026-01-06T12:49:01.496533815-05:00" + }, + { + "id": 147, + "from_node_id": 135, + "to_node_id": 136, + "from_change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", + "to_change_id": "33d19ce9-56be-4894-95ee-ce4f8d7dbbc8", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "render_cache action leads to outcome", + "created_at": "2026-01-06T12:49:06.880751070-05:00" + }, + { + "id": 148, + "from_node_id": 132, + "to_node_id": 137, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "a5094bcf-151d-462d-85e1-b8550e65a0eb", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "dedup effort", + "created_at": "2026-01-06T12:49:06.897304176-05:00" + }, + { + "id": 149, + "from_node_id": 132, + "to_node_id": 138, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "439402ae-17dc-467e-9bf3-04f32a9f4931", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "dedup effort", + "created_at": "2026-01-06T12:49:06.980327331-05:00" } ] } \ No newline at end of file