more browser extraction/refactoring/dedup

Orual 834bf8b1 735b7880

+1773 -2377
+1
Cargo.lock
··· 12113 12113 "wasm-bindgen-futures", 12114 12114 "weaver-api", 12115 12115 "weaver-common", 12116 + "weaver-editor-browser", 12116 12117 "weaver-editor-core", 12117 12118 "weaver-renderer", 12118 12119 "web-sys",
+1
crates/weaver-app/Cargo.toml
··· 49 49 dioxus = { version = "0.7.1", features = ["router"] } 50 50 weaver-common = { path = "../weaver-common" } 51 51 weaver-editor-core = { path = "../weaver-editor-core" } 52 + weaver-editor-browser = { path = "../weaver-editor-browser" } 52 53 jacquard = { workspace = true}#, features = ["streaming"] } 53 54 jacquard-lexicon = { workspace = true } 54 55 jacquard-identity = { workspace = true }
+111 -737
crates/weaver-app/src/components/editor/actions.rs
··· 1 1 //! Editor actions and keybinding system. 2 2 //! 3 - //! This module defines all editor operations as an enum, providing a clean 4 - //! abstraction layer between input events and document mutations. This enables: 5 - //! 6 - //! - Configurable keybindings (user can remap shortcuts) 7 - //! - Platform-specific defaults (Cmd vs Ctrl, etc.) 8 - //! - Consistent action handling regardless of input source 9 - //! - Potential for command palette, macros, etc. 10 - 11 - use std::collections::HashMap; 3 + //! This module re-exports core types and provides Dioxus-specific conversions 4 + //! and the concrete execute_action implementation for EditorDocument. 12 5 13 6 use dioxus::prelude::*; 14 - use jacquard::smol_str::SmolStr; 15 7 16 8 use super::document::EditorDocument; 17 9 use super::platform::Platform; 18 10 19 - /// A range in the document, measured in character offsets. 20 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 21 - pub struct Range { 22 - pub start: usize, 23 - pub end: usize, 24 - } 25 - 26 - impl Range { 27 - pub fn new(start: usize, end: usize) -> Self { 28 - Self { start, end } 29 - } 30 - 31 - pub fn caret(offset: usize) -> Self { 32 - Self { 33 - start: offset, 34 - end: offset, 35 - } 36 - } 37 - 38 - pub fn is_caret(&self) -> bool { 39 - self.start == self.end 40 - } 41 - 42 - pub fn len(&self) -> usize { 43 - self.end.saturating_sub(self.start) 44 - } 45 - 46 - #[allow(dead_code)] 47 - pub fn is_empty(&self) -> bool { 48 - self.len() == 0 49 - } 50 - 51 - /// Normalize range so start <= end. 52 - pub fn normalize(self) -> Self { 53 - if self.start <= self.end { 54 - self 55 - } else { 56 - Self { 57 - start: self.end, 58 - end: self.start, 59 - } 60 - } 61 - } 62 - } 63 - 64 - /// All possible editor actions. 65 - /// 66 - /// These represent semantic operations on the document, decoupled from 67 - /// how they're triggered (keyboard, mouse, touch, voice, etc.). 68 - #[derive(Debug, Clone, PartialEq)] 69 - #[allow(dead_code)] 70 - pub enum EditorAction { 71 - // === Text Insertion === 72 - /// Insert text at the given range (replacing any selected content). 73 - Insert { text: String, range: Range }, 74 - 75 - /// Insert a soft line break (Shift+Enter, `<br>` equivalent). 76 - InsertLineBreak { range: Range }, 77 - 78 - /// Insert a paragraph break (Enter). 79 - InsertParagraph { range: Range }, 80 - 81 - // === Deletion === 82 - /// Delete content backward (Backspace). 83 - DeleteBackward { range: Range }, 84 - 85 - /// Delete content forward (Delete key). 86 - DeleteForward { range: Range }, 87 - 88 - /// Delete word backward (Ctrl/Alt+Backspace). 89 - DeleteWordBackward { range: Range }, 90 - 91 - /// Delete word forward (Ctrl/Alt+Delete). 92 - DeleteWordForward { range: Range }, 93 - 94 - /// Delete to start of line (Cmd+Backspace on Mac). 95 - DeleteToLineStart { range: Range }, 96 - 97 - /// Delete to end of line (Cmd+Delete on Mac). 98 - DeleteToLineEnd { range: Range }, 99 - 100 - /// Delete to start of soft line (visual line in wrapped text). 101 - DeleteSoftLineBackward { range: Range }, 102 - 103 - /// Delete to end of soft line. 104 - DeleteSoftLineForward { range: Range }, 105 - 106 - // === History === 107 - /// Undo the last change. 108 - Undo, 109 - 110 - /// Redo the last undone change. 111 - Redo, 112 - 113 - // === Formatting === 114 - /// Toggle bold on selection. 115 - ToggleBold, 116 - 117 - /// Toggle italic on selection. 118 - ToggleItalic, 119 - 120 - /// Toggle inline code on selection. 121 - ToggleCode, 122 - 123 - /// Toggle strikethrough on selection. 124 - ToggleStrikethrough, 125 - 126 - /// Insert/wrap with link. 127 - InsertLink, 128 - 129 - // === Clipboard === 130 - /// Cut selection to clipboard. 131 - Cut, 132 - 133 - /// Copy selection to clipboard. 134 - Copy, 135 - 136 - /// Paste from clipboard at range. 137 - Paste { range: Range }, 138 - 139 - /// Copy selection as rendered HTML. 140 - CopyAsHtml, 141 - 142 - // === Selection === 143 - /// Select all content. 144 - SelectAll, 145 - 146 - // === Navigation (for command palette / programmatic use) === 147 - /// Move cursor to position. 148 - MoveCursor { offset: usize }, 149 - 150 - /// Extend selection to position. 151 - ExtendSelection { offset: usize }, 152 - } 153 - 154 - impl EditorAction { 155 - /// Update the range in actions that use one. 156 - /// Actions without a range are returned unchanged. 157 - pub fn with_range(self, range: Range) -> Self { 158 - match self { 159 - Self::Insert { text, .. } => Self::Insert { text, range }, 160 - Self::InsertLineBreak { .. } => Self::InsertLineBreak { range }, 161 - Self::InsertParagraph { .. } => Self::InsertParagraph { range }, 162 - Self::DeleteBackward { .. } => Self::DeleteBackward { range }, 163 - Self::DeleteForward { .. } => Self::DeleteForward { range }, 164 - Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range }, 165 - Self::DeleteWordForward { .. } => Self::DeleteWordForward { range }, 166 - Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range }, 167 - Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range }, 168 - Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range }, 169 - Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range }, 170 - Self::Paste { .. } => Self::Paste { range }, 171 - // Actions without range stay unchanged 172 - other => other, 173 - } 174 - } 175 - } 176 - 177 - /// Key values for keyboard input. 178 - /// 179 - /// Mirrors the keyboard-types crate's Key enum structure. Character keys use 180 - /// SmolStr to efficiently handle both single characters and composed sequences 181 - /// (from dead keys, IME, etc.). 182 - #[derive(Debug, Clone, PartialEq, Eq, Hash)] 183 - #[allow(dead_code)] 184 - pub enum Key { 185 - /// A character key. The string corresponds to the character typed, 186 - /// taking into account locale, modifiers, and keyboard mapping. 187 - /// May be multiple characters for composed sequences. 188 - Character(SmolStr), 189 - 190 - /// Unknown/unidentified key. 191 - Unidentified, 192 - 193 - // === Whitespace / editing === 194 - Backspace, 195 - Delete, 196 - Enter, 197 - Tab, 198 - Escape, 199 - Space, 200 - Insert, 201 - Clear, 202 - 203 - // === Navigation === 204 - ArrowLeft, 205 - ArrowRight, 206 - ArrowUp, 207 - ArrowDown, 208 - Home, 209 - End, 210 - PageUp, 211 - PageDown, 212 - 213 - // === Modifiers === 214 - Alt, 215 - AltGraph, 216 - CapsLock, 217 - Control, 218 - Fn, 219 - FnLock, 220 - Meta, 221 - NumLock, 222 - ScrollLock, 223 - Shift, 224 - Symbol, 225 - SymbolLock, 226 - Hyper, 227 - Super, 228 - 229 - // === Function keys === 230 - F1, 231 - F2, 232 - F3, 233 - F4, 234 - F5, 235 - F6, 236 - F7, 237 - F8, 238 - F9, 239 - F10, 240 - F11, 241 - F12, 242 - F13, 243 - F14, 244 - F15, 245 - F16, 246 - F17, 247 - F18, 248 - F19, 249 - F20, 250 - 251 - // === UI keys === 252 - ContextMenu, 253 - PrintScreen, 254 - Pause, 255 - Help, 256 - 257 - // === Clipboard / editing commands === 258 - Copy, 259 - Cut, 260 - Paste, 261 - Undo, 262 - Redo, 263 - Find, 264 - Select, 265 - 266 - // === Media keys === 267 - MediaPlayPause, 268 - MediaStop, 269 - MediaTrackNext, 270 - MediaTrackPrevious, 271 - AudioVolumeDown, 272 - AudioVolumeUp, 273 - AudioVolumeMute, 274 - 275 - // === IME / composition === 276 - Compose, 277 - Convert, 278 - NonConvert, 279 - Dead, 280 - 281 - // === CJK IME keys === 282 - HangulMode, 283 - HanjaMode, 284 - JunjaMode, 285 - Eisu, 286 - Hankaku, 287 - Hiragana, 288 - HiraganaKatakana, 289 - KanaMode, 290 - KanjiMode, 291 - Katakana, 292 - Romaji, 293 - Zenkaku, 294 - ZenkakuHankaku, 295 - } 296 - 297 - impl Key { 298 - /// Create a character key from a string. 299 - pub fn character(s: impl Into<SmolStr>) -> Self { 300 - Self::Character(s.into()) 301 - } 302 - 303 - /// Convert from a dioxus keyboard_types::Key. 304 - pub fn from_keyboard_types(key: dioxus::prelude::keyboard_types::Key) -> Self { 305 - use dioxus::prelude::keyboard_types::Key as KT; 306 - 307 - match key { 308 - KT::Character(s) => Self::Character(s.as_str().into()), 309 - KT::Unidentified => Self::Unidentified, 310 - 311 - // Whitespace / editing 312 - KT::Backspace => Self::Backspace, 313 - KT::Delete => Self::Delete, 314 - KT::Enter => Self::Enter, 315 - KT::Tab => Self::Tab, 316 - KT::Escape => Self::Escape, 317 - KT::Insert => Self::Insert, 318 - KT::Clear => Self::Clear, 319 - 320 - // Navigation 321 - KT::ArrowLeft => Self::ArrowLeft, 322 - KT::ArrowRight => Self::ArrowRight, 323 - KT::ArrowUp => Self::ArrowUp, 324 - KT::ArrowDown => Self::ArrowDown, 325 - KT::Home => Self::Home, 326 - KT::End => Self::End, 327 - KT::PageUp => Self::PageUp, 328 - KT::PageDown => Self::PageDown, 329 - 330 - // Modifiers 331 - KT::Alt => Self::Alt, 332 - KT::AltGraph => Self::AltGraph, 333 - KT::CapsLock => Self::CapsLock, 334 - KT::Control => Self::Control, 335 - KT::Fn => Self::Fn, 336 - KT::FnLock => Self::FnLock, 337 - KT::Meta => Self::Meta, 338 - KT::NumLock => Self::NumLock, 339 - KT::ScrollLock => Self::ScrollLock, 340 - KT::Shift => Self::Shift, 341 - KT::Symbol => Self::Symbol, 342 - KT::SymbolLock => Self::SymbolLock, 343 - KT::Hyper => Self::Hyper, 344 - KT::Super => Self::Super, 345 - 346 - // Function keys 347 - KT::F1 => Self::F1, 348 - KT::F2 => Self::F2, 349 - KT::F3 => Self::F3, 350 - KT::F4 => Self::F4, 351 - KT::F5 => Self::F5, 352 - KT::F6 => Self::F6, 353 - KT::F7 => Self::F7, 354 - KT::F8 => Self::F8, 355 - KT::F9 => Self::F9, 356 - KT::F10 => Self::F10, 357 - KT::F11 => Self::F11, 358 - KT::F12 => Self::F12, 359 - KT::F13 => Self::F13, 360 - KT::F14 => Self::F14, 361 - KT::F15 => Self::F15, 362 - KT::F16 => Self::F16, 363 - KT::F17 => Self::F17, 364 - KT::F18 => Self::F18, 365 - KT::F19 => Self::F19, 366 - KT::F20 => Self::F20, 367 - 368 - // UI keys 369 - KT::ContextMenu => Self::ContextMenu, 370 - KT::PrintScreen => Self::PrintScreen, 371 - KT::Pause => Self::Pause, 372 - KT::Help => Self::Help, 373 - 374 - // Clipboard / editing commands 375 - KT::Copy => Self::Copy, 376 - KT::Cut => Self::Cut, 377 - KT::Paste => Self::Paste, 378 - KT::Undo => Self::Undo, 379 - KT::Redo => Self::Redo, 380 - KT::Find => Self::Find, 381 - KT::Select => Self::Select, 11 + // Re-export core types. 12 + pub use weaver_editor_core::{ 13 + EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 14 + }; 382 15 383 - // Media keys 384 - KT::MediaPlayPause => Self::MediaPlayPause, 385 - KT::MediaStop => Self::MediaStop, 386 - KT::MediaTrackNext => Self::MediaTrackNext, 387 - KT::MediaTrackPrevious => Self::MediaTrackPrevious, 388 - KT::AudioVolumeDown => Self::AudioVolumeDown, 389 - KT::AudioVolumeUp => Self::AudioVolumeUp, 390 - KT::AudioVolumeMute => Self::AudioVolumeMute, 16 + // === Dioxus conversion helpers === 391 17 392 - // IME / composition 393 - KT::Compose => Self::Compose, 394 - KT::Convert => Self::Convert, 395 - KT::NonConvert => Self::NonConvert, 396 - KT::Dead => Self::Dead, 397 - 398 - // CJK IME keys 399 - KT::HangulMode => Self::HangulMode, 400 - KT::HanjaMode => Self::HanjaMode, 401 - KT::JunjaMode => Self::JunjaMode, 402 - KT::Eisu => Self::Eisu, 403 - KT::Hankaku => Self::Hankaku, 404 - KT::Hiragana => Self::Hiragana, 405 - KT::HiraganaKatakana => Self::HiraganaKatakana, 406 - KT::KanaMode => Self::KanaMode, 407 - KT::KanjiMode => Self::KanjiMode, 408 - KT::Katakana => Self::Katakana, 409 - KT::Romaji => Self::Romaji, 410 - KT::Zenkaku => Self::Zenkaku, 411 - KT::ZenkakuHankaku => Self::ZenkakuHankaku, 412 - 413 - // Everything else falls through to Unidentified 414 - _ => Self::Unidentified, 415 - } 416 - } 417 - 418 - /// Check if this is a navigation key that should pass through to browser. 419 - pub fn is_navigation(&self) -> bool { 420 - matches!( 421 - self, 422 - Self::ArrowLeft 423 - | Self::ArrowRight 424 - | Self::ArrowUp 425 - | Self::ArrowDown 426 - | Self::Home 427 - | Self::End 428 - | Self::PageUp 429 - | Self::PageDown 430 - ) 431 - } 18 + /// Convert a dioxus keyboard_types::Key to our Key type. 19 + pub fn key_from_dioxus(key: dioxus::prelude::keyboard_types::Key) -> Key { 20 + use dioxus::prelude::keyboard_types::Key as KT; 432 21 433 - /// Check if this is a modifier key. 434 - pub fn is_modifier(&self) -> bool { 435 - matches!( 436 - self, 437 - Self::Alt 438 - | Self::AltGraph 439 - | Self::CapsLock 440 - | Self::Control 441 - | Self::Fn 442 - | Self::FnLock 443 - | Self::Meta 444 - | Self::NumLock 445 - | Self::ScrollLock 446 - | Self::Shift 447 - | Self::Symbol 448 - | Self::SymbolLock 449 - | Self::Hyper 450 - | Self::Super 451 - ) 22 + match key { 23 + KT::Character(s) => Key::character(s.as_str()), 24 + KT::Unidentified => Key::Unidentified, 25 + KT::Backspace => Key::Backspace, 26 + KT::Delete => Key::Delete, 27 + KT::Enter => Key::Enter, 28 + KT::Tab => Key::Tab, 29 + KT::Escape => Key::Escape, 30 + KT::Insert => Key::Insert, 31 + KT::Clear => Key::Clear, 32 + KT::ArrowLeft => Key::ArrowLeft, 33 + KT::ArrowRight => Key::ArrowRight, 34 + KT::ArrowUp => Key::ArrowUp, 35 + KT::ArrowDown => Key::ArrowDown, 36 + KT::Home => Key::Home, 37 + KT::End => Key::End, 38 + KT::PageUp => Key::PageUp, 39 + KT::PageDown => Key::PageDown, 40 + KT::Alt => Key::Alt, 41 + KT::AltGraph => Key::AltGraph, 42 + KT::CapsLock => Key::CapsLock, 43 + KT::Control => Key::Control, 44 + KT::Fn => Key::Fn, 45 + KT::FnLock => Key::FnLock, 46 + KT::Meta => Key::Meta, 47 + KT::NumLock => Key::NumLock, 48 + KT::ScrollLock => Key::ScrollLock, 49 + KT::Shift => Key::Shift, 50 + KT::Symbol => Key::Symbol, 51 + KT::SymbolLock => Key::SymbolLock, 52 + KT::Hyper => Key::Hyper, 53 + KT::Super => Key::Super, 54 + KT::F1 => Key::F1, 55 + KT::F2 => Key::F2, 56 + KT::F3 => Key::F3, 57 + KT::F4 => Key::F4, 58 + KT::F5 => Key::F5, 59 + KT::F6 => Key::F6, 60 + KT::F7 => Key::F7, 61 + KT::F8 => Key::F8, 62 + KT::F9 => Key::F9, 63 + KT::F10 => Key::F10, 64 + KT::F11 => Key::F11, 65 + KT::F12 => Key::F12, 66 + KT::F13 => Key::F13, 67 + KT::F14 => Key::F14, 68 + KT::F15 => Key::F15, 69 + KT::F16 => Key::F16, 70 + KT::F17 => Key::F17, 71 + KT::F18 => Key::F18, 72 + KT::F19 => Key::F19, 73 + KT::F20 => Key::F20, 74 + KT::ContextMenu => Key::ContextMenu, 75 + KT::PrintScreen => Key::PrintScreen, 76 + KT::Pause => Key::Pause, 77 + KT::Help => Key::Help, 78 + KT::Copy => Key::Copy, 79 + KT::Cut => Key::Cut, 80 + KT::Paste => Key::Paste, 81 + KT::Undo => Key::Undo, 82 + KT::Redo => Key::Redo, 83 + KT::Find => Key::Find, 84 + KT::Select => Key::Select, 85 + KT::MediaPlayPause => Key::MediaPlayPause, 86 + KT::MediaStop => Key::MediaStop, 87 + KT::MediaTrackNext => Key::MediaTrackNext, 88 + KT::MediaTrackPrevious => Key::MediaTrackPrevious, 89 + KT::AudioVolumeDown => Key::AudioVolumeDown, 90 + KT::AudioVolumeUp => Key::AudioVolumeUp, 91 + KT::AudioVolumeMute => Key::AudioVolumeMute, 92 + KT::Compose => Key::Compose, 93 + KT::Convert => Key::Convert, 94 + KT::NonConvert => Key::NonConvert, 95 + KT::Dead => Key::Dead, 96 + KT::HangulMode => Key::HangulMode, 97 + KT::HanjaMode => Key::HanjaMode, 98 + KT::JunjaMode => Key::JunjaMode, 99 + KT::Eisu => Key::Eisu, 100 + KT::Hankaku => Key::Hankaku, 101 + KT::Hiragana => Key::Hiragana, 102 + KT::HiraganaKatakana => Key::HiraganaKatakana, 103 + KT::KanaMode => Key::KanaMode, 104 + KT::KanjiMode => Key::KanjiMode, 105 + KT::Katakana => Key::Katakana, 106 + KT::Romaji => Key::Romaji, 107 + KT::Zenkaku => Key::Zenkaku, 108 + KT::ZenkakuHankaku => Key::ZenkakuHankaku, 109 + _ => Key::Unidentified, 452 110 } 453 111 } 454 112 455 - /// Modifier key state for a key combination. 456 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 457 - pub struct Modifiers { 458 - pub ctrl: bool, 459 - pub alt: bool, 460 - pub shift: bool, 461 - pub meta: bool, 462 - pub hyper: bool, 463 - pub super_: bool, // `super` is a keyword 464 - } 465 - 466 - #[allow(dead_code)] 467 - impl Modifiers { 468 - pub const NONE: Self = Self { 469 - ctrl: false, 470 - alt: false, 471 - shift: false, 472 - meta: false, 473 - hyper: false, 474 - super_: false, 475 - }; 476 - pub const CTRL: Self = Self { 477 - ctrl: true, 478 - alt: false, 479 - shift: false, 480 - meta: false, 113 + /// Create a KeyCombo from a dioxus keyboard event. 114 + pub fn keycombo_from_dioxus_event(event: &dioxus::events::KeyboardData) -> KeyCombo { 115 + let key = key_from_dioxus(event.key()); 116 + let modifiers = Modifiers { 117 + ctrl: event.modifiers().ctrl(), 118 + alt: event.modifiers().alt(), 119 + shift: event.modifiers().shift(), 120 + meta: event.modifiers().meta(), 481 121 hyper: false, 482 122 super_: false, 483 123 }; 484 - pub const ALT: Self = Self { 485 - ctrl: false, 486 - alt: true, 487 - shift: false, 488 - meta: false, 489 - hyper: false, 490 - super_: false, 491 - }; 492 - pub const SHIFT: Self = Self { 493 - ctrl: false, 494 - alt: false, 495 - shift: true, 496 - meta: false, 497 - hyper: false, 498 - super_: false, 499 - }; 500 - pub const META: Self = Self { 501 - ctrl: false, 502 - alt: false, 503 - shift: false, 504 - meta: true, 505 - hyper: false, 506 - super_: false, 507 - }; 508 - pub const HYPER: Self = Self { 509 - ctrl: false, 510 - alt: false, 511 - shift: false, 512 - meta: false, 513 - hyper: true, 514 - super_: false, 515 - }; 516 - pub const SUPER: Self = Self { 517 - ctrl: false, 518 - alt: false, 519 - shift: false, 520 - meta: false, 521 - hyper: false, 522 - super_: true, 523 - }; 524 - pub const CTRL_SHIFT: Self = Self { 525 - ctrl: true, 526 - alt: false, 527 - shift: true, 528 - meta: false, 529 - hyper: false, 530 - super_: false, 531 - }; 532 - pub const META_SHIFT: Self = Self { 533 - ctrl: false, 534 - alt: false, 535 - shift: true, 536 - meta: true, 537 - hyper: false, 538 - super_: false, 539 - }; 540 - 541 - /// Get the platform's primary modifier (Cmd on Mac, Ctrl elsewhere). 542 - pub fn cmd_or_ctrl(platform: &Platform) -> Self { 543 - if platform.mac { Self::META } else { Self::CTRL } 544 - } 545 - 546 - /// Get the platform's primary modifier + Shift. 547 - pub fn cmd_or_ctrl_shift(platform: &Platform) -> Self { 548 - if platform.mac { 549 - Self::META_SHIFT 550 - } else { 551 - Self::CTRL_SHIFT 552 - } 553 - } 124 + KeyCombo::with_modifiers(key, modifiers) 554 125 } 555 126 556 - /// A key combination for triggering an action. 557 - #[derive(Debug, Clone, PartialEq, Eq, Hash)] 558 - pub struct KeyCombo { 559 - pub key: Key, 560 - pub modifiers: Modifiers, 561 - } 562 - 563 - impl KeyCombo { 564 - pub fn new(key: Key) -> Self { 565 - Self { 566 - key, 567 - modifiers: Modifiers::NONE, 568 - } 569 - } 570 - 571 - pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self { 572 - Self { key, modifiers } 573 - } 574 - 575 - pub fn ctrl(key: Key) -> Self { 576 - Self { 577 - key, 578 - modifiers: Modifiers::CTRL, 579 - } 580 - } 581 - 582 - pub fn meta(key: Key) -> Self { 583 - Self { 584 - key, 585 - modifiers: Modifiers::META, 586 - } 587 - } 588 - 589 - pub fn shift(key: Key) -> Self { 590 - Self { 591 - key, 592 - modifiers: Modifiers::SHIFT, 593 - } 594 - } 595 - 596 - pub fn cmd_or_ctrl(key: Key, platform: &Platform) -> Self { 597 - Self { 598 - key, 599 - modifiers: Modifiers::cmd_or_ctrl(platform), 600 - } 601 - } 602 - 603 - pub fn cmd_or_ctrl_shift(key: Key, platform: &Platform) -> Self { 604 - Self { 605 - key, 606 - modifiers: Modifiers::cmd_or_ctrl_shift(platform), 607 - } 608 - } 609 - 610 - /// Create a KeyCombo from a dioxus keyboard event. 611 - pub fn from_keyboard_event(event: &dioxus::events::KeyboardData) -> Self { 612 - let key = Key::from_keyboard_types(event.key()); 613 - let modifiers = Modifiers { 614 - ctrl: event.modifiers().ctrl(), 615 - alt: event.modifiers().alt(), 616 - shift: event.modifiers().shift(), 617 - meta: event.modifiers().meta(), 618 - // dioxus doesn't expose hyper/super separately, they typically map to meta 619 - hyper: false, 620 - super_: false, 621 - }; 622 - Self { key, modifiers } 623 - } 624 - } 625 - 626 - /// Keybinding configuration for the editor. 627 - /// 628 - /// Uses a HashMap for O(1) keybinding lookup. 629 - #[derive(Debug, Clone)] 630 - pub struct KeybindingConfig { 631 - bindings: HashMap<KeyCombo, EditorAction>, 632 - } 633 - 634 - impl KeybindingConfig { 635 - /// Create default keybindings for the given platform. 636 - pub fn default_for_platform(platform: &Platform) -> Self { 637 - let mut bindings = HashMap::new(); 638 - 639 - // === Formatting === 640 - bindings.insert( 641 - KeyCombo::cmd_or_ctrl(Key::character("b"), platform), 642 - EditorAction::ToggleBold, 643 - ); 644 - bindings.insert( 645 - KeyCombo::cmd_or_ctrl(Key::character("i"), platform), 646 - EditorAction::ToggleItalic, 647 - ); 648 - bindings.insert( 649 - KeyCombo::cmd_or_ctrl(Key::character("e"), platform), 650 - EditorAction::CopyAsHtml, 651 - ); 652 - 653 - // === History === 654 - bindings.insert( 655 - KeyCombo::cmd_or_ctrl(Key::character("z"), platform), 656 - EditorAction::Undo, 657 - ); 658 - 659 - // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere 660 - if platform.mac { 661 - bindings.insert( 662 - KeyCombo::cmd_or_ctrl_shift(Key::character("Z"), platform), 663 - EditorAction::Redo, 664 - ); 665 - } else { 666 - bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo); 667 - bindings.insert( 668 - KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT), 669 - EditorAction::Redo, 670 - ); 671 - } 672 - 673 - // === Selection === 674 - bindings.insert( 675 - KeyCombo::cmd_or_ctrl(Key::character("a"), platform), 676 - EditorAction::SelectAll, 677 - ); 678 - 679 - // === Line deletion === 680 - if platform.mac { 681 - bindings.insert( 682 - KeyCombo::meta(Key::Backspace), 683 - EditorAction::DeleteToLineStart { 684 - range: Range::caret(0), 685 - }, 686 - ); 687 - bindings.insert( 688 - KeyCombo::meta(Key::Delete), 689 - EditorAction::DeleteToLineEnd { 690 - range: Range::caret(0), 691 - }, 692 - ); 693 - } 694 - 695 - // === Enter behaviour === 696 - // Enter = soft break (single newline) 697 - bindings.insert( 698 - KeyCombo::new(Key::Enter), 699 - EditorAction::InsertLineBreak { 700 - range: Range::caret(0), 701 - }, 702 - ); 703 - // Shift+Enter = paragraph break (double newline) 704 - bindings.insert( 705 - KeyCombo::shift(Key::Enter), 706 - EditorAction::InsertParagraph { 707 - range: Range::caret(0), 708 - }, 709 - ); 710 - 711 - bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo); 712 - bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo); 713 - bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy); 714 - bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut); 715 - bindings.insert( 716 - KeyCombo::new(Key::Paste), 717 - EditorAction::Paste { 718 - range: Range::caret(0), 719 - }, 720 - ); 721 - bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 722 - 723 - Self { bindings } 724 - } 725 - 726 - /// Look up an action for the given key combo, with the current range applied. 727 - pub fn lookup(&self, combo: KeyCombo, range: Range) -> Option<EditorAction> { 728 - self.bindings 729 - .get(&combo) 730 - .cloned() 731 - .map(|a| a.with_range(range)) 732 - } 733 - 734 - /// Add or replace a keybinding. 735 - #[allow(dead_code)] 736 - pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) { 737 - self.bindings.insert(combo, action); 738 - } 739 - 740 - /// Remove a keybinding. 741 - #[allow(dead_code)] 742 - pub fn unbind(&mut self, combo: KeyCombo) { 743 - self.bindings.remove(&combo); 744 - } 127 + /// Create a default KeybindingConfig for the given platform. 128 + pub fn default_keybindings(platform: &Platform) -> KeybindingConfig { 129 + KeybindingConfig::default_for_platform(platform.mac) 745 130 } 746 131 747 132 /// Execute an editor action on a document. ··· 1248 633 pos 1249 634 } 1250 635 1251 - /// Result of handling a keydown event. 1252 - #[derive(Debug, Clone, PartialEq)] 1253 - pub enum KeydownResult { 1254 - /// Event was handled, prevent default. 1255 - Handled, 1256 - /// Event was not a keybinding, let browser/beforeinput handle it. 1257 - NotHandled, 1258 - /// Event should be passed through (navigation, etc.). 1259 - PassThrough, 1260 - } 1261 - 1262 636 /// Handle a keydown event using the keybinding configuration. 1263 637 /// 1264 638 /// This handles keyboard shortcuts only. Text input and deletion ··· 1271 645 range: Range, 1272 646 ) -> KeydownResult { 1273 647 // Look up keybinding (range is applied by lookup) 1274 - if let Some(action) = config.lookup(combo.clone(), range) { 648 + if let Some(action) = config.lookup(&combo, range) { 1275 649 execute_action(doc, &action); 1276 650 return KeydownResult::Handled; 1277 651 }
+4 -198
crates/weaver-app/src/components/editor/beforeinput.rs
··· 16 16 17 17 use dioxus::prelude::*; 18 18 19 - use super::actions::{EditorAction, Range, execute_action}; 19 + use super::actions::{EditorAction, execute_action}; 20 20 use super::document::EditorDocument; 21 21 use super::platform::Platform; 22 22 23 - // Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it. 24 - // StaticRange is returned by InputEvent.getTargetRanges() and represents 25 - // a fixed range that doesn't update when the DOM changes. 26 - // https://developer.mozilla.org/en-US/docs/Web/API/StaticRange 27 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 28 - mod static_range { 29 - use wasm_bindgen::prelude::*; 30 - 31 - #[wasm_bindgen] 32 - extern "C" { 33 - /// The StaticRange interface represents a static range of text in the DOM. 34 - pub type StaticRange; 35 - 36 - #[wasm_bindgen(method, getter, structural)] 37 - pub fn startContainer(this: &StaticRange) -> web_sys::Node; 38 - 39 - #[wasm_bindgen(method, getter, structural)] 40 - pub fn startOffset(this: &StaticRange) -> u32; 41 - 42 - #[wasm_bindgen(method, getter, structural)] 43 - pub fn endContainer(this: &StaticRange) -> web_sys::Node; 44 - 45 - #[wasm_bindgen(method, getter, structural)] 46 - pub fn endOffset(this: &StaticRange) -> u32; 47 - 48 - #[wasm_bindgen(method, getter, structural)] 49 - pub fn collapsed(this: &StaticRange) -> bool; 50 - } 51 - } 23 + // Re-export types from extracted crates. 24 + pub use weaver_editor_core::{InputType, Range}; 52 25 53 26 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 54 - pub use static_range::StaticRange; 55 - 56 - /// Input types from the beforeinput event. 57 - /// 58 - /// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes 59 - #[derive(Debug, Clone, PartialEq, Eq)] 60 - #[allow(dead_code)] 61 - pub enum InputType { 62 - // === Insertion === 63 - /// Insert typed text. 64 - InsertText, 65 - /// Insert text from IME composition. 66 - InsertCompositionText, 67 - /// Insert a line break (`<br>`, Shift+Enter). 68 - InsertLineBreak, 69 - /// Insert a paragraph break (Enter). 70 - InsertParagraph, 71 - /// Insert from paste operation. 72 - InsertFromPaste, 73 - /// Insert from drop operation. 74 - InsertFromDrop, 75 - /// Insert replacement text (e.g., spell check correction). 76 - InsertReplacementText, 77 - /// Insert from voice input or other source. 78 - InsertFromYank, 79 - /// Insert a horizontal rule. 80 - InsertHorizontalRule, 81 - /// Insert an ordered list. 82 - InsertOrderedList, 83 - /// Insert an unordered list. 84 - InsertUnorderedList, 85 - /// Insert a link. 86 - InsertLink, 87 - 88 - // === Deletion === 89 - /// Delete content backward (Backspace). 90 - DeleteContentBackward, 91 - /// Delete content forward (Delete key). 92 - DeleteContentForward, 93 - /// Delete word backward (Ctrl/Alt+Backspace). 94 - DeleteWordBackward, 95 - /// Delete word forward (Ctrl/Alt+Delete). 96 - DeleteWordForward, 97 - /// Delete to soft line boundary backward. 98 - DeleteSoftLineBackward, 99 - /// Delete to soft line boundary forward. 100 - DeleteSoftLineForward, 101 - /// Delete to hard line boundary backward (Cmd+Backspace on Mac). 102 - DeleteHardLineBackward, 103 - /// Delete to hard line boundary forward (Cmd+Delete on Mac). 104 - DeleteHardLineForward, 105 - /// Delete by cut operation. 106 - DeleteByCut, 107 - /// Delete by drag operation. 108 - DeleteByDrag, 109 - /// Generic content deletion. 110 - DeleteContent, 111 - /// Delete entire word backward (Ctrl+W on some systems). 112 - DeleteEntireWordBackward, 113 - /// Delete entire word forward. 114 - DeleteEntireWordForward, 115 - 116 - // === History === 117 - /// Undo. 118 - HistoryUndo, 119 - /// Redo. 120 - HistoryRedo, 121 - 122 - // === Formatting (rarely used, most apps handle via shortcuts) === 123 - FormatBold, 124 - FormatItalic, 125 - FormatUnderline, 126 - FormatStrikethrough, 127 - FormatSuperscript, 128 - FormatSubscript, 129 - 130 - // === Unknown === 131 - /// Unrecognized input type. 132 - Unknown(String), 133 - } 134 - 135 - #[allow(dead_code)] 136 - impl InputType { 137 - /// Parse from the browser's inputType string. 138 - pub fn from_str(s: &str) -> Self { 139 - match s { 140 - // Insertion 141 - "insertText" => Self::InsertText, 142 - "insertCompositionText" => Self::InsertCompositionText, 143 - "insertLineBreak" => Self::InsertLineBreak, 144 - "insertParagraph" => Self::InsertParagraph, 145 - "insertFromPaste" => Self::InsertFromPaste, 146 - "insertFromDrop" => Self::InsertFromDrop, 147 - "insertReplacementText" => Self::InsertReplacementText, 148 - "insertFromYank" => Self::InsertFromYank, 149 - "insertHorizontalRule" => Self::InsertHorizontalRule, 150 - "insertOrderedList" => Self::InsertOrderedList, 151 - "insertUnorderedList" => Self::InsertUnorderedList, 152 - "insertLink" => Self::InsertLink, 153 - 154 - // Deletion 155 - "deleteContentBackward" => Self::DeleteContentBackward, 156 - "deleteContentForward" => Self::DeleteContentForward, 157 - "deleteWordBackward" => Self::DeleteWordBackward, 158 - "deleteWordForward" => Self::DeleteWordForward, 159 - "deleteSoftLineBackward" => Self::DeleteSoftLineBackward, 160 - "deleteSoftLineForward" => Self::DeleteSoftLineForward, 161 - "deleteHardLineBackward" => Self::DeleteHardLineBackward, 162 - "deleteHardLineForward" => Self::DeleteHardLineForward, 163 - "deleteByCut" => Self::DeleteByCut, 164 - "deleteByDrag" => Self::DeleteByDrag, 165 - "deleteContent" => Self::DeleteContent, 166 - "deleteEntireSoftLine" => Self::DeleteSoftLineBackward, // Treat as soft line 167 - "deleteEntireWordBackward" => Self::DeleteEntireWordBackward, 168 - "deleteEntireWordForward" => Self::DeleteEntireWordForward, 169 - 170 - // History 171 - "historyUndo" => Self::HistoryUndo, 172 - "historyRedo" => Self::HistoryRedo, 173 - 174 - // Formatting 175 - "formatBold" => Self::FormatBold, 176 - "formatItalic" => Self::FormatItalic, 177 - "formatUnderline" => Self::FormatUnderline, 178 - "formatStrikethrough" => Self::FormatStrikethrough, 179 - "formatSuperscript" => Self::FormatSuperscript, 180 - "formatSubscript" => Self::FormatSubscript, 181 - 182 - // Unknown 183 - other => Self::Unknown(other.to_string()), 184 - } 185 - } 186 - 187 - /// Whether this input type is a deletion operation. 188 - pub fn is_deletion(&self) -> bool { 189 - matches!( 190 - self, 191 - Self::DeleteContentBackward 192 - | Self::DeleteContentForward 193 - | Self::DeleteWordBackward 194 - | Self::DeleteWordForward 195 - | Self::DeleteSoftLineBackward 196 - | Self::DeleteSoftLineForward 197 - | Self::DeleteHardLineBackward 198 - | Self::DeleteHardLineForward 199 - | Self::DeleteByCut 200 - | Self::DeleteByDrag 201 - | Self::DeleteContent 202 - | Self::DeleteEntireWordBackward 203 - | Self::DeleteEntireWordForward 204 - ) 205 - } 206 - 207 - /// Whether this input type is an insertion operation. 208 - pub fn is_insertion(&self) -> bool { 209 - matches!( 210 - self, 211 - Self::InsertText 212 - | Self::InsertCompositionText 213 - | Self::InsertLineBreak 214 - | Self::InsertParagraph 215 - | Self::InsertFromPaste 216 - | Self::InsertFromDrop 217 - | Self::InsertReplacementText 218 - | Self::InsertFromYank 219 - ) 220 - } 221 - } 27 + pub use weaver_editor_browser::StaticRange; 222 28 223 29 /// Result of handling a beforeinput event. 224 30 #[derive(Debug, Clone)]
+2 -2
crates/weaver-app/src/components/editor/component.rs
··· 1238 1238 1239 1239 onkeydown: { 1240 1240 let mut doc = document.clone(); 1241 - let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); 1241 + let keybindings = super::actions::default_keybindings(platform::platform()); 1242 1242 move |evt| { 1243 1243 use dioxus::prelude::keyboard_types::Key; 1244 1244 use std::time::Duration; ··· 1284 1284 } 1285 1285 1286 1286 // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) 1287 - let combo = KeyCombo::from_keyboard_event(&evt.data()); 1287 + let combo = super::actions::keycombo_from_dioxus_event(&evt.data()); 1288 1288 let cursor_offset = doc.cursor.read().offset; 1289 1289 let selection = *doc.selection.read(); 1290 1290 let range = selection
+3 -15
crates/weaver-app/src/components/editor/cursor.rs
··· 8 8 //! 4. Setting cursor with web_sys Selection API 9 9 10 10 use weaver_editor_core::OffsetMapping; 11 + pub use weaver_editor_core::{CursorRect, SelectionRect}; 11 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 12 13 use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 13 14 ··· 194 195 Err("no text node found in container".into()) 195 196 } 196 197 197 - /// Screen coordinates for a cursor position. 198 - #[derive(Debug, Clone, Copy)] 199 - pub struct CursorRect { 200 - pub x: f64, 201 - pub y: f64, 202 - pub height: f64, 203 - } 198 + // CursorRect is imported from weaver_editor_core. 204 199 205 200 /// Get screen coordinates for a character offset in the editor. 206 201 /// ··· 290 285 None 291 286 } 292 287 293 - /// A rectangle for part of a selection (one per line). 294 - #[derive(Debug, Clone, Copy)] 295 - pub struct SelectionRect { 296 - pub x: f64, 297 - pub y: f64, 298 - pub width: f64, 299 - pub height: f64, 300 - } 288 + // SelectionRect is imported from weaver_editor_core. 301 289 302 290 /// Get screen rectangles for a selection range, relative to editor. 303 291 ///
+5 -59
crates/weaver-app/src/components/editor/document.rs
··· 28 28 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 29 29 use weaver_api::sh_weaver::embed::images::Image; 30 30 use weaver_api::sh_weaver::embed::records::RecordEmbed; 31 + pub use weaver_editor_core::{ 32 + Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, 33 + }; 31 34 use weaver_api::sh_weaver::notebook::entry::Entry; 32 35 33 36 /// Helper for working with editor images. ··· 151 154 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>, 152 155 } 153 156 154 - /// Cursor state including position and affinity. 155 - #[derive(Clone, Debug, Copy)] 156 - pub struct CursorState { 157 - /// Character offset in text (NOT byte offset!) 158 - pub offset: usize, 159 - 160 - /// Prefer left/right when at boundary (for vertical cursor movement) 161 - pub affinity: Affinity, 162 - } 163 - 164 - /// Cursor affinity for vertical movement. 165 - #[derive(Clone, Debug, Copy, PartialEq, Eq)] 166 - pub enum Affinity { 167 - Before, 168 - After, 169 - } 170 - 171 - /// Text selection with anchor and head positions. 172 - #[derive(Clone, Debug, Copy)] 173 - pub struct Selection { 174 - /// Where selection started 175 - pub anchor: usize, 176 - /// Where cursor is now 177 - pub head: usize, 178 - } 179 - 180 - /// IME composition state (for international text input). 181 - #[derive(Clone, Debug)] 182 - pub struct CompositionState { 183 - pub start_offset: usize, 184 - pub text: String, 185 - } 186 - 187 - /// Information about the most recent edit, used for incremental rendering optimization. 188 - /// Derives PartialEq so it can be used with Dioxus memos for change detection. 189 - #[derive(Clone, Debug, PartialEq)] 190 - pub struct EditInfo { 191 - /// Character offset where the edit occurred 192 - pub edit_char_pos: usize, 193 - /// Number of characters inserted 194 - pub inserted_len: usize, 195 - /// Number of characters deleted 196 - pub deleted_len: usize, 197 - /// Whether the edit contains a newline (boundary-affecting) 198 - pub contains_newline: bool, 199 - /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). 200 - /// Edits here could affect block-level syntax like headings, lists, code fences. 201 - pub in_block_syntax_zone: bool, 202 - /// Document length (in chars) after this edit was applied. 203 - /// Used to detect stale edit info - if current doc length doesn't match, 204 - /// the edit info is from a previous render cycle and shouldn't be used. 205 - pub doc_len_after: usize, 206 - /// When this edit occurred. Used for idle detection in collaborative sync. 207 - pub timestamp: web_time::Instant, 208 - } 209 - 210 - /// Max distance from line start where block syntax can appear. 211 - /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) 212 - const BLOCK_SYNTAX_ZONE: usize = 6; 157 + // CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE 158 + // are imported from weaver_editor_core. 213 159 214 160 /// Pre-loaded document state that can be created outside of reactive context. 215 161 ///
+5 -14
crates/weaver-app/src/components/editor/formatting.rs
··· 5 5 use super::input::{ListContext, detect_list_context, find_line_end}; 6 6 use dioxus::prelude::*; 7 7 8 - /// Formatting actions available in the editor. 9 - #[derive(Clone, Debug, PartialEq)] 10 - pub enum FormatAction { 11 - Bold, 12 - Italic, 13 - Strikethrough, 14 - Code, 15 - Link, 16 - Image, 17 - Heading(u8), // 1-6 18 - BulletList, 19 - NumberedList, 20 - Quote, 21 - } 8 + // FormatAction is imported from core. 9 + pub use weaver_editor_core::FormatAction; 22 10 23 11 /// Find word boundaries around cursor position. 24 12 /// ··· 164 152 let _ = doc.insert_tracked(line_start, "> "); 165 153 doc.cursor.write().offset = cursor_offset + 2; 166 154 doc.selection.set(None); 155 + } 156 + _ => { 157 + tracing::warn!(?action, "unhandled format action"); 167 158 } 168 159 } 169 160 }
+4 -44
crates/weaver-app/src/components/editor/paragraph.rs
··· 1 1 //! Paragraph-level rendering for incremental updates. 2 2 //! 3 - //! Paragraphs are discovered during markdown rendering by tracking 4 - //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. 3 + //! Re-exports core types and provides Loro-specific helpers. 5 4 6 5 use loro::LoroText; 7 6 use std::ops::Range; 8 - use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; 9 7 10 - /// A rendered paragraph with its source range and offset mappings. 11 - #[derive(Debug, Clone, PartialEq)] 12 - pub struct ParagraphRender { 13 - /// Stable content-based ID for DOM diffing (format: `p-{hash_prefix}-{collision_idx}`) 14 - pub id: String, 15 - 16 - /// Source byte range in the rope 17 - pub byte_range: Range<usize>, 18 - 19 - /// Source char range in the rope 20 - pub char_range: Range<usize>, 21 - 22 - /// Rendered HTML content (without wrapper div) 23 - pub html: String, 24 - 25 - /// Offset mappings for this paragraph 26 - pub offset_map: Vec<OffsetMapping>, 27 - 28 - /// Syntax spans for conditional visibility 29 - pub syntax_spans: Vec<SyntaxSpanInfo>, 30 - 31 - /// Hash of source text for quick change detection 32 - pub source_hash: u64, 33 - } 34 - 35 - /// Simple hash function for source text comparison 36 - pub fn hash_source(text: &str) -> u64 { 37 - use std::collections::hash_map::DefaultHasher; 38 - use std::hash::{Hash, Hasher}; 39 - 40 - let mut hasher = DefaultHasher::new(); 41 - text.hash(&mut hasher); 42 - hasher.finish() 43 - } 44 - 45 - /// Generate a paragraph ID from monotonic counter. 46 - /// IDs are stable across content changes - only position/cursor determines identity. 47 - pub fn make_paragraph_id(id: usize) -> String { 48 - format!("p-{}", id) 49 - } 8 + // Re-export core types. 9 + pub use weaver_editor_core::{ParagraphRender, hash_source, make_paragraph_id}; 50 10 51 - /// Extract substring from LoroText as String 11 + /// Extract substring from LoroText as String. 52 12 pub fn text_slice_to_string(text: &LoroText, range: Range<usize>) -> String { 53 13 text.slice(range.start, range.end).unwrap_or_default() 54 14 }
+64 -838
crates/weaver-app/src/components/editor/render.rs
··· 2 2 //! 3 3 //! Phase 2: Paragraph-level incremental rendering with formatting characters visible. 4 4 //! 5 - //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 5 + //! This module provides a thin wrapper around the core rendering logic, 6 + //! adapting it to LoroText and adding app-specific features like timing. 6 7 7 - use super::document::EditInfo; 8 - use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 8 + use super::paragraph::ParagraphRender; 9 9 use super::writer::embed::EditorImageResolver; 10 10 use loro::LoroText; 11 - use markdown_weaver::Parser; 12 - use std::ops::Range; 13 11 use weaver_common::{EntryIndex, ResolvedContent}; 14 - use weaver_editor_core::{ 15 - EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SyntaxSpanInfo, 16 - }; 12 + use weaver_editor_core::{EditInfo, SmolStr, TextBuffer}; 17 13 18 - /// Cache for incremental paragraph rendering. 19 - /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. 20 - #[derive(Clone, Debug, Default)] 21 - pub struct RenderCache { 22 - /// Cached paragraph renders (content paragraphs only, gaps computed fresh) 23 - pub paragraphs: Vec<CachedParagraph>, 24 - /// Next available node ID for fresh renders 25 - pub next_node_id: usize, 26 - /// Next available syntax span ID for fresh renders 27 - pub next_syn_id: usize, 28 - /// Next available paragraph ID (monotonic counter) 29 - pub next_para_id: usize, 30 - } 14 + // Re-export core types. 15 + pub use weaver_editor_core::RenderCache; 31 16 32 - /// A cached paragraph render that can be reused if source hasn't changed. 33 - #[derive(Clone, Debug)] 34 - pub struct CachedParagraph { 35 - /// Stable monotonic ID for DOM element identity 36 - pub id: String, 37 - /// Hash of paragraph source text for change detection 38 - pub source_hash: u64, 39 - /// Byte range in source document 40 - pub byte_range: Range<usize>, 41 - /// Char range in source document 42 - pub char_range: Range<usize>, 43 - /// Rendered HTML 44 - pub html: String, 45 - /// Offset mappings for cursor positioning 46 - pub offset_map: Vec<OffsetMapping>, 47 - /// Syntax spans for conditional visibility 48 - pub syntax_spans: Vec<SyntaxSpanInfo>, 49 - /// Collected refs (wikilinks, AT embeds) from this paragraph 50 - pub collected_refs: Vec<weaver_common::ExtractedRef>, 51 - } 17 + /// Adapter to make LoroText implement TextBuffer-like interface for core. 18 + /// temporary until weaver-editor-crdt crate complete 19 + struct LoroTextAdapter<'a>(&'a LoroText); 52 20 53 - /// Check if an edit affects paragraph boundaries. 54 - /// 55 - /// Edits that don't contain newlines and aren't in the block-syntax zone 56 - /// are considered "safe" and can skip boundary rediscovery. 57 - fn is_boundary_affecting(edit: &EditInfo) -> bool { 58 - // Newlines always affect boundaries (paragraph splits/joins) 59 - if edit.contains_newline { 60 - return true; 21 + impl TextBuffer for LoroTextAdapter<'_> { 22 + fn len_chars(&self) -> usize { 23 + self.0.len_unicode() 61 24 } 62 25 63 - // Edits in the block-syntax zone (first ~6 chars of line) could affect 64 - // headings, lists, blockquotes, code fences, etc. 65 - if edit.in_block_syntax_zone { 66 - return true; 26 + fn len_bytes(&self) -> usize { 27 + self.0.len_utf8() 67 28 } 68 29 69 - false 70 - } 71 - 72 - /// Apply a signed delta to a usize, saturating at 0 on underflow. 73 - fn apply_delta(val: usize, delta: isize) -> usize { 74 - if delta >= 0 { 75 - val.saturating_add(delta as usize) 76 - } else { 77 - val.saturating_sub((-delta) as usize) 30 + fn slice(&self, range: std::ops::Range<usize>) -> Option<SmolStr> { 31 + self.0 32 + .slice(range.start, range.end) 33 + .ok() 34 + .map(|s| SmolStr::new(&s)) 78 35 } 79 - } 80 36 81 - /// Insert gap paragraphs for extra whitespace between blocks. 82 - fn add_gap_paragraphs( 83 - paragraphs: Vec<ParagraphRender>, 84 - text: &LoroText, 85 - source: &str, 86 - ) -> Vec<ParagraphRender> { 87 - const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n 37 + fn char_at(&self, offset: usize) -> Option<char> { 38 + self.0 39 + .slice(offset, offset + 1) 40 + .ok() 41 + .and_then(|s| s.chars().next()) 42 + } 88 43 89 - let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); 90 - let mut prev_end_char = 0usize; 91 - let mut prev_end_byte = 0usize; 44 + fn char_to_byte(&self, char_offset: usize) -> usize { 45 + // LoroText doesn't expose this directly, so we compute it. 46 + let slice = self.0.slice(0, char_offset).unwrap_or_default(); 47 + slice.len() 48 + } 92 49 93 - for para in paragraphs { 94 - // Gap insertion commented out - with break-spaces CSS, whitespace renders naturally 95 - // let gap_size = para.char_range.start.saturating_sub(prev_end_char); 96 - // if gap_size > MIN_PARAGRAPH_BREAK_INCR { 97 - // let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 98 - // let gap_end_char = para.char_range.start; 99 - // let gap_end_byte = para.byte_range.start; 100 - // 101 - // let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); 102 - // let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 103 - // 104 - // paragraphs_with_gaps.push(ParagraphRender { 105 - // id: gap_node_id.clone(), 106 - // byte_range: prev_end_byte..gap_end_byte, 107 - // char_range: prev_end_char..gap_end_char, 108 - // html: gap_html, 109 - // offset_map: vec![OffsetMapping { 110 - // byte_range: prev_end_byte..gap_end_byte, 111 - // char_range: prev_end_char..gap_end_char, 112 - // node_id: gap_node_id, 113 - // char_offset_in_node: 0, 114 - // child_index: None, 115 - // utf16_len: 1, 116 - // }], 117 - // syntax_spans: vec![], 118 - // source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)), 119 - // }); 120 - // } 50 + fn byte_to_char(&self, byte_offset: usize) -> usize { 51 + // LoroText doesn't expose this directly, so we compute it. 52 + let full = self.0.to_string(); 53 + full[..byte_offset.min(full.len())].chars().count() 54 + } 121 55 122 - prev_end_char = para.char_range.end; 123 - prev_end_byte = para.byte_range.end; 124 - paragraphs_with_gaps.push(para); 56 + fn insert(&mut self, _offset: usize, _text: &str) { 57 + // Read-only adapter - this should never be called during rendering. 58 + panic!("LoroTextAdapter is read-only"); 125 59 } 126 60 127 - // Trailing gap insertion commented out - with break-spaces CSS, whitespace renders naturally 128 - // let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); 129 - // if has_trailing_newlines { 130 - // let doc_end_char = text.len_unicode(); 131 - // let doc_end_byte = text.len_utf8(); 132 - // 133 - // if doc_end_char > prev_end_char { 134 - // let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); 135 - // let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}'); 136 - // 137 - // paragraphs_with_gaps.push(ParagraphRender { 138 - // id: trailing_node_id.clone(), 139 - // byte_range: prev_end_byte..doc_end_byte, 140 - // char_range: prev_end_char..doc_end_char, 141 - // html: trailing_html, 142 - // offset_map: vec![OffsetMapping { 143 - // byte_range: prev_end_byte..doc_end_byte, 144 - // char_range: prev_end_char..doc_end_char, 145 - // node_id: trailing_node_id, 146 - // char_offset_in_node: 0, 147 - // child_index: None, 148 - // utf16_len: 1, 149 - // }], 150 - // syntax_spans: vec![], 151 - // source_hash: 0, 152 - // }); 153 - // } 154 - // } 61 + fn delete(&mut self, _range: std::ops::Range<usize>) { 62 + // Read-only adapter - this should never be called during rendering. 63 + panic!("LoroTextAdapter is read-only"); 64 + } 155 65 156 - paragraphs_with_gaps 66 + fn to_string(&self) -> String { 67 + self.0.to_string() 68 + } 157 69 } 158 70 159 71 /// Render markdown with incremental caching. 160 72 /// 161 73 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 74 + /// This is a thin wrapper around the core rendering logic that adds timing. 162 75 /// 163 76 /// # Parameters 164 - /// - `cursor_offset`: Current cursor position (for finding which NEW paragraph is the cursor para) 165 - /// - `edit`: Edit info for stable ID assignment. Uses `edit_char_pos` to find which OLD cached 166 - /// paragraph to reuse the ID from (since cursor may have moved after the edit). 167 - /// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes) 77 + /// - `text`: The LoroText to render 78 + /// - `cache`: Optional previous render cache 79 + /// - `cursor_offset`: Current cursor position 80 + /// - `edit`: Edit info for stable ID assignment 81 + /// - `image_resolver`: Optional image URL resolver 82 + /// - `entry_index`: Optional index for wikilink validation 168 83 /// - `resolved_content`: Pre-resolved embed content for sync rendering 169 84 /// 170 85 /// # Returns ··· 183 98 Vec<weaver_common::ExtractedRef>, 184 99 ) { 185 100 let fn_start = crate::perf::now(); 186 - let source = text.to_string(); 187 101 188 - // Log source entering renderer to detect ZWC/space issues 189 - if tracing::enabled!(target: "weaver::render", tracing::Level::TRACE) { 190 - tracing::trace!( 191 - target: "weaver::render", 192 - source_len = source.len(), 193 - source_chars = source.chars().count(), 194 - source_content = %source.escape_debug(), 195 - "render_paragraphs: source entering renderer" 196 - ); 197 - } 198 - 199 - // Handle empty document 200 - if source.is_empty() { 201 - let empty_node_id = "n0".to_string(); 202 - let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 203 - let para_id = make_paragraph_id(0); 204 - 205 - let para = ParagraphRender { 206 - id: para_id.clone(), 207 - byte_range: 0..0, 208 - char_range: 0..0, 209 - html: empty_html.clone(), 210 - offset_map: vec![], 211 - syntax_spans: vec![], 212 - source_hash: 0, 213 - }; 214 - 215 - let new_cache = RenderCache { 216 - paragraphs: vec![CachedParagraph { 217 - id: para_id, 218 - source_hash: 0, 219 - byte_range: 0..0, 220 - char_range: 0..0, 221 - html: empty_html, 222 - offset_map: vec![], 223 - syntax_spans: vec![], 224 - collected_refs: vec![], 225 - }], 226 - next_node_id: 1, 227 - next_syn_id: 0, 228 - next_para_id: 1, 229 - }; 230 - 231 - return (vec![para], new_cache, vec![]); 232 - } 233 - 234 - // Determine if we can use fast path (skip boundary discovery) 235 - // Need cache and non-boundary-affecting edit info (for edit position) 236 - let current_len = text.len_unicode(); 237 - let current_byte_len = text.len_utf8(); 238 - 239 - // If we have cache but no edit, just return cached data (no re-render needed) 240 - // This happens on cursor position changes, clicks, etc. 241 - if let (Some(c), None) = (cache, edit) { 242 - // Verify cache is still valid (document length matches) 243 - let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); 244 - if cached_len == current_len { 245 - tracing::trace!( 246 - target: "weaver::render", 247 - "no edit, returning cached paragraphs" 248 - ); 249 - let paragraphs: Vec<ParagraphRender> = c 250 - .paragraphs 251 - .iter() 252 - .map(|p| ParagraphRender { 253 - id: p.id.clone(), 254 - byte_range: p.byte_range.clone(), 255 - char_range: p.char_range.clone(), 256 - html: p.html.clone(), 257 - offset_map: p.offset_map.clone(), 258 - syntax_spans: p.syntax_spans.clone(), 259 - source_hash: p.source_hash, 260 - }) 261 - .collect(); 262 - let paragraphs = add_gap_paragraphs(paragraphs, text, &source); 263 - return ( 264 - paragraphs, 265 - c.clone(), 266 - c.paragraphs 267 - .iter() 268 - .flat_map(|p| p.collected_refs.clone()) 269 - .collect(), 270 - ); 271 - } 272 - } 273 - 274 - let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 275 - 276 - tracing::debug!( 277 - target: "weaver::render", 278 - use_fast_path, 279 - has_cache = cache.is_some(), 280 - has_edit = edit.is_some(), 281 - boundary_affecting = edit.map(is_boundary_affecting), 282 - current_len, 283 - "render path decision" 284 - ); 285 - 286 - // Get paragraph boundaries 287 - let paragraph_ranges = if use_fast_path { 288 - // Fast path: adjust cached boundaries based on actual length change 289 - let cache = cache.unwrap(); 290 - let edit = edit.unwrap(); 291 - 292 - // Find which paragraph the edit falls into 293 - let edit_pos = edit.edit_char_pos; 294 - 295 - // Compute delta from actual length difference, not edit info 296 - // This handles stale edits gracefully (delta = 0 if lengths match) 297 - let (cached_len, cached_byte_len) = cache 298 - .paragraphs 299 - .last() 300 - .map(|p| (p.char_range.end, p.byte_range.end)) 301 - .unwrap_or((0, 0)); 302 - let char_delta = current_len as isize - cached_len as isize; 303 - let byte_delta = current_byte_len as isize - cached_byte_len as isize; 304 - 305 - // Adjust each cached paragraph's range 306 - cache 307 - .paragraphs 308 - .iter() 309 - .map(|p| { 310 - if p.char_range.end < edit_pos { 311 - // Before edit - no change (edit is strictly after this paragraph) 312 - (p.byte_range.clone(), p.char_range.clone()) 313 - } else if p.char_range.start > edit_pos { 314 - // After edit - shift by delta (edit is strictly before this paragraph) 315 - ( 316 - apply_delta(p.byte_range.start, byte_delta) 317 - ..apply_delta(p.byte_range.end, byte_delta), 318 - apply_delta(p.char_range.start, char_delta) 319 - ..apply_delta(p.char_range.end, char_delta), 320 - ) 321 - } else { 322 - // Edit is at or within this paragraph - expand its end 323 - ( 324 - p.byte_range.start..apply_delta(p.byte_range.end, byte_delta), 325 - p.char_range.start..apply_delta(p.char_range.end, char_delta), 326 - ) 327 - } 328 - }) 329 - .collect::<Vec<_>>() 330 - } else { 331 - vec![] // Will be populated by slow path below 332 - }; 333 - 334 - // Validate fast path results - if any ranges are invalid, use slow path 335 - let use_fast_path = if !paragraph_ranges.is_empty() { 336 - let all_valid = paragraph_ranges 337 - .iter() 338 - .all(|(_, char_range)| char_range.start <= char_range.end); 339 - if !all_valid { 340 - tracing::debug!( 341 - target: "weaver::render", 342 - "fast path produced invalid ranges, falling back to slow path" 343 - ); 344 - false 345 - } else { 346 - true 347 - } 348 - } else { 349 - false 350 - }; 351 - 352 - // ============ FAST PATH ============ 353 - // Reuse cached paragraphs with offset adjustment, only re-render cursor paragraph 354 - if use_fast_path { 355 - let fast_start = crate::perf::now(); 356 - let cache = cache.unwrap(); 357 - let edit = edit.unwrap(); 358 - let edit_pos = edit.edit_char_pos; 359 - 360 - // Compute deltas 361 - let (cached_len, cached_byte_len) = cache 362 - .paragraphs 363 - .last() 364 - .map(|p| (p.char_range.end, p.byte_range.end)) 365 - .unwrap_or((0, 0)); 366 - let char_delta = current_len as isize - cached_len as isize; 367 - let byte_delta = current_byte_len as isize - cached_byte_len as isize; 368 - 369 - // Find cursor paragraph index 370 - let cursor_para_idx = cache 371 - .paragraphs 372 - .iter() 373 - .position(|p| p.char_range.start <= edit_pos && edit_pos <= p.char_range.end); 374 - 375 - let mut paragraphs = Vec::with_capacity(cache.paragraphs.len()); 376 - let mut new_cached = Vec::with_capacity(cache.paragraphs.len()); 377 - let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 378 - 379 - for (idx, cached_para) in cache.paragraphs.iter().enumerate() { 380 - let is_cursor_para = Some(idx) == cursor_para_idx; 381 - 382 - // Adjust ranges based on position relative to edit 383 - let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { 384 - // Before edit - no change 385 - ( 386 - cached_para.byte_range.clone(), 387 - cached_para.char_range.clone(), 388 - ) 389 - } else if cached_para.char_range.start > edit_pos { 390 - // After edit - shift by delta 391 - ( 392 - apply_delta(cached_para.byte_range.start, byte_delta) 393 - ..apply_delta(cached_para.byte_range.end, byte_delta), 394 - apply_delta(cached_para.char_range.start, char_delta) 395 - ..apply_delta(cached_para.char_range.end, char_delta), 396 - ) 397 - } else { 398 - // Contains edit - expand end 399 - ( 400 - cached_para.byte_range.start 401 - ..apply_delta(cached_para.byte_range.end, byte_delta), 402 - cached_para.char_range.start 403 - ..apply_delta(cached_para.char_range.end, char_delta), 404 - ) 405 - }; 406 - 407 - let para_source = text_slice_to_string(text, char_range.clone()); 408 - let source_hash = hash_source(&para_source); 409 - 410 - if is_cursor_para { 411 - // Re-render cursor paragraph for fresh syntax detection 412 - let resolver = image_resolver.cloned().unwrap_or_default(); 413 - let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 414 - .into_offset_iter(); 415 - 416 - let para_rope = EditorRope::from(para_source.as_str()); 417 - 418 - let mut writer = 419 - EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( 420 - &para_source, 421 - &para_rope, 422 - parser, 423 - ) 424 - .with_node_id_prefix(&cached_para.id) 425 - .with_image_resolver(&resolver) 426 - .with_embed_provider(resolved_content); 427 - 428 - if let Some(idx) = entry_index { 429 - writer = writer.with_entry_index(idx); 430 - } 431 - 432 - let (html, offset_map, syntax_spans, para_refs) = match writer.run() { 433 - Ok(result) => { 434 - // Adjust offsets to be document-absolute 435 - let mut offset_map = result 436 - .offset_maps_by_paragraph 437 - .into_iter() 438 - .next() 439 - .unwrap_or_default(); 440 - for m in &mut offset_map { 441 - m.char_range.start += char_range.start; 442 - m.char_range.end += char_range.start; 443 - m.byte_range.start += byte_range.start; 444 - m.byte_range.end += byte_range.start; 445 - } 446 - let mut syntax_spans = result 447 - .syntax_spans_by_paragraph 448 - .into_iter() 449 - .next() 450 - .unwrap_or_default(); 451 - for s in &mut syntax_spans { 452 - s.adjust_positions(char_range.start as isize); 453 - } 454 - let para_refs = result 455 - .collected_refs_by_paragraph 456 - .into_iter() 457 - .next() 458 - .unwrap_or_default(); 459 - let html = result.html_segments.into_iter().next().unwrap_or_default(); 460 - (html, offset_map, syntax_spans, para_refs) 461 - } 462 - Err(_) => (String::new(), Vec::new(), Vec::new(), Vec::new()), 463 - }; 464 - 465 - all_refs.extend(para_refs.clone()); 466 - 467 - new_cached.push(CachedParagraph { 468 - id: cached_para.id.clone(), 469 - source_hash, 470 - byte_range: byte_range.clone(), 471 - char_range: char_range.clone(), 472 - html: html.clone(), 473 - offset_map: offset_map.clone(), 474 - syntax_spans: syntax_spans.clone(), 475 - collected_refs: para_refs.clone(), 476 - }); 477 - 478 - paragraphs.push(ParagraphRender { 479 - id: cached_para.id.clone(), 480 - byte_range, 481 - char_range, 482 - html, 483 - offset_map, 484 - syntax_spans, 485 - source_hash, 486 - }); 487 - } else { 488 - // Reuse cached with adjusted offsets 489 - let mut offset_map = cached_para.offset_map.clone(); 490 - let mut syntax_spans = cached_para.syntax_spans.clone(); 491 - 492 - if cached_para.char_range.start > edit_pos { 493 - // After edit - adjust offsets 494 - for m in &mut offset_map { 495 - m.char_range.start = apply_delta(m.char_range.start, char_delta); 496 - m.char_range.end = apply_delta(m.char_range.end, char_delta); 497 - m.byte_range.start = apply_delta(m.byte_range.start, byte_delta); 498 - m.byte_range.end = apply_delta(m.byte_range.end, byte_delta); 499 - } 500 - for s in &mut syntax_spans { 501 - s.adjust_positions(char_delta); 502 - } 503 - } 504 - 505 - all_refs.extend(cached_para.collected_refs.clone()); 506 - 507 - new_cached.push(CachedParagraph { 508 - id: cached_para.id.clone(), 509 - source_hash, 510 - byte_range: byte_range.clone(), 511 - char_range: char_range.clone(), 512 - html: cached_para.html.clone(), 513 - offset_map: offset_map.clone(), 514 - syntax_spans: syntax_spans.clone(), 515 - collected_refs: cached_para.collected_refs.clone(), 516 - }); 517 - 518 - paragraphs.push(ParagraphRender { 519 - id: cached_para.id.clone(), 520 - byte_range, 521 - char_range, 522 - html: cached_para.html.clone(), 523 - offset_map, 524 - syntax_spans, 525 - source_hash, 526 - }); 527 - } 528 - } 529 - 530 - // Add gaps (reuse gap logic from below) 531 - let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source); 532 - 533 - let new_cache = RenderCache { 534 - paragraphs: new_cached, 535 - next_node_id: 0, 536 - next_syn_id: 0, 537 - next_para_id: cache.next_para_id, 538 - }; 539 - 540 - let fast_ms = crate::perf::now() - fast_start; 541 - tracing::debug!( 542 - fast_ms, 543 - paragraphs = paragraphs_with_gaps.len(), 544 - cursor_para_idx, 545 - "fast path render timing" 546 - ); 547 - 548 - return (paragraphs_with_gaps, new_cache, all_refs); 549 - } 550 - 551 - // ============ SLOW PATH ============ 552 - // Partial render: reuse cached paragraphs before edit, parse from affected to end 553 - let render_start = crate::perf::now(); 554 - 555 - // Try partial parse if we have cache and edit info 556 - let (reused_paragraphs, parse_start_byte, parse_start_char) = 557 - if let (Some(c), Some(e)) = (cache, edit) { 558 - // Find the first cached paragraph that contains or is after the edit 559 - let edit_pos = e.edit_char_pos; 560 - let affected_idx = c 561 - .paragraphs 562 - .iter() 563 - .position(|p| p.char_range.end >= edit_pos); 102 + // Create adapter for LoroText to use with core rendering. 103 + let adapter = LoroTextAdapter(text); 564 104 565 - if let Some(mut idx) = affected_idx { 566 - // If edit is near the start of a paragraph (within first few chars), 567 - // the previous paragraph is also affected (e.g., backspace to join) 568 - const BOUNDARY_SLOP: usize = 3; 569 - let para_start = c.paragraphs[idx].char_range.start; 570 - if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP { 571 - idx -= 1; 572 - } 573 - 574 - if idx > 0 { 575 - // Reuse paragraphs before the affected one 576 - let reused: Vec<_> = c.paragraphs[..idx].to_vec(); 577 - let last_reused = &c.paragraphs[idx - 1]; 578 - tracing::trace!( 579 - reused_count = idx, 580 - parse_start_byte = last_reused.byte_range.end, 581 - parse_start_char = last_reused.char_range.end, 582 - "slow path: partial parse from affected paragraph" 583 - ); 584 - ( 585 - reused, 586 - last_reused.byte_range.end, 587 - last_reused.char_range.end, 588 - ) 589 - } else { 590 - // Edit is in first paragraph, parse everything 591 - (Vec::new(), 0, 0) 592 - } 593 - } else { 594 - // Edit is after all paragraphs (appending), parse from end 595 - if let Some(last) = c.paragraphs.last() { 596 - let reused = c.paragraphs.clone(); 597 - (reused, last.byte_range.end, last.char_range.end) 598 - } else { 599 - (Vec::new(), 0, 0) 600 - } 601 - } 602 - } else { 603 - // No cache or no edit info, parse everything 604 - (Vec::new(), 0, 0) 605 - }; 606 - 607 - // Parse from the start point to end of document 608 - let parse_slice = &source[parse_start_byte..]; 609 - let parser = 610 - Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); 611 - 612 - // Use provided resolver or empty default 613 - let resolver = image_resolver.cloned().unwrap_or_default(); 614 - 615 - // Create EditorRope for efficient offset conversions 616 - let slice_rope = EditorRope::from(parse_slice); 617 - 618 - // Determine starting paragraph ID for freshly parsed paragraphs 619 - // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML 620 - let reused_count = reused_paragraphs.len(); 621 - 622 - // If reused_count = 0 (full re-render), start from 0 for DOM stability 623 - // Otherwise, use next_para_id to avoid collisions with reused paragraphs 624 - let parsed_para_id_start = if reused_count == 0 { 625 - 0 626 - } else { 627 - cache.map(|c| c.next_para_id).unwrap_or(0) 628 - }; 629 - 630 - tracing::trace!( 631 - parsed_para_id_start, 632 - reused_count, 633 - "slow path: paragraph ID allocation" 634 - ); 635 - 636 - // Find if cursor paragraph is being re-parsed (not reused) 637 - // If so, we want it to keep its cached prefix for DOM/offset_map stability 638 - let cursor_para_override: Option<(usize, String)> = cache.and_then(|c| { 639 - // Find cached paragraph containing cursor 640 - let cached_cursor_idx = c.paragraphs.iter().position(|p| { 641 - p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end 642 - })?; 643 - 644 - // If cursor paragraph is reused (not being re-parsed), no override needed 645 - if cached_cursor_idx < reused_count { 646 - return None; 647 - } 648 - 649 - // Cursor paragraph is being re-parsed - use its cached ID 650 - let cached_para = &c.paragraphs[cached_cursor_idx]; 651 - let parsed_index = cached_cursor_idx - reused_count; 652 - 653 - tracing::trace!( 654 - cached_cursor_idx, 655 - reused_count, 656 - parsed_index, 657 - cached_id = %cached_para.id, 658 - "slow path: cursor paragraph override" 659 - ); 660 - 661 - Some((parsed_index, cached_para.id.clone())) 662 - }); 663 - 664 - // Build writer with all resolvers and auto-incrementing paragraph prefixes 665 - let mut writer = EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( 666 - parse_slice, 667 - &slice_rope, 668 - parser, 669 - ) 670 - .with_auto_incrementing_prefix(parsed_para_id_start) 671 - .with_image_resolver(&resolver) 672 - .with_embed_provider(resolved_content); 673 - 674 - // Apply cursor paragraph override if needed 675 - if let Some((idx, ref prefix)) = cursor_para_override { 676 - writer = writer.with_static_prefix_at_index(idx, prefix); 677 - } 678 - 679 - if let Some(idx) = entry_index { 680 - writer = writer.with_entry_index(idx); 681 - } 682 - 683 - let writer_result = match writer.run() { 684 - Ok(result) => result, 685 - Err(_) => return (Vec::new(), RenderCache::default(), vec![]), 686 - }; 687 - 688 - // Get the final paragraph ID counter from the writer (accounts for all parsed paragraphs) 689 - let parsed_para_count = writer_result.paragraph_ranges.len(); 690 - 691 - let render_ms = crate::perf::now() - render_start; 692 - 693 - // Adjust parsed paragraph ranges to be document-absolute 694 - let parsed_paragraph_ranges: Vec<_> = writer_result 695 - .paragraph_ranges 696 - .iter() 697 - .map(|(byte_range, char_range)| { 698 - ( 699 - (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte), 700 - (char_range.start + parse_start_char)..(char_range.end + parse_start_char), 701 - ) 702 - }) 703 - .collect(); 704 - 705 - // Combine reused ranges with parsed ranges 706 - let paragraph_ranges: Vec<_> = reused_paragraphs 707 - .iter() 708 - .map(|p| (p.byte_range.clone(), p.char_range.clone())) 709 - .chain(parsed_paragraph_ranges.clone()) 710 - .collect(); 711 - 712 - // Log discovered paragraphs (only if trace is enabled to avoid wasted work) 713 - if tracing::enabled!(tracing::Level::TRACE) { 714 - for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 715 - let preview: String = text_slice_to_string(text, char_range.clone()) 716 - .chars() 717 - .take(30) 718 - .collect(); 719 - tracing::trace!( 720 - target: "weaver::render", 721 - para_idx = i, 722 - char_range = ?char_range, 723 - byte_range = ?byte_range, 724 - preview = %preview, 725 - "paragraph boundary" 726 - ); 727 - } 728 - } 729 - 730 - // Build paragraphs from render results 731 - let build_start = crate::perf::now(); 732 - let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 733 - let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 734 - let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 735 - // next_para_id must account for all IDs allocated by the writer 736 - let next_para_id = parsed_para_id_start + parsed_para_count; 737 - let reused_count = reused_paragraphs.len(); 738 - 739 - // Find which paragraph contains cursor (for stable ID assignment) 740 - let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { 741 - char_range.start <= cursor_offset && cursor_offset <= char_range.end 742 - }); 743 - 744 - tracing::trace!( 105 + // Call the core rendering function. 106 + let result = weaver_editor_core::render_paragraphs_incremental( 107 + &adapter, 108 + cache, 745 109 cursor_offset, 746 - ?cursor_para_idx, 747 - edit_char_pos = ?edit.map(|e| e.edit_char_pos), 748 - reused_count, 749 - parsed_count = parsed_paragraph_ranges.len(), 750 - "ID assignment: cursor and edit info" 110 + edit, 111 + image_resolver, 112 + entry_index, 113 + resolved_content, 751 114 ); 752 115 753 - for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 754 - let para_source = text_slice_to_string(text, char_range.clone()); 755 - let source_hash = hash_source(&para_source); 756 - let is_cursor_para = Some(idx) == cursor_para_idx; 757 - 758 - // Check if this is a reused paragraph or a freshly parsed one 759 - let is_reused = idx < reused_count; 760 - 761 - // ID assignment depends on whether this is reused or freshly parsed 762 - let para_id = if is_reused { 763 - // Reused paragraph: keep its existing ID (HTML already has matching prefixes) 764 - reused_paragraphs[idx].id.clone() 765 - } else { 766 - // Freshly parsed: ID MUST match what the writer used for node ID prefixes 767 - let parsed_idx = idx - reused_count; 768 - 769 - // Check if this is the cursor paragraph with an override 770 - let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override { 771 - if parsed_idx == override_idx { 772 - // Use the override prefix (matches what writer used) 773 - override_prefix.clone() 774 - } else { 775 - // Use auto-incremented ID (matches what writer used) 776 - make_paragraph_id(parsed_para_id_start + parsed_idx) 777 - } 778 - } else { 779 - // No override, use auto-incremented ID 780 - make_paragraph_id(parsed_para_id_start + parsed_idx) 781 - }; 782 - 783 - if idx < 3 || is_cursor_para { 784 - tracing::trace!( 785 - idx, 786 - parsed_idx, 787 - is_cursor_para, 788 - para_id = %id, 789 - "slow path: assigned paragraph ID" 790 - ); 791 - } 792 - 793 - id 794 - }; 795 - 796 - // Get data either from reused cache or from fresh parse 797 - let (html, offset_map, syntax_spans, para_refs) = if is_reused { 798 - // Reused from cache - take directly 799 - let reused = &reused_paragraphs[idx]; 800 - ( 801 - reused.html.clone(), 802 - reused.offset_map.clone(), 803 - reused.syntax_spans.clone(), 804 - reused.collected_refs.clone(), 805 - ) 806 - } else { 807 - // Freshly parsed - get from writer_result with offset adjustment 808 - let parsed_idx = idx - reused_count; 809 - let html = writer_result 810 - .html_segments 811 - .get(parsed_idx) 812 - .cloned() 813 - .unwrap_or_default(); 814 - 815 - // Adjust offset maps to document-absolute positions 816 - let mut offset_map = writer_result 817 - .offset_maps_by_paragraph 818 - .get(parsed_idx) 819 - .cloned() 820 - .unwrap_or_default(); 821 - for m in &mut offset_map { 822 - m.char_range.start += parse_start_char; 823 - m.char_range.end += parse_start_char; 824 - m.byte_range.start += parse_start_byte; 825 - m.byte_range.end += parse_start_byte; 826 - } 827 - 828 - // Adjust syntax spans to document-absolute positions 829 - let mut syntax_spans = writer_result 830 - .syntax_spans_by_paragraph 831 - .get(parsed_idx) 832 - .cloned() 833 - .unwrap_or_default(); 834 - for s in &mut syntax_spans { 835 - s.adjust_positions(parse_start_char as isize); 836 - } 837 - 838 - let para_refs = writer_result 839 - .collected_refs_by_paragraph 840 - .get(parsed_idx) 841 - .cloned() 842 - .unwrap_or_default(); 843 - (html, offset_map, syntax_spans, para_refs) 844 - }; 845 - 846 - all_refs.extend(para_refs.clone()); 847 - 848 - // Store in cache 849 - new_cached.push(CachedParagraph { 850 - id: para_id.clone(), 851 - source_hash, 852 - byte_range: byte_range.clone(), 853 - char_range: char_range.clone(), 854 - html: html.clone(), 855 - offset_map: offset_map.clone(), 856 - syntax_spans: syntax_spans.clone(), 857 - collected_refs: para_refs.clone(), 858 - }); 859 - 860 - paragraphs.push(ParagraphRender { 861 - id: para_id, 862 - byte_range: byte_range.clone(), 863 - char_range: char_range.clone(), 864 - html, 865 - offset_map, 866 - syntax_spans, 867 - source_hash, 868 - }); 869 - } 870 - 871 - let build_ms = crate::perf::now() - build_start; 872 - tracing::trace!( 873 - render_ms, 874 - build_ms, 875 - paragraphs = paragraph_ranges.len(), 876 - "single-pass render timing" 877 - ); 878 - 879 - let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source); 880 - 881 - let new_cache = RenderCache { 882 - paragraphs: new_cached, 883 - next_node_id: 0, // Not used in single-pass mode 884 - next_syn_id: 0, // Not used in single-pass mode 885 - next_para_id, 886 - }; 887 - 888 116 let total_ms = crate::perf::now() - fn_start; 889 117 tracing::debug!( 890 118 total_ms, 891 - render_ms, 892 - build_ms, 893 - paragraphs = paragraphs_with_gaps.len(), 119 + paragraphs = result.paragraphs.len(), 894 120 "render_paragraphs_incremental timing" 895 121 ); 896 122 897 - (paragraphs_with_gaps, new_cache, all_refs) 123 + (result.paragraphs, result.cache, result.collected_refs) 898 124 }
+5 -468
crates/weaver-app/src/components/editor/visibility.rs
··· 1 1 //! Conditional syntax visibility based on cursor position. 2 2 //! 3 - //! Implements Obsidian-style formatting character visibility: syntax markers 4 - //! are hidden when cursor is not near them, revealed when cursor approaches. 5 - 6 - use weaver_editor_core::SmolStr; 7 - 8 - use super::document::Selection; 9 - use super::paragraph::ParagraphRender; 10 - use super::writer::{SyntaxSpanInfo, SyntaxType}; 11 - use std::collections::HashSet; 12 - use std::ops::Range; 13 - 14 - /// Determines which syntax spans should be visible based on cursor/selection. 15 - #[derive(Debug, Clone, Default)] 16 - pub struct VisibilityState { 17 - /// Set of syn_ids that should be visible 18 - pub visible_span_ids: HashSet<SmolStr>, 19 - } 20 - 21 - impl VisibilityState { 22 - /// Calculate visibility based on cursor position and selection. 23 - pub fn calculate( 24 - cursor_offset: usize, 25 - selection: Option<&Selection>, 26 - syntax_spans: &[SyntaxSpanInfo], 27 - paragraphs: &[ParagraphRender], 28 - ) -> Self { 29 - let mut visible = HashSet::new(); 30 - 31 - for span in syntax_spans { 32 - // Find the paragraph containing this span for boundary clamping 33 - let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); 3 + //! Re-exports core visibility logic and browser DOM updates. 34 4 35 - let should_show = match span.syntax_type { 36 - SyntaxType::Inline => { 37 - // Show if cursor within formatted span content OR adjacent to markers 38 - // "Adjacent" means within 1 char of the syntax boundaries, 39 - // clamped to paragraph bounds (paragraphs are split by newlines, 40 - // so clamping to para bounds prevents cross-line extension) 41 - let extended_start = 42 - safe_extend_left(span.char_range.start, 1, para_bounds.as_ref()); 43 - let extended_end = 44 - safe_extend_right(span.char_range.end, 1, para_bounds.as_ref()); 45 - let extended_range = extended_start..extended_end; 5 + // Core visibility calculation. 6 + pub use weaver_editor_core::VisibilityState; 46 7 47 - // Also show if cursor is anywhere in the formatted_range 48 - // (the region between paired opening/closing markers) 49 - // Extend by 1 char on BOTH sides for symmetric "approaching" behavior, 50 - // clamped to paragraph bounds. 51 - let in_formatted_region = span 52 - .formatted_range 53 - .as_ref() 54 - .map(|r| { 55 - let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref()); 56 - let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref()); 57 - cursor_offset >= ext_start && cursor_offset <= ext_end 58 - }) 59 - .unwrap_or(false); 60 - 61 - let in_extended = extended_range.contains(&cursor_offset); 62 - let result = in_extended 63 - || in_formatted_region 64 - || selection_overlaps(selection, &span.char_range) 65 - || span 66 - .formatted_range 67 - .as_ref() 68 - .map(|r| selection_overlaps(selection, r)) 69 - .unwrap_or(false); 70 - 71 - result 72 - } 73 - SyntaxType::Block => { 74 - // Show if cursor anywhere in same paragraph (with slop for edge cases) 75 - // The slop handles typing at the end of a heading like "# |" 76 - let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); 77 - let in_paragraph = para_bounds 78 - .as_ref() 79 - .map(|p| { 80 - // Extend paragraph bounds by 1 char on each side for slop 81 - let ext_start = p.start.saturating_sub(1); 82 - let ext_end = p.end.saturating_add(1); 83 - cursor_offset >= ext_start && cursor_offset <= ext_end 84 - }) 85 - .unwrap_or(false); 86 - 87 - in_paragraph || selection_overlaps(selection, &span.char_range) 88 - } 89 - }; 90 - 91 - if should_show { 92 - visible.insert(span.syn_id.clone()); 93 - } 94 - } 95 - 96 - tracing::debug!( 97 - target: "weaver::visibility", 98 - cursor_offset, 99 - total_spans = syntax_spans.len(), 100 - visible_count = visible.len(), 101 - "calculated visibility" 102 - ); 103 - 104 - Self { 105 - visible_span_ids: visible, 106 - } 107 - } 108 - 109 - /// Check if a specific span should be visible. 110 - pub fn is_visible(&self, syn_id: &str) -> bool { 111 - self.visible_span_ids.contains(syn_id) 112 - } 113 - } 114 - 115 - /// Check if selection overlaps with a char range. 116 - fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool { 117 - let Some(sel) = selection else { 118 - return false; 119 - }; 120 - 121 - let sel_start = sel.anchor.min(sel.head); 122 - let sel_end = sel.anchor.max(sel.head); 123 - 124 - // Check if ranges overlap 125 - sel_start < range.end && sel_end > range.start 126 - } 127 - 128 - /// Check if cursor is in the same paragraph as a syntax span. 129 - #[allow(dead_code)] 130 - fn cursor_in_same_paragraph( 131 - cursor_offset: usize, 132 - syntax_range: &Range<usize>, 133 - paragraphs: &[ParagraphRender], 134 - ) -> bool { 135 - // Find which paragraph contains the syntax span 136 - for para in paragraphs { 137 - // Skip gap paragraphs (they have no syntax spans) 138 - if para.syntax_spans.is_empty() && !para.char_range.is_empty() { 139 - continue; 140 - } 141 - 142 - // Check if this paragraph contains the syntax span 143 - if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { 144 - // Check if cursor is also in this paragraph 145 - return para.char_range.contains(&cursor_offset); 146 - } 147 - } 148 - 149 - false 150 - } 151 - 152 - /// Find the paragraph bounds containing a syntax span. 153 - fn find_paragraph_bounds( 154 - syntax_range: &Range<usize>, 155 - paragraphs: &[ParagraphRender], 156 - ) -> Option<Range<usize>> { 157 - for para in paragraphs { 158 - // Skip gap paragraphs 159 - if para.syntax_spans.is_empty() && !para.char_range.is_empty() { 160 - continue; 161 - } 162 - 163 - if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { 164 - return Some(para.char_range.clone()); 165 - } 166 - } 167 - None 168 - } 169 - 170 - /// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds. 171 - /// 172 - /// Paragraphs are already split by newlines, so clamping to paragraph bounds 173 - /// naturally prevents extending across line boundaries. 174 - fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize { 175 - let min_pos = para_bounds.map(|p| p.start).unwrap_or(0); 176 - pos.saturating_sub(amount).max(min_pos) 177 - } 178 - 179 - /// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds. 180 - /// 181 - /// Paragraphs are already split by newlines, so clamping to paragraph bounds 182 - /// naturally prevents extending across line boundaries. 183 - fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize { 184 - let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX); 185 - pos.saturating_add(amount).min(max_pos) 186 - } 187 - 188 - /// Update syntax span visibility in the DOM based on cursor position. 189 - /// 190 - /// Toggles the "hidden" class on syntax spans based on calculated visibility. 191 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 192 - pub fn update_syntax_visibility( 193 - cursor_offset: usize, 194 - selection: Option<&Selection>, 195 - syntax_spans: &[SyntaxSpanInfo], 196 - paragraphs: &[ParagraphRender], 197 - ) { 198 - use wasm_bindgen::JsCast; 199 - 200 - let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 201 - 202 - let Some(window) = web_sys::window() else { 203 - return; 204 - }; 205 - let Some(document) = window.document() else { 206 - return; 207 - }; 208 - 209 - // Single querySelectorAll instead of N individual queries 210 - let Ok(node_list) = document.query_selector_all("[data-syn-id]") else { 211 - return; 212 - }; 213 - 214 - for i in 0..node_list.length() { 215 - let Some(node) = node_list.item(i) else { 216 - continue; 217 - }; 218 - 219 - // Cast to Element to access attributes and class_list 220 - let Some(element) = node.dyn_ref::<web_sys::Element>() else { 221 - continue; 222 - }; 223 - 224 - let Some(syn_id) = element.get_attribute("data-syn-id") else { 225 - continue; 226 - }; 227 - 228 - let class_list = element.class_list(); 229 - if visibility.is_visible(&syn_id) { 230 - let _ = class_list.remove_1("hidden"); 231 - } else { 232 - let _ = class_list.add_1("hidden"); 233 - } 234 - } 235 - } 236 - 237 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 238 - pub fn update_syntax_visibility( 239 - _cursor_offset: usize, 240 - _selection: Option<&Selection>, 241 - _syntax_spans: &[SyntaxSpanInfo], 242 - _paragraphs: &[ParagraphRender], 243 - ) { 244 - // No-op on non-wasm 245 - } 246 - 247 - #[cfg(test)] 248 - mod tests { 249 - use super::*; 250 - 251 - fn make_span( 252 - syn_id: &str, 253 - start: usize, 254 - end: usize, 255 - syntax_type: SyntaxType, 256 - ) -> SyntaxSpanInfo { 257 - SyntaxSpanInfo { 258 - syn_id: syn_id.into(), 259 - char_range: start..end, 260 - syntax_type, 261 - formatted_range: None, 262 - } 263 - } 264 - 265 - fn make_span_with_range( 266 - syn_id: &str, 267 - start: usize, 268 - end: usize, 269 - syntax_type: SyntaxType, 270 - formatted_range: Range<usize>, 271 - ) -> SyntaxSpanInfo { 272 - SyntaxSpanInfo { 273 - syn_id: syn_id.into(), 274 - char_range: start..end, 275 - syntax_type, 276 - formatted_range: Some(formatted_range), 277 - } 278 - } 279 - 280 - fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender { 281 - ParagraphRender { 282 - id: format!("test-{}-{}", start, end), 283 - byte_range: start..end, 284 - char_range: start..end, 285 - html: String::new(), 286 - offset_map: vec![], 287 - syntax_spans, 288 - source_hash: 0, 289 - } 290 - } 291 - 292 - #[test] 293 - fn test_inline_visibility_cursor_inside() { 294 - // **bold** at chars 0-2 (opening **) and 6-8 (closing **) 295 - // Text positions: 0-1 = **, 2-5 = bold, 6-7 = ** 296 - // formatted_range is 0..8 (the whole **bold** region) 297 - let spans = vec![ 298 - make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** 299 - make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** 300 - ]; 301 - let paras = vec![make_para(0, 8, spans.clone())]; 302 - 303 - // Cursor at position 4 (middle of "bold", inside formatted region) 304 - let vis = VisibilityState::calculate(4, None, &spans, &paras); 305 - assert!( 306 - vis.is_visible("s0"), 307 - "opening ** should be visible when cursor inside formatted region" 308 - ); 309 - assert!( 310 - vis.is_visible("s1"), 311 - "closing ** should be visible when cursor inside formatted region" 312 - ); 313 - 314 - // Cursor at position 2 (adjacent to opening **, start of "bold") 315 - let vis = VisibilityState::calculate(2, None, &spans, &paras); 316 - assert!( 317 - vis.is_visible("s0"), 318 - "opening ** should be visible when cursor adjacent at start of bold" 319 - ); 320 - 321 - // Cursor at position 5 (adjacent to closing **, end of "bold") 322 - let vis = VisibilityState::calculate(5, None, &spans, &paras); 323 - assert!( 324 - vis.is_visible("s1"), 325 - "closing ** should be visible when cursor adjacent at end of bold" 326 - ); 327 - } 328 - 329 - #[test] 330 - fn test_inline_visibility_without_formatted_range() { 331 - // Test without formatted_range - just adjacency-based visibility 332 - let spans = vec![ 333 - make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range) 334 - make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range) 335 - ]; 336 - let paras = vec![make_para(0, 8, spans.clone())]; 337 - 338 - // Cursor at position 4 (middle of "bold", not adjacent to either marker) 339 - let vis = VisibilityState::calculate(4, None, &spans, &paras); 340 - assert!( 341 - !vis.is_visible("s0"), 342 - "opening ** should be hidden when no formatted_range and cursor not adjacent" 343 - ); 344 - assert!( 345 - !vis.is_visible("s1"), 346 - "closing ** should be hidden when no formatted_range and cursor not adjacent" 347 - ); 348 - } 349 - 350 - #[test] 351 - fn test_inline_visibility_cursor_adjacent() { 352 - // "test **bold** after" 353 - // 5 7 354 - let spans = vec![ 355 - make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 356 - ]; 357 - let paras = vec![make_para(0, 19, spans.clone())]; 358 - 359 - // Cursor at position 4 (one before ** which starts at 5) 360 - let vis = VisibilityState::calculate(4, None, &spans, &paras); 361 - assert!( 362 - vis.is_visible("s0"), 363 - "** should be visible when cursor adjacent" 364 - ); 365 - 366 - // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) 367 - let vis = VisibilityState::calculate(7, None, &spans, &paras); 368 - assert!( 369 - vis.is_visible("s0"), 370 - "** should be visible when cursor adjacent after span" 371 - ); 372 - } 373 - 374 - #[test] 375 - fn test_inline_visibility_cursor_far() { 376 - let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)]; 377 - let paras = vec![make_para(0, 33, spans.clone())]; 378 - 379 - // Cursor at position 0 (far from **) 380 - let vis = VisibilityState::calculate(0, None, &spans, &paras); 381 - assert!( 382 - !vis.is_visible("s0"), 383 - "** should be hidden when cursor far away" 384 - ); 385 - } 386 - 387 - #[test] 388 - fn test_block_visibility_same_paragraph() { 389 - // # at start of heading 390 - let spans = vec![ 391 - make_span("s0", 0, 2, SyntaxType::Block), // "# " 392 - ]; 393 - let paras = vec![ 394 - make_para(0, 10, spans.clone()), // heading paragraph 395 - make_para(12, 30, vec![]), // next paragraph 396 - ]; 397 - 398 - // Cursor at position 5 (inside heading) 399 - let vis = VisibilityState::calculate(5, None, &spans, &paras); 400 - assert!( 401 - vis.is_visible("s0"), 402 - "# should be visible when cursor in same paragraph" 403 - ); 404 - } 405 - 406 - #[test] 407 - fn test_block_visibility_different_paragraph() { 408 - let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)]; 409 - let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])]; 410 - 411 - // Cursor at position 20 (in second paragraph) 412 - let vis = VisibilityState::calculate(20, None, &spans, &paras); 413 - assert!( 414 - !vis.is_visible("s0"), 415 - "# should be hidden when cursor in different paragraph" 416 - ); 417 - } 418 - 419 - #[test] 420 - fn test_selection_reveals_syntax() { 421 - let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)]; 422 - let paras = vec![make_para(0, 24, spans.clone())]; 423 - 424 - // Selection overlaps the syntax span 425 - let selection = Selection { 426 - anchor: 3, 427 - head: 10, 428 - }; 429 - let vis = VisibilityState::calculate(10, Some(&selection), &spans, &paras); 430 - assert!( 431 - vis.is_visible("s0"), 432 - "** should be visible when selection overlaps" 433 - ); 434 - } 435 - 436 - #[test] 437 - fn test_paragraph_boundary_blocks_extension() { 438 - // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1, 439 - // even if cursor is only 1 char after the paragraph boundary 440 - // (paragraph bounds clamp the extension) 441 - let spans = vec![ 442 - make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** 443 - make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** 444 - ]; 445 - let paras = vec![ 446 - make_para(0, 8, spans.clone()), // "**bold**" 447 - make_para(9, 13, vec![]), // "text" (after newline) 448 - ]; 449 - 450 - // Cursor at position 9 (start of second paragraph) 451 - // Should NOT reveal the closing ** because para bounds clamp extension 452 - let vis = VisibilityState::calculate(9, None, &spans, &paras); 453 - assert!( 454 - !vis.is_visible("s1"), 455 - "closing ** should NOT be visible when cursor is in next paragraph" 456 - ); 457 - } 458 - 459 - #[test] 460 - fn test_extension_clamps_to_paragraph() { 461 - // Syntax at very start of paragraph - extension left should stop at para start 462 - let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)]; 463 - let paras = vec![make_para(0, 8, spans.clone())]; 464 - 465 - // Cursor at position 0 - should still see the opening ** 466 - let vis = VisibilityState::calculate(0, None, &spans, &paras); 467 - assert!( 468 - vis.is_visible("s0"), 469 - "** at start should be visible when cursor at position 0" 470 - ); 471 - } 472 - } 8 + // Browser DOM updates. 9 + pub use weaver_editor_browser::update_syntax_visibility;
+191
crates/weaver-editor-browser/src/events.rs
··· 255 255 256 256 Ok(result.as_string()) 257 257 } 258 + 259 + // === BeforeInput handler === 260 + 261 + use weaver_editor_core::{EditorAction, EditorDocument, execute_action}; 262 + 263 + /// Handle a beforeinput event, dispatching to the appropriate action. 264 + /// 265 + /// This is the main entry point for beforeinput-based input handling. 266 + /// The `current_range` parameter should be the current cursor/selection range 267 + /// from the document when `ctx.target_range` is None. 268 + /// 269 + /// Returns the handling result indicating whether default should be prevented. 270 + pub fn handle_beforeinput<D: EditorDocument>( 271 + doc: &mut D, 272 + ctx: &BeforeInputContext<'_>, 273 + current_range: Range, 274 + ) -> BeforeInputResult { 275 + // During composition, let the browser handle most things. 276 + if ctx.is_composing { 277 + match ctx.input_type { 278 + InputType::HistoryUndo | InputType::HistoryRedo => { 279 + // Handle undo/redo even during composition. 280 + } 281 + InputType::InsertCompositionText => { 282 + return BeforeInputResult::PassThrough; 283 + } 284 + _ => { 285 + return BeforeInputResult::PassThrough; 286 + } 287 + } 288 + } 289 + 290 + // Use target range from event, or fall back to current range. 291 + let range = ctx.target_range.unwrap_or(current_range); 292 + 293 + match ctx.input_type { 294 + // === Insertion === 295 + InputType::InsertText => { 296 + if let Some(ref text) = ctx.data { 297 + let action = EditorAction::Insert { 298 + text: text.clone(), 299 + range, 300 + }; 301 + execute_action(doc, &action); 302 + BeforeInputResult::Handled 303 + } else { 304 + BeforeInputResult::PassThrough 305 + } 306 + } 307 + 308 + InputType::InsertLineBreak => { 309 + let action = EditorAction::InsertLineBreak { range }; 310 + execute_action(doc, &action); 311 + BeforeInputResult::Handled 312 + } 313 + 314 + InputType::InsertParagraph => { 315 + let action = EditorAction::InsertParagraph { range }; 316 + execute_action(doc, &action); 317 + BeforeInputResult::Handled 318 + } 319 + 320 + InputType::InsertFromPaste | InputType::InsertReplacementText => { 321 + if let Some(ref text) = ctx.data { 322 + let action = EditorAction::Insert { 323 + text: text.clone(), 324 + range, 325 + }; 326 + execute_action(doc, &action); 327 + BeforeInputResult::Handled 328 + } else { 329 + BeforeInputResult::PassThrough 330 + } 331 + } 332 + 333 + InputType::InsertFromDrop => BeforeInputResult::PassThrough, 334 + 335 + InputType::InsertCompositionText => BeforeInputResult::PassThrough, 336 + 337 + // === Deletion === 338 + InputType::DeleteContentBackward => { 339 + // Android Chrome workaround: backspace sometimes doesn't work properly. 340 + if ctx.is_android && ctx.is_chrome && range.is_caret() { 341 + let action = EditorAction::DeleteBackward { range }; 342 + return BeforeInputResult::DeferredCheck { 343 + fallback_action: action, 344 + }; 345 + } 346 + 347 + let action = EditorAction::DeleteBackward { range }; 348 + execute_action(doc, &action); 349 + BeforeInputResult::Handled 350 + } 351 + 352 + InputType::DeleteContentForward => { 353 + let action = EditorAction::DeleteForward { range }; 354 + execute_action(doc, &action); 355 + BeforeInputResult::Handled 356 + } 357 + 358 + InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { 359 + let action = EditorAction::DeleteWordBackward { range }; 360 + execute_action(doc, &action); 361 + BeforeInputResult::Handled 362 + } 363 + 364 + InputType::DeleteWordForward | InputType::DeleteEntireWordForward => { 365 + let action = EditorAction::DeleteWordForward { range }; 366 + execute_action(doc, &action); 367 + BeforeInputResult::Handled 368 + } 369 + 370 + InputType::DeleteSoftLineBackward => { 371 + let action = EditorAction::DeleteSoftLineBackward { range }; 372 + execute_action(doc, &action); 373 + BeforeInputResult::Handled 374 + } 375 + 376 + InputType::DeleteSoftLineForward => { 377 + let action = EditorAction::DeleteSoftLineForward { range }; 378 + execute_action(doc, &action); 379 + BeforeInputResult::Handled 380 + } 381 + 382 + InputType::DeleteHardLineBackward => { 383 + let action = EditorAction::DeleteToLineStart { range }; 384 + execute_action(doc, &action); 385 + BeforeInputResult::Handled 386 + } 387 + 388 + InputType::DeleteHardLineForward => { 389 + let action = EditorAction::DeleteToLineEnd { range }; 390 + execute_action(doc, &action); 391 + BeforeInputResult::Handled 392 + } 393 + 394 + InputType::DeleteByCut => { 395 + if !range.is_caret() { 396 + let action = EditorAction::DeleteBackward { range }; 397 + execute_action(doc, &action); 398 + } 399 + BeforeInputResult::Handled 400 + } 401 + 402 + InputType::DeleteByDrag | InputType::DeleteContent => { 403 + if !range.is_caret() { 404 + let action = EditorAction::DeleteBackward { range }; 405 + execute_action(doc, &action); 406 + } 407 + BeforeInputResult::Handled 408 + } 409 + 410 + // === History === 411 + InputType::HistoryUndo => { 412 + execute_action(doc, &EditorAction::Undo); 413 + BeforeInputResult::Handled 414 + } 415 + 416 + InputType::HistoryRedo => { 417 + execute_action(doc, &EditorAction::Redo); 418 + BeforeInputResult::Handled 419 + } 420 + 421 + // === Formatting === 422 + InputType::FormatBold => { 423 + execute_action(doc, &EditorAction::ToggleBold); 424 + BeforeInputResult::Handled 425 + } 426 + 427 + InputType::FormatItalic => { 428 + execute_action(doc, &EditorAction::ToggleItalic); 429 + BeforeInputResult::Handled 430 + } 431 + 432 + InputType::FormatStrikethrough => { 433 + execute_action(doc, &EditorAction::ToggleStrikethrough); 434 + BeforeInputResult::Handled 435 + } 436 + 437 + // === Not handled === 438 + InputType::InsertFromYank 439 + | InputType::InsertHorizontalRule 440 + | InputType::InsertOrderedList 441 + | InputType::InsertUnorderedList 442 + | InputType::InsertLink 443 + | InputType::FormatUnderline 444 + | InputType::FormatSuperscript 445 + | InputType::FormatSubscript 446 + | InputType::Unknown(_) => BeforeInputResult::PassThrough, 447 + } 448 + }
+5 -1
crates/weaver-editor-browser/src/lib.rs
··· 24 24 pub mod dom_sync; 25 25 pub mod events; 26 26 pub mod platform; 27 + pub mod visibility; 27 28 28 29 // Browser cursor implementation 29 30 pub use cursor::BrowserCursor; ··· 34 35 // Event handling 35 36 pub use events::{ 36 37 BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, 37 - get_input_type_from_event, get_target_range_from_event, is_composing, 38 + get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing, 38 39 parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, 39 40 }; 40 41 41 42 // Platform detection 42 43 pub use platform::{Platform, platform}; 44 + 45 + // Visibility updates 46 + pub use visibility::update_syntax_visibility;
+90
crates/weaver-editor-browser/src/visibility.rs
··· 1 + //! DOM-based syntax visibility updates. 2 + //! 3 + //! This module applies visibility state to the DOM by toggling CSS classes 4 + //! on syntax span elements. Works with the core `VisibilityState` calculation. 5 + //! 6 + //! # How it works 7 + //! 8 + //! 1. Core's `VisibilityState::calculate()` determines which syntax spans should be visible 9 + //! 2. This module's `update_syntax_visibility()` applies that state to the DOM 10 + //! 3. Elements with `data-syn-id` attributes get "hidden" class toggled 11 + //! 12 + //! # CSS Integration 13 + //! 14 + //! Your CSS should hide elements with the "hidden" class: 15 + //! ```css 16 + //! [data-syn-id].hidden { 17 + //! opacity: 0; 18 + //! /* or display: none, visibility: hidden, etc. */ 19 + //! } 20 + //! ``` 21 + 22 + use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState}; 23 + 24 + /// Update syntax span visibility in the DOM based on cursor position. 25 + /// 26 + /// Calculates which syntax spans should be visible using `VisibilityState::calculate()`, 27 + /// then toggles the "hidden" class on matching DOM elements. 28 + /// 29 + /// # Parameters 30 + /// - `cursor_offset`: Current cursor position in characters 31 + /// - `selection`: Optional text selection 32 + /// - `syntax_spans`: All syntax spans from rendered paragraphs 33 + /// - `paragraphs`: Rendered paragraph data for boundary detection 34 + /// 35 + /// # DOM Requirements 36 + /// Syntax span elements must have `data-syn-id` attributes matching `SyntaxSpanInfo.syn_id`. 37 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 38 + pub fn update_syntax_visibility( 39 + cursor_offset: usize, 40 + selection: Option<&Selection>, 41 + syntax_spans: &[SyntaxSpanInfo], 42 + paragraphs: &[ParagraphRender], 43 + ) { 44 + use wasm_bindgen::JsCast; 45 + 46 + let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 47 + 48 + let Some(window) = web_sys::window() else { 49 + return; 50 + }; 51 + let Some(document) = window.document() else { 52 + return; 53 + }; 54 + 55 + // Single querySelectorAll instead of N individual queries. 56 + let Ok(node_list) = document.query_selector_all("[data-syn-id]") else { 57 + return; 58 + }; 59 + 60 + for i in 0..node_list.length() { 61 + let Some(node) = node_list.item(i) else { 62 + continue; 63 + }; 64 + 65 + let Some(element) = node.dyn_ref::<web_sys::Element>() else { 66 + continue; 67 + }; 68 + 69 + let Some(syn_id) = element.get_attribute("data-syn-id") else { 70 + continue; 71 + }; 72 + 73 + let class_list = element.class_list(); 74 + if visibility.is_visible(&syn_id) { 75 + let _ = class_list.remove_1("hidden"); 76 + } else { 77 + let _ = class_list.add_1("hidden"); 78 + } 79 + } 80 + } 81 + 82 + /// No-op on non-WASM targets. 83 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 84 + pub fn update_syntax_visibility( 85 + _cursor_offset: usize, 86 + _selection: Option<&Selection>, 87 + _syntax_spans: &[SyntaxSpanInfo], 88 + _paragraphs: &[ParagraphRender], 89 + ) { 90 + }
+212
crates/weaver-editor-browser/tests/web.rs
··· 1 + //! WASM browser tests for weaver-editor-browser. 2 + //! 3 + //! Run with: `wasm-pack test --headless --firefox` or `--chrome` 4 + 5 + use wasm_bindgen_test::*; 6 + 7 + wasm_bindgen_test_configure!(run_in_browser); 8 + 9 + use weaver_editor_browser::{ 10 + BeforeInputContext, BeforeInputResult, InputType, Range, handle_beforeinput, 11 + parse_browser_input_type, platform, 12 + }; 13 + use weaver_editor_core::{EditorDocument, EditorRope, PlainEditor, UndoableBuffer}; 14 + 15 + type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>; 16 + 17 + fn make_editor(content: &str) -> TestEditor { 18 + let rope = EditorRope::from_str(content); 19 + let buf = UndoableBuffer::new(rope, 100); 20 + PlainEditor::new(buf) 21 + } 22 + 23 + // === InputType parsing tests === 24 + 25 + #[wasm_bindgen_test] 26 + fn test_parse_insert_text() { 27 + assert_eq!( 28 + parse_browser_input_type("insertText"), 29 + InputType::InsertText 30 + ); 31 + } 32 + 33 + #[wasm_bindgen_test] 34 + fn test_parse_delete_backward() { 35 + assert_eq!( 36 + parse_browser_input_type("deleteContentBackward"), 37 + InputType::DeleteContentBackward 38 + ); 39 + } 40 + 41 + #[wasm_bindgen_test] 42 + fn test_parse_unknown() { 43 + match parse_browser_input_type("unknownType") { 44 + InputType::Unknown(s) => assert_eq!(s, "unknownType"), 45 + _ => panic!("Expected Unknown variant"), 46 + } 47 + } 48 + 49 + // === Platform detection tests === 50 + 51 + #[wasm_bindgen_test] 52 + fn test_platform_detection() { 53 + let plat = platform(); 54 + // Just verify it returns something without panicking. 55 + // Actual values depend on the browser running the test. 56 + let _ = plat.mac; 57 + let _ = plat.safari; 58 + let _ = plat.chrome; 59 + let _ = plat.gecko; 60 + let _ = plat.android; 61 + let _ = plat.ios; 62 + let _ = plat.mobile; 63 + } 64 + 65 + // === BeforeInput handler tests === 66 + 67 + #[wasm_bindgen_test] 68 + fn test_handle_insert_text() { 69 + let mut editor = make_editor("hello"); 70 + editor.set_cursor_offset(5); 71 + 72 + let ctx = BeforeInputContext { 73 + input_type: InputType::InsertText, 74 + data: Some(" world".to_string()), 75 + target_range: None, 76 + is_composing: false, 77 + is_android: false, 78 + is_chrome: false, 79 + offset_map: &[], 80 + }; 81 + 82 + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); 83 + assert!(matches!(result, BeforeInputResult::Handled)); 84 + assert_eq!(editor.content_string(), "hello world"); 85 + } 86 + 87 + #[wasm_bindgen_test] 88 + fn test_handle_delete_backward() { 89 + let mut editor = make_editor("hello"); 90 + editor.set_cursor_offset(5); 91 + 92 + let ctx = BeforeInputContext { 93 + input_type: InputType::DeleteContentBackward, 94 + data: None, 95 + target_range: None, 96 + is_composing: false, 97 + is_android: false, 98 + is_chrome: false, 99 + offset_map: &[], 100 + }; 101 + 102 + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); 103 + assert!(matches!(result, BeforeInputResult::Handled)); 104 + assert_eq!(editor.content_string(), "hell"); 105 + } 106 + 107 + #[wasm_bindgen_test] 108 + fn test_handle_composition_passthrough() { 109 + let mut editor = make_editor("hello"); 110 + 111 + let ctx = BeforeInputContext { 112 + input_type: InputType::InsertText, 113 + data: Some("x".to_string()), 114 + target_range: None, 115 + is_composing: true, // During composition 116 + is_android: false, 117 + is_chrome: false, 118 + offset_map: &[], 119 + }; 120 + 121 + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); 122 + assert!(matches!(result, BeforeInputResult::PassThrough)); 123 + // Document unchanged during composition passthrough. 124 + assert_eq!(editor.content_string(), "hello"); 125 + } 126 + 127 + #[wasm_bindgen_test] 128 + fn test_handle_undo_redo() { 129 + let mut editor = make_editor("hello"); 130 + editor.set_cursor_offset(5); 131 + 132 + // Insert text first. 133 + let insert_ctx = BeforeInputContext { 134 + input_type: InputType::InsertText, 135 + data: Some(" world".to_string()), 136 + target_range: None, 137 + is_composing: false, 138 + is_android: false, 139 + is_chrome: false, 140 + offset_map: &[], 141 + }; 142 + handle_beforeinput(&mut editor, &insert_ctx, Range::caret(5)); 143 + assert_eq!(editor.content_string(), "hello world"); 144 + 145 + // Undo. 146 + let undo_ctx = BeforeInputContext { 147 + input_type: InputType::HistoryUndo, 148 + data: None, 149 + target_range: None, 150 + is_composing: false, 151 + is_android: false, 152 + is_chrome: false, 153 + offset_map: &[], 154 + }; 155 + let result = handle_beforeinput(&mut editor, &undo_ctx, Range::caret(11)); 156 + assert!(matches!(result, BeforeInputResult::Handled)); 157 + assert_eq!(editor.content_string(), "hello"); 158 + 159 + // Redo. 160 + let redo_ctx = BeforeInputContext { 161 + input_type: InputType::HistoryRedo, 162 + data: None, 163 + target_range: None, 164 + is_composing: false, 165 + is_android: false, 166 + is_chrome: false, 167 + offset_map: &[], 168 + }; 169 + let result = handle_beforeinput(&mut editor, &redo_ctx, Range::caret(5)); 170 + assert!(matches!(result, BeforeInputResult::Handled)); 171 + assert_eq!(editor.content_string(), "hello world"); 172 + } 173 + 174 + #[wasm_bindgen_test] 175 + fn test_handle_insert_paragraph() { 176 + let mut editor = make_editor("hello"); 177 + editor.set_cursor_offset(5); 178 + 179 + let ctx = BeforeInputContext { 180 + input_type: InputType::InsertParagraph, 181 + data: None, 182 + target_range: None, 183 + is_composing: false, 184 + is_android: false, 185 + is_chrome: false, 186 + offset_map: &[], 187 + }; 188 + 189 + let result = handle_beforeinput(&mut editor, &ctx, Range::caret(5)); 190 + assert!(matches!(result, BeforeInputResult::Handled)); 191 + // InsertParagraph inserts double newline. 192 + assert!(editor.content_string().contains("\n\n")); 193 + } 194 + 195 + #[wasm_bindgen_test] 196 + fn test_handle_selection_delete() { 197 + let mut editor = make_editor("hello world"); 198 + 199 + let ctx = BeforeInputContext { 200 + input_type: InputType::DeleteContentBackward, 201 + data: None, 202 + target_range: Some(Range::new(5, 11)), // Select " world" 203 + is_composing: false, 204 + is_android: false, 205 + is_chrome: false, 206 + offset_map: &[], 207 + }; 208 + 209 + let result = handle_beforeinput(&mut editor, &ctx, Range::new(5, 11)); 210 + assert!(matches!(result, BeforeInputResult::Handled)); 211 + assert_eq!(editor.content_string(), "hello"); 212 + }
+20
crates/weaver-editor-core/src/actions.rs
··· 181 181 } 182 182 } 183 183 184 + /// High-level formatting actions for markdown editing. 185 + /// 186 + /// These represent user-initiated formatting operations that wrap or modify 187 + /// text with markdown syntax. 188 + #[derive(Debug, Clone, PartialEq, Eq)] 189 + #[non_exhaustive] 190 + pub enum FormatAction { 191 + Bold, 192 + Italic, 193 + Strikethrough, 194 + Code, 195 + Link, 196 + Image, 197 + /// Heading level 1-6. 198 + Heading(u8), 199 + BulletList, 200 + NumberedList, 201 + Quote, 202 + } 203 + 184 204 /// All possible editor actions. 185 205 /// 186 206 /// These represent semantic operations on the document, decoupled from
+7 -1
crates/weaver-editor-core/src/lib.rs
··· 16 16 pub mod paragraph; 17 17 pub mod platform; 18 18 pub mod render; 19 + pub mod render_cache; 19 20 pub mod syntax; 20 21 pub mod text; 21 22 pub mod text_helpers; ··· 43 44 pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; 44 45 pub use platform::{CursorPlatform, CursorSync, PlatformError}; 45 46 pub use actions::{ 46 - EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 47 + EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, 48 + Modifiers, Range, 47 49 }; 48 50 pub use execute::execute_action; 49 51 pub use text_helpers::{ ··· 51 53 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, 52 54 is_zero_width_char, 53 55 }; 56 + pub use render_cache::{ 57 + CachedParagraph, IncrementalRenderResult, RenderCache, apply_delta, is_boundary_affecting, 58 + render_paragraphs_incremental, 59 + };
+757
crates/weaver-editor-core/src/render_cache.rs
··· 1 + //! Render caching and incremental paragraph rendering. 2 + //! 3 + //! This module provides infrastructure for incremental markdown rendering, 4 + //! caching paragraph renders to avoid re-rendering unchanged content. 5 + 6 + use std::ops::Range; 7 + 8 + use smol_str::SmolStr; 9 + 10 + use crate::offset_map::OffsetMapping; 11 + use crate::paragraph::{ParagraphRender, hash_source, make_paragraph_id}; 12 + use crate::syntax::SyntaxSpanInfo; 13 + use crate::text::TextBuffer; 14 + use crate::types::EditInfo; 15 + use crate::writer::EditorWriter; 16 + use crate::{EditorRope, EmbedContentProvider, ImageResolver}; 17 + 18 + use markdown_weaver::Parser; 19 + use weaver_common::ExtractedRef; 20 + 21 + /// Cache for incremental paragraph rendering. 22 + /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. 23 + #[derive(Clone, Debug, Default)] 24 + pub struct RenderCache { 25 + /// Cached paragraph renders (content paragraphs only, gaps computed fresh). 26 + pub paragraphs: Vec<CachedParagraph>, 27 + /// Next available node ID for fresh renders. 28 + pub next_node_id: usize, 29 + /// Next available syntax span ID for fresh renders. 30 + pub next_syn_id: usize, 31 + /// Next available paragraph ID (monotonic counter). 32 + pub next_para_id: usize, 33 + } 34 + 35 + /// A cached paragraph render that can be reused if source hasn't changed. 36 + #[derive(Clone, Debug)] 37 + pub struct CachedParagraph { 38 + /// Stable monotonic ID for DOM element identity. 39 + pub id: SmolStr, 40 + /// Hash of paragraph source text for change detection. 41 + pub source_hash: u64, 42 + /// Byte range in source document. 43 + pub byte_range: Range<usize>, 44 + /// Char range in source document. 45 + pub char_range: Range<usize>, 46 + /// Rendered HTML. 47 + pub html: String, 48 + /// Offset mappings for cursor positioning. 49 + pub offset_map: Vec<OffsetMapping>, 50 + /// Syntax spans for conditional visibility. 51 + pub syntax_spans: Vec<SyntaxSpanInfo>, 52 + /// Collected refs (wikilinks, AT embeds) from this paragraph. 53 + pub collected_refs: Vec<ExtractedRef>, 54 + } 55 + 56 + /// Check if an edit affects paragraph boundaries. 57 + /// 58 + /// Edits that don't contain newlines and aren't in the block-syntax zone 59 + /// are considered "safe" and can skip boundary rediscovery. 60 + pub fn is_boundary_affecting(edit: &EditInfo) -> bool { 61 + // Newlines always affect boundaries (paragraph splits/joins). 62 + if edit.contains_newline { 63 + return true; 64 + } 65 + 66 + // Edits in the block-syntax zone (first ~6 chars of line) could affect 67 + // headings, lists, blockquotes, code fences, etc. 68 + if edit.in_block_syntax_zone { 69 + return true; 70 + } 71 + 72 + false 73 + } 74 + 75 + /// Apply a signed delta to a usize, saturating at 0 on underflow. 76 + pub fn apply_delta(val: usize, delta: isize) -> usize { 77 + if delta >= 0 { 78 + val.saturating_add(delta as usize) 79 + } else { 80 + val.saturating_sub((-delta) as usize) 81 + } 82 + } 83 + 84 + /// Result of incremental paragraph rendering. 85 + pub struct IncrementalRenderResult { 86 + /// Rendered paragraphs. 87 + pub paragraphs: Vec<ParagraphRender>, 88 + /// Updated cache for next render. 89 + pub cache: RenderCache, 90 + /// Collected refs (wikilinks, AT embeds) found during render. 91 + pub collected_refs: Vec<ExtractedRef>, 92 + } 93 + 94 + /// Render markdown with incremental caching. 95 + /// 96 + /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 97 + /// Generic over any `TextBuffer` implementation. 98 + /// 99 + /// # Parameters 100 + /// - `text`: The text buffer to render 101 + /// - `cache`: Optional previous render cache 102 + /// - `cursor_offset`: Current cursor position (for finding which NEW paragraph is the cursor para) 103 + /// - `edit`: Edit info for stable ID assignment 104 + /// - `image_resolver`: Optional image URL resolver 105 + /// - `entry_index`: Optional index for wikilink validation 106 + /// - `embed_provider`: Provider for embed content 107 + /// 108 + /// # Returns 109 + /// `IncrementalRenderResult` containing paragraphs, updated cache, and collected refs. 110 + pub fn render_paragraphs_incremental<T, I, E>( 111 + text: &T, 112 + cache: Option<&RenderCache>, 113 + cursor_offset: usize, 114 + edit: Option<&EditInfo>, 115 + image_resolver: Option<&I>, 116 + entry_index: Option<&weaver_common::EntryIndex>, 117 + embed_provider: &E, 118 + ) -> IncrementalRenderResult 119 + where 120 + T: TextBuffer, 121 + I: ImageResolver + Clone + Default, 122 + E: EmbedContentProvider, 123 + { 124 + let source = text.to_string(); 125 + 126 + // Log source entering renderer to detect ZWC/space issues. 127 + if tracing::enabled!(target: "weaver::render", tracing::Level::TRACE) { 128 + tracing::trace!( 129 + target: "weaver::render", 130 + source_len = source.len(), 131 + source_chars = source.chars().count(), 132 + source_content = %source.escape_debug(), 133 + "render_paragraphs: source entering renderer" 134 + ); 135 + } 136 + 137 + // Handle empty document. 138 + if source.is_empty() { 139 + let empty_node_id = "n0".to_string(); 140 + let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 141 + let para_id = make_paragraph_id(0); 142 + 143 + let para = ParagraphRender { 144 + id: para_id.clone(), 145 + byte_range: 0..0, 146 + char_range: 0..0, 147 + html: empty_html.clone(), 148 + offset_map: vec![], 149 + syntax_spans: vec![], 150 + source_hash: 0, 151 + }; 152 + 153 + let new_cache = RenderCache { 154 + paragraphs: vec![CachedParagraph { 155 + id: para_id, 156 + source_hash: 0, 157 + byte_range: 0..0, 158 + char_range: 0..0, 159 + html: empty_html, 160 + offset_map: vec![], 161 + syntax_spans: vec![], 162 + collected_refs: vec![], 163 + }], 164 + next_node_id: 1, 165 + next_syn_id: 0, 166 + next_para_id: 1, 167 + }; 168 + 169 + return IncrementalRenderResult { 170 + paragraphs: vec![para], 171 + cache: new_cache, 172 + collected_refs: vec![], 173 + }; 174 + } 175 + 176 + // Determine if we can use fast path (skip boundary discovery). 177 + let current_len = text.len_chars(); 178 + let current_byte_len = text.len_bytes(); 179 + 180 + // If we have cache but no edit, just return cached data (no re-render needed). 181 + // This happens on cursor position changes, clicks, etc. 182 + if let (Some(c), None) = (cache, edit) { 183 + let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); 184 + if cached_len == current_len { 185 + tracing::trace!( 186 + target: "weaver::render", 187 + "no edit, returning cached paragraphs" 188 + ); 189 + let paragraphs: Vec<ParagraphRender> = c 190 + .paragraphs 191 + .iter() 192 + .map(|p| ParagraphRender { 193 + id: p.id.clone(), 194 + byte_range: p.byte_range.clone(), 195 + char_range: p.char_range.clone(), 196 + html: p.html.clone(), 197 + offset_map: p.offset_map.clone(), 198 + syntax_spans: p.syntax_spans.clone(), 199 + source_hash: p.source_hash, 200 + }) 201 + .collect(); 202 + return IncrementalRenderResult { 203 + paragraphs, 204 + cache: c.clone(), 205 + collected_refs: c 206 + .paragraphs 207 + .iter() 208 + .flat_map(|p| p.collected_refs.clone()) 209 + .collect(), 210 + }; 211 + } 212 + } 213 + 214 + let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 215 + 216 + tracing::debug!( 217 + target: "weaver::render", 218 + use_fast_path, 219 + has_cache = cache.is_some(), 220 + has_edit = edit.is_some(), 221 + boundary_affecting = edit.map(is_boundary_affecting), 222 + current_len, 223 + "render path decision" 224 + ); 225 + 226 + // Get paragraph boundaries. 227 + let paragraph_ranges = if use_fast_path { 228 + // Fast path: adjust cached boundaries based on actual length change. 229 + let cache = cache.unwrap(); 230 + let edit = edit.unwrap(); 231 + 232 + let edit_pos = edit.edit_char_pos; 233 + 234 + let (cached_len, cached_byte_len) = cache 235 + .paragraphs 236 + .last() 237 + .map(|p| (p.char_range.end, p.byte_range.end)) 238 + .unwrap_or((0, 0)); 239 + let char_delta = current_len as isize - cached_len as isize; 240 + let byte_delta = current_byte_len as isize - cached_byte_len as isize; 241 + 242 + cache 243 + .paragraphs 244 + .iter() 245 + .map(|p| { 246 + if p.char_range.end < edit_pos { 247 + (p.byte_range.clone(), p.char_range.clone()) 248 + } else if p.char_range.start > edit_pos { 249 + ( 250 + apply_delta(p.byte_range.start, byte_delta) 251 + ..apply_delta(p.byte_range.end, byte_delta), 252 + apply_delta(p.char_range.start, char_delta) 253 + ..apply_delta(p.char_range.end, char_delta), 254 + ) 255 + } else { 256 + ( 257 + p.byte_range.start..apply_delta(p.byte_range.end, byte_delta), 258 + p.char_range.start..apply_delta(p.char_range.end, char_delta), 259 + ) 260 + } 261 + }) 262 + .collect::<Vec<_>>() 263 + } else { 264 + vec![] 265 + }; 266 + 267 + // Validate fast path results. 268 + let use_fast_path = if !paragraph_ranges.is_empty() { 269 + let all_valid = paragraph_ranges 270 + .iter() 271 + .all(|(_, char_range)| char_range.start <= char_range.end); 272 + if !all_valid { 273 + tracing::debug!( 274 + target: "weaver::render", 275 + "fast path produced invalid ranges, falling back to slow path" 276 + ); 277 + false 278 + } else { 279 + true 280 + } 281 + } else { 282 + false 283 + }; 284 + 285 + // ============ FAST PATH ============ 286 + if use_fast_path { 287 + let cache = cache.unwrap(); 288 + let edit = edit.unwrap(); 289 + let edit_pos = edit.edit_char_pos; 290 + 291 + let (cached_len, cached_byte_len) = cache 292 + .paragraphs 293 + .last() 294 + .map(|p| (p.char_range.end, p.byte_range.end)) 295 + .unwrap_or((0, 0)); 296 + let char_delta = current_len as isize - cached_len as isize; 297 + let byte_delta = current_byte_len as isize - cached_byte_len as isize; 298 + 299 + let cursor_para_idx = cache 300 + .paragraphs 301 + .iter() 302 + .position(|p| p.char_range.start <= edit_pos && edit_pos <= p.char_range.end); 303 + 304 + let mut paragraphs = Vec::with_capacity(cache.paragraphs.len()); 305 + let mut new_cached = Vec::with_capacity(cache.paragraphs.len()); 306 + let mut all_refs: Vec<ExtractedRef> = Vec::new(); 307 + 308 + for (idx, cached_para) in cache.paragraphs.iter().enumerate() { 309 + let is_cursor_para = Some(idx) == cursor_para_idx; 310 + 311 + let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { 312 + ( 313 + cached_para.byte_range.clone(), 314 + cached_para.char_range.clone(), 315 + ) 316 + } else if cached_para.char_range.start > edit_pos { 317 + ( 318 + apply_delta(cached_para.byte_range.start, byte_delta) 319 + ..apply_delta(cached_para.byte_range.end, byte_delta), 320 + apply_delta(cached_para.char_range.start, char_delta) 321 + ..apply_delta(cached_para.char_range.end, char_delta), 322 + ) 323 + } else { 324 + ( 325 + cached_para.byte_range.start 326 + ..apply_delta(cached_para.byte_range.end, byte_delta), 327 + cached_para.char_range.start 328 + ..apply_delta(cached_para.char_range.end, char_delta), 329 + ) 330 + }; 331 + 332 + let para_source = text 333 + .slice(char_range.clone()) 334 + .map(|s| s.to_string()) 335 + .unwrap_or_default(); 336 + let source_hash = hash_source(&para_source); 337 + 338 + if is_cursor_para { 339 + // Re-render cursor paragraph for fresh syntax detection. 340 + let resolver = image_resolver.cloned().unwrap_or_default(); 341 + let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 342 + .into_offset_iter(); 343 + 344 + let para_rope = EditorRope::from(para_source.as_str()); 345 + 346 + let mut writer = EditorWriter::<_, _, &E, &I, ()>::new( 347 + &para_source, 348 + &para_rope, 349 + parser, 350 + ) 351 + .with_node_id_prefix(&cached_para.id) 352 + .with_image_resolver(&resolver) 353 + .with_embed_provider(embed_provider); 354 + 355 + if let Some(idx) = entry_index { 356 + writer = writer.with_entry_index(idx); 357 + } 358 + 359 + let (html, offset_map, syntax_spans, para_refs) = match writer.run() { 360 + Ok(result) => { 361 + let mut offset_map = result 362 + .offset_maps_by_paragraph 363 + .into_iter() 364 + .next() 365 + .unwrap_or_default(); 366 + for m in &mut offset_map { 367 + m.char_range.start += char_range.start; 368 + m.char_range.end += char_range.start; 369 + m.byte_range.start += byte_range.start; 370 + m.byte_range.end += byte_range.start; 371 + } 372 + let mut syntax_spans = result 373 + .syntax_spans_by_paragraph 374 + .into_iter() 375 + .next() 376 + .unwrap_or_default(); 377 + for s in &mut syntax_spans { 378 + s.adjust_positions(char_range.start as isize); 379 + } 380 + let para_refs = result 381 + .collected_refs_by_paragraph 382 + .into_iter() 383 + .next() 384 + .unwrap_or_default(); 385 + let html = result.html_segments.into_iter().next().unwrap_or_default(); 386 + (html, offset_map, syntax_spans, para_refs) 387 + } 388 + Err(_) => (String::new(), Vec::new(), Vec::new(), Vec::new()), 389 + }; 390 + 391 + all_refs.extend(para_refs.clone()); 392 + 393 + new_cached.push(CachedParagraph { 394 + id: cached_para.id.clone(), 395 + source_hash, 396 + byte_range: byte_range.clone(), 397 + char_range: char_range.clone(), 398 + html: html.clone(), 399 + offset_map: offset_map.clone(), 400 + syntax_spans: syntax_spans.clone(), 401 + collected_refs: para_refs.clone(), 402 + }); 403 + 404 + paragraphs.push(ParagraphRender { 405 + id: cached_para.id.clone(), 406 + byte_range, 407 + char_range, 408 + html, 409 + offset_map, 410 + syntax_spans, 411 + source_hash, 412 + }); 413 + } else { 414 + // Reuse cached with adjusted offsets. 415 + let mut offset_map = cached_para.offset_map.clone(); 416 + let mut syntax_spans = cached_para.syntax_spans.clone(); 417 + 418 + if cached_para.char_range.start > edit_pos { 419 + for m in &mut offset_map { 420 + m.char_range.start = apply_delta(m.char_range.start, char_delta); 421 + m.char_range.end = apply_delta(m.char_range.end, char_delta); 422 + m.byte_range.start = apply_delta(m.byte_range.start, byte_delta); 423 + m.byte_range.end = apply_delta(m.byte_range.end, byte_delta); 424 + } 425 + for s in &mut syntax_spans { 426 + s.adjust_positions(char_delta); 427 + } 428 + } 429 + 430 + all_refs.extend(cached_para.collected_refs.clone()); 431 + 432 + new_cached.push(CachedParagraph { 433 + id: cached_para.id.clone(), 434 + source_hash, 435 + byte_range: byte_range.clone(), 436 + char_range: char_range.clone(), 437 + html: cached_para.html.clone(), 438 + offset_map: offset_map.clone(), 439 + syntax_spans: syntax_spans.clone(), 440 + collected_refs: cached_para.collected_refs.clone(), 441 + }); 442 + 443 + paragraphs.push(ParagraphRender { 444 + id: cached_para.id.clone(), 445 + byte_range, 446 + char_range, 447 + html: cached_para.html.clone(), 448 + offset_map, 449 + syntax_spans, 450 + source_hash, 451 + }); 452 + } 453 + } 454 + 455 + let new_cache = RenderCache { 456 + paragraphs: new_cached, 457 + next_node_id: 0, 458 + next_syn_id: 0, 459 + next_para_id: cache.next_para_id, 460 + }; 461 + 462 + return IncrementalRenderResult { 463 + paragraphs, 464 + cache: new_cache, 465 + collected_refs: all_refs, 466 + }; 467 + } 468 + 469 + // ============ SLOW PATH ============ 470 + // Partial render: reuse cached paragraphs before edit, parse from affected to end. 471 + 472 + let (reused_paragraphs, parse_start_byte, parse_start_char) = 473 + if let (Some(c), Some(e)) = (cache, edit) { 474 + let edit_pos = e.edit_char_pos; 475 + let affected_idx = c 476 + .paragraphs 477 + .iter() 478 + .position(|p| p.char_range.end >= edit_pos); 479 + 480 + if let Some(mut idx) = affected_idx { 481 + const BOUNDARY_SLOP: usize = 3; 482 + let para_start = c.paragraphs[idx].char_range.start; 483 + if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP { 484 + idx -= 1; 485 + } 486 + 487 + if idx > 0 { 488 + let reused: Vec<_> = c.paragraphs[..idx].to_vec(); 489 + let last_reused = &c.paragraphs[idx - 1]; 490 + tracing::trace!( 491 + reused_count = idx, 492 + parse_start_byte = last_reused.byte_range.end, 493 + parse_start_char = last_reused.char_range.end, 494 + "slow path: partial parse from affected paragraph" 495 + ); 496 + ( 497 + reused, 498 + last_reused.byte_range.end, 499 + last_reused.char_range.end, 500 + ) 501 + } else { 502 + (Vec::new(), 0, 0) 503 + } 504 + } else { 505 + if let Some(last) = c.paragraphs.last() { 506 + let reused = c.paragraphs.clone(); 507 + (reused, last.byte_range.end, last.char_range.end) 508 + } else { 509 + (Vec::new(), 0, 0) 510 + } 511 + } 512 + } else { 513 + (Vec::new(), 0, 0) 514 + }; 515 + 516 + let parse_slice = &source[parse_start_byte..]; 517 + let parser = 518 + Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); 519 + 520 + let resolver = image_resolver.cloned().unwrap_or_default(); 521 + let slice_rope = EditorRope::from(parse_slice); 522 + 523 + let reused_count = reused_paragraphs.len(); 524 + let parsed_para_id_start = if reused_count == 0 { 525 + 0 526 + } else { 527 + cache.map(|c| c.next_para_id).unwrap_or(0) 528 + }; 529 + 530 + tracing::trace!( 531 + parsed_para_id_start, 532 + reused_count, 533 + "slow path: paragraph ID allocation" 534 + ); 535 + 536 + let cursor_para_override: Option<(usize, SmolStr)> = cache.and_then(|c| { 537 + let cached_cursor_idx = c.paragraphs.iter().position(|p| { 538 + p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end 539 + })?; 540 + 541 + if cached_cursor_idx < reused_count { 542 + return None; 543 + } 544 + 545 + let cached_para = &c.paragraphs[cached_cursor_idx]; 546 + let parsed_index = cached_cursor_idx - reused_count; 547 + 548 + tracing::trace!( 549 + cached_cursor_idx, 550 + reused_count, 551 + parsed_index, 552 + cached_id = %cached_para.id, 553 + "slow path: cursor paragraph override" 554 + ); 555 + 556 + Some((parsed_index, cached_para.id.clone())) 557 + }); 558 + 559 + let mut writer = EditorWriter::<_, _, &E, &I, ()>::new(parse_slice, &slice_rope, parser) 560 + .with_auto_incrementing_prefix(parsed_para_id_start) 561 + .with_image_resolver(&resolver) 562 + .with_embed_provider(embed_provider); 563 + 564 + if let Some((idx, ref prefix)) = cursor_para_override { 565 + writer = writer.with_static_prefix_at_index(idx, prefix); 566 + } 567 + 568 + if let Some(idx) = entry_index { 569 + writer = writer.with_entry_index(idx); 570 + } 571 + 572 + let writer_result = match writer.run() { 573 + Ok(result) => result, 574 + Err(_) => { 575 + return IncrementalRenderResult { 576 + paragraphs: Vec::new(), 577 + cache: RenderCache::default(), 578 + collected_refs: vec![], 579 + } 580 + } 581 + }; 582 + 583 + let parsed_para_count = writer_result.paragraph_ranges.len(); 584 + 585 + let parsed_paragraph_ranges: Vec<_> = writer_result 586 + .paragraph_ranges 587 + .iter() 588 + .map(|(byte_range, char_range)| { 589 + ( 590 + (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte), 591 + (char_range.start + parse_start_char)..(char_range.end + parse_start_char), 592 + ) 593 + }) 594 + .collect(); 595 + 596 + let paragraph_ranges: Vec<_> = reused_paragraphs 597 + .iter() 598 + .map(|p| (p.byte_range.clone(), p.char_range.clone())) 599 + .chain(parsed_paragraph_ranges.clone()) 600 + .collect(); 601 + 602 + if tracing::enabled!(tracing::Level::TRACE) { 603 + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 604 + let preview: String = text 605 + .slice(char_range.clone()) 606 + .map(|s| s.chars().take(30).collect()) 607 + .unwrap_or_default(); 608 + tracing::trace!( 609 + target: "weaver::render", 610 + para_idx = i, 611 + char_range = ?char_range, 612 + byte_range = ?byte_range, 613 + preview = %preview, 614 + "paragraph boundary" 615 + ); 616 + } 617 + } 618 + 619 + let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 620 + let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 621 + let mut all_refs: Vec<ExtractedRef> = Vec::new(); 622 + let next_para_id = parsed_para_id_start + parsed_para_count; 623 + let reused_count = reused_paragraphs.len(); 624 + 625 + let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { 626 + char_range.start <= cursor_offset && cursor_offset <= char_range.end 627 + }); 628 + 629 + tracing::trace!( 630 + cursor_offset, 631 + ?cursor_para_idx, 632 + edit_char_pos = ?edit.map(|e| e.edit_char_pos), 633 + reused_count, 634 + parsed_count = parsed_paragraph_ranges.len(), 635 + "ID assignment: cursor and edit info" 636 + ); 637 + 638 + for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 639 + let para_source = text 640 + .slice(char_range.clone()) 641 + .map(|s| s.to_string()) 642 + .unwrap_or_default(); 643 + let source_hash = hash_source(&para_source); 644 + let is_cursor_para = Some(idx) == cursor_para_idx; 645 + 646 + let is_reused = idx < reused_count; 647 + 648 + let para_id = if is_reused { 649 + reused_paragraphs[idx].id.clone() 650 + } else { 651 + let parsed_idx = idx - reused_count; 652 + 653 + let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override { 654 + if parsed_idx == override_idx { 655 + override_prefix.clone() 656 + } else { 657 + make_paragraph_id(parsed_para_id_start + parsed_idx) 658 + } 659 + } else { 660 + make_paragraph_id(parsed_para_id_start + parsed_idx) 661 + }; 662 + 663 + if idx < 3 || is_cursor_para { 664 + tracing::trace!( 665 + idx, 666 + parsed_idx, 667 + is_cursor_para, 668 + para_id = %id, 669 + "slow path: assigned paragraph ID" 670 + ); 671 + } 672 + 673 + id 674 + }; 675 + 676 + let (html, offset_map, syntax_spans, para_refs) = if is_reused { 677 + let reused = &reused_paragraphs[idx]; 678 + ( 679 + reused.html.clone(), 680 + reused.offset_map.clone(), 681 + reused.syntax_spans.clone(), 682 + reused.collected_refs.clone(), 683 + ) 684 + } else { 685 + let parsed_idx = idx - reused_count; 686 + let html = writer_result 687 + .html_segments 688 + .get(parsed_idx) 689 + .cloned() 690 + .unwrap_or_default(); 691 + 692 + let mut offset_map = writer_result 693 + .offset_maps_by_paragraph 694 + .get(parsed_idx) 695 + .cloned() 696 + .unwrap_or_default(); 697 + for m in &mut offset_map { 698 + m.char_range.start += parse_start_char; 699 + m.char_range.end += parse_start_char; 700 + m.byte_range.start += parse_start_byte; 701 + m.byte_range.end += parse_start_byte; 702 + } 703 + 704 + let mut syntax_spans = writer_result 705 + .syntax_spans_by_paragraph 706 + .get(parsed_idx) 707 + .cloned() 708 + .unwrap_or_default(); 709 + for s in &mut syntax_spans { 710 + s.adjust_positions(parse_start_char as isize); 711 + } 712 + 713 + let para_refs = writer_result 714 + .collected_refs_by_paragraph 715 + .get(parsed_idx) 716 + .cloned() 717 + .unwrap_or_default(); 718 + (html, offset_map, syntax_spans, para_refs) 719 + }; 720 + 721 + all_refs.extend(para_refs.clone()); 722 + 723 + new_cached.push(CachedParagraph { 724 + id: para_id.clone(), 725 + source_hash, 726 + byte_range: byte_range.clone(), 727 + char_range: char_range.clone(), 728 + html: html.clone(), 729 + offset_map: offset_map.clone(), 730 + syntax_spans: syntax_spans.clone(), 731 + collected_refs: para_refs.clone(), 732 + }); 733 + 734 + paragraphs.push(ParagraphRender { 735 + id: para_id, 736 + byte_range: byte_range.clone(), 737 + char_range: char_range.clone(), 738 + html, 739 + offset_map, 740 + syntax_spans, 741 + source_hash, 742 + }); 743 + } 744 + 745 + let new_cache = RenderCache { 746 + paragraphs: new_cached, 747 + next_node_id: 0, 748 + next_syn_id: 0, 749 + next_para_id, 750 + }; 751 + 752 + IncrementalRenderResult { 753 + paragraphs, 754 + cache: new_cache, 755 + collected_refs: all_refs, 756 + } 757 + }
+286
docs/graph-data.json
··· 1352 1352 "created_at": "2026-01-06T12:01:52.561290209-05:00", 1353 1353 "updated_at": "2026-01-06T12:01:52.561290209-05:00", 1354 1354 "metadata_json": "{\"confidence\":95}" 1355 + }, 1356 + { 1357 + "id": 125, 1358 + "change_id": "5d4e888e-42c7-4c48-ba82-d9f18206f6e0", 1359 + "node_type": "action", 1360 + "title": "Added KeybindingConfig and hyper/super modifiers to core", 1361 + "description": null, 1362 + "status": "pending", 1363 + "created_at": "2026-01-06T12:04:23.569321382-05:00", 1364 + "updated_at": "2026-01-06T12:04:23.569321382-05:00", 1365 + "metadata_json": "{\"confidence\":95}" 1366 + }, 1367 + { 1368 + "id": 126, 1369 + "change_id": "bd54a469-fa5c-43bb-9796-3b4ccbee356f", 1370 + "node_type": "observation", 1371 + "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", 1372 + "description": null, 1373 + "status": "pending", 1374 + "created_at": "2026-01-06T12:08:08.392667991-05:00", 1375 + "updated_at": "2026-01-06T12:08:08.392667991-05:00", 1376 + "metadata_json": "{\"confidence\":80}" 1377 + }, 1378 + { 1379 + "id": 127, 1380 + "change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 1381 + "node_type": "decision", 1382 + "title": "Where should embed worker live?", 1383 + "description": null, 1384 + "status": "pending", 1385 + "created_at": "2026-01-06T12:09:01.110078211-05:00", 1386 + "updated_at": "2026-01-06T12:09:01.110078211-05:00", 1387 + "metadata_json": "{\"confidence\":85}" 1388 + }, 1389 + { 1390 + "id": 128, 1391 + "change_id": "761683dd-e0b9-48e3-9695-4b0ddedcd226", 1392 + "node_type": "option", 1393 + "title": "weaver-renderer - already has fetch_and_render", 1394 + "description": null, 1395 + "status": "pending", 1396 + "created_at": "2026-01-06T12:09:01.127567526-05:00", 1397 + "updated_at": "2026-01-06T12:09:01.127567526-05:00", 1398 + "metadata_json": "{\"confidence\":60}" 1399 + }, 1400 + { 1401 + "id": 129, 1402 + "change_id": "229d61f2-352b-4a12-8b58-8f5f9ff5ee49", 1403 + "node_type": "option", 1404 + "title": "Own crate weaver-embed-worker - clean separation", 1405 + "description": null, 1406 + "status": "pending", 1407 + "created_at": "2026-01-06T12:09:01.144794901-05:00", 1408 + "updated_at": "2026-01-06T12:09:01.144794901-05:00", 1409 + "metadata_json": "{\"confidence\":85}" 1410 + }, 1411 + { 1412 + "id": 130, 1413 + "change_id": "795d4e93-df31-4a55-af7a-d4aee1813d22", 1414 + "node_type": "outcome", 1415 + "title": "Browser crate is fairly complete - cursor, dom_sync, events with handle_beforeinput, platform. App has duplicates that need removal.", 1416 + "description": null, 1417 + "status": "pending", 1418 + "created_at": "2026-01-06T12:13:55.962679951-05:00", 1419 + "updated_at": "2026-01-06T12:13:55.962679951-05:00", 1420 + "metadata_json": "{\"confidence\":90}" 1421 + }, 1422 + { 1423 + "id": 131, 1424 + "change_id": "29cd0f9f-a61a-4102-bc0f-04ff35735d30", 1425 + "node_type": "action", 1426 + "title": "Added WASM test infrastructure with 10 passing tests", 1427 + "description": null, 1428 + "status": "pending", 1429 + "created_at": "2026-01-06T12:16:55.818922231-05:00", 1430 + "updated_at": "2026-01-06T12:16:55.818922231-05:00", 1431 + "metadata_json": "{\"confidence\":95}" 1432 + }, 1433 + { 1434 + "id": 132, 1435 + "change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 1436 + "node_type": "action", 1437 + "title": "Deduplicating weaver-app types by importing from core/browser crates", 1438 + "description": null, 1439 + "status": "pending", 1440 + "created_at": "2026-01-06T12:22:03.641188187-05:00", 1441 + "updated_at": "2026-01-06T12:22:03.641188187-05:00", 1442 + "metadata_json": "{\"confidence\":85}" 1443 + }, 1444 + { 1445 + "id": 133, 1446 + "change_id": "381f7585-0b04-4106-ae9c-7fac2baa2f6a", 1447 + "node_type": "observation", 1448 + "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.", 1449 + "description": null, 1450 + "status": "pending", 1451 + "created_at": "2026-01-06T12:22:11.819592056-05:00", 1452 + "updated_at": "2026-01-06T12:22:11.819592056-05:00", 1453 + "metadata_json": "{\"confidence\":95}" 1454 + }, 1455 + { 1456 + "id": 134, 1457 + "change_id": "1e8412cc-95de-473c-9ac7-f96510e7eb46", 1458 + "node_type": "outcome", 1459 + "title": "Deduplicated actions.rs and paragraph.rs - imported types from core, SmolStr consistency established", 1460 + "description": null, 1461 + "status": "pending", 1462 + "created_at": "2026-01-06T12:30:26.244030599-05:00", 1463 + "updated_at": "2026-01-06T12:30:26.244030599-05:00", 1464 + "metadata_json": "{\"confidence\":95}" 1465 + }, 1466 + { 1467 + "id": 135, 1468 + "change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", 1469 + "node_type": "action", 1470 + "title": "Moved render_cache.rs to core - generic incremental rendering over TextBuffer", 1471 + "description": null, 1472 + "status": "pending", 1473 + "created_at": "2026-01-06T12:48:54.654141279-05:00", 1474 + "updated_at": "2026-01-06T12:48:54.654141279-05:00", 1475 + "metadata_json": "{\"confidence\":95}" 1476 + }, 1477 + { 1478 + "id": 136, 1479 + "change_id": "33d19ce9-56be-4894-95ee-ce4f8d7dbbc8", 1480 + "node_type": "outcome", 1481 + "title": "Core render_cache complete: RenderCache, CachedParagraph, apply_delta, is_boundary_affecting, render_paragraphs_incremental<T: TextBuffer>", 1482 + "description": null, 1483 + "status": "pending", 1484 + "created_at": "2026-01-06T12:48:54.777507129-05:00", 1485 + "updated_at": "2026-01-06T12:48:54.777507129-05:00", 1486 + "metadata_json": "{\"confidence\":95}" 1487 + }, 1488 + { 1489 + "id": 137, 1490 + "change_id": "a5094bcf-151d-462d-85e1-b8550e65a0eb", 1491 + "node_type": "action", 1492 + "title": "Deduplicated document.rs - removed local Selection, CompositionState, EditInfo, CursorState, Affinity, BLOCK_SYNTAX_ZONE", 1493 + "description": null, 1494 + "status": "pending", 1495 + "created_at": "2026-01-06T12:49:01.242741800-05:00", 1496 + "updated_at": "2026-01-06T12:49:01.242741800-05:00", 1497 + "metadata_json": "{\"confidence\":95}" 1498 + }, 1499 + { 1500 + "id": 138, 1501 + "change_id": "439402ae-17dc-467e-9bf3-04f32a9f4931", 1502 + "node_type": "action", 1503 + "title": "Deduplicated cursor.rs - removed local CursorRect, SelectionRect", 1504 + "description": null, 1505 + "status": "pending", 1506 + "created_at": "2026-01-06T12:49:01.362223737-05:00", 1507 + "updated_at": "2026-01-06T12:49:01.362223737-05:00", 1508 + "metadata_json": "{\"confidence\":95}" 1355 1509 } 1356 1510 ], 1357 1511 "edges": [ ··· 2850 3004 "weight": 1.0, 2851 3005 "rationale": "Execute action implementation outcome", 2852 3006 "created_at": "2026-01-06T12:01:52.577885198-05:00" 3007 + }, 3008 + { 3009 + "id": 138, 3010 + "from_node_id": 122, 3011 + "to_node_id": 125, 3012 + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", 3013 + "to_change_id": "5d4e888e-42c7-4c48-ba82-d9f18206f6e0", 3014 + "edge_type": "leads_to", 3015 + "weight": 1.0, 3016 + "rationale": "Part of extraction work", 3017 + "created_at": "2026-01-06T12:04:23.698114449-05:00" 3018 + }, 3019 + { 3020 + "id": 139, 3021 + "from_node_id": 126, 3022 + "to_node_id": 127, 3023 + "from_change_id": "bd54a469-fa5c-43bb-9796-3b4ccbee356f", 3024 + "to_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 3025 + "edge_type": "leads_to", 3026 + "weight": 1.0, 3027 + "rationale": "Decision follows observation", 3028 + "created_at": "2026-01-06T12:09:01.163893115-05:00" 3029 + }, 3030 + { 3031 + "id": 140, 3032 + "from_node_id": 127, 3033 + "to_node_id": 128, 3034 + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 3035 + "to_change_id": "761683dd-e0b9-48e3-9695-4b0ddedcd226", 3036 + "edge_type": "leads_to", 3037 + "weight": 1.0, 3038 + "rationale": "Option A", 3039 + "created_at": "2026-01-06T12:09:05.444869523-05:00" 3040 + }, 3041 + { 3042 + "id": 141, 3043 + "from_node_id": 127, 3044 + "to_node_id": 129, 3045 + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", 3046 + "to_change_id": "229d61f2-352b-4a12-8b58-8f5f9ff5ee49", 3047 + "edge_type": "leads_to", 3048 + "weight": 1.0, 3049 + "rationale": "Option B - preferred", 3050 + "created_at": "2026-01-06T12:09:05.461293286-05:00" 3051 + }, 3052 + { 3053 + "id": 142, 3054 + "from_node_id": 122, 3055 + "to_node_id": 130, 3056 + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", 3057 + "to_change_id": "795d4e93-df31-4a55-af7a-d4aee1813d22", 3058 + "edge_type": "leads_to", 3059 + "weight": 1.0, 3060 + "rationale": "Extraction progress", 3061 + "created_at": "2026-01-06T12:13:55.979279744-05:00" 3062 + }, 3063 + { 3064 + "id": 143, 3065 + "from_node_id": 122, 3066 + "to_node_id": 131, 3067 + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", 3068 + "to_change_id": "29cd0f9f-a61a-4102-bc0f-04ff35735d30", 3069 + "edge_type": "leads_to", 3070 + "weight": 1.0, 3071 + "rationale": "Test setup for browser crate", 3072 + "created_at": "2026-01-06T12:16:55.834996178-05:00" 3073 + }, 3074 + { 3075 + "id": 144, 3076 + "from_node_id": 120, 3077 + "to_node_id": 132, 3078 + "from_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", 3079 + "to_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3080 + "edge_type": "leads_to", 3081 + "weight": 1.0, 3082 + "rationale": "Part of phase 1 extraction - removing app duplicates", 3083 + "created_at": "2026-01-06T12:22:11.802027921-05:00" 3084 + }, 3085 + { 3086 + "id": 145, 3087 + "from_node_id": 132, 3088 + "to_node_id": 134, 3089 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3090 + "to_change_id": "1e8412cc-95de-473c-9ac7-f96510e7eb46", 3091 + "edge_type": "leads_to", 3092 + "weight": 1.0, 3093 + "rationale": "Deduplication completed", 3094 + "created_at": "2026-01-06T12:30:26.260547276-05:00" 3095 + }, 3096 + { 3097 + "id": 146, 3098 + "from_node_id": 132, 3099 + "to_node_id": 135, 3100 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3101 + "to_change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", 3102 + "edge_type": "leads_to", 3103 + "weight": 1.0, 3104 + "rationale": "render_cache migration is part of deduplication effort", 3105 + "created_at": "2026-01-06T12:49:01.496533815-05:00" 3106 + }, 3107 + { 3108 + "id": 147, 3109 + "from_node_id": 135, 3110 + "to_node_id": 136, 3111 + "from_change_id": "d4a8f89d-3926-4a60-9802-05c921d6d578", 3112 + "to_change_id": "33d19ce9-56be-4894-95ee-ce4f8d7dbbc8", 3113 + "edge_type": "leads_to", 3114 + "weight": 1.0, 3115 + "rationale": "render_cache action leads to outcome", 3116 + "created_at": "2026-01-06T12:49:06.880751070-05:00" 3117 + }, 3118 + { 3119 + "id": 148, 3120 + "from_node_id": 132, 3121 + "to_node_id": 137, 3122 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3123 + "to_change_id": "a5094bcf-151d-462d-85e1-b8550e65a0eb", 3124 + "edge_type": "leads_to", 3125 + "weight": 1.0, 3126 + "rationale": "dedup effort", 3127 + "created_at": "2026-01-06T12:49:06.897304176-05:00" 3128 + }, 3129 + { 3130 + "id": 149, 3131 + "from_node_id": 132, 3132 + "to_node_id": 138, 3133 + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", 3134 + "to_change_id": "439402ae-17dc-467e-9bf3-04f32a9f4931", 3135 + "edge_type": "leads_to", 3136 + "weight": 1.0, 3137 + "rationale": "dedup effort", 3138 + "created_at": "2026-01-06T12:49:06.980327331-05:00" 2853 3139 } 2854 3140 ] 2855 3141 }