reworked editor to do beforeinput

Orual db383970 44a27928

+1943 -58
+1264
crates/weaver-app/src/components/editor/actions.rs
···
··· 1 + //! Editor actions and keybinding system. 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; 12 + 13 + use dioxus::prelude::*; 14 + use jacquard::smol_str::SmolStr; 15 + 16 + use super::document::EditorDocument; 17 + use super::platform::Platform; 18 + 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 + pub fn is_empty(&self) -> bool { 47 + self.len() == 0 48 + } 49 + 50 + /// Normalize range so start <= end. 51 + pub fn normalize(self) -> Self { 52 + if self.start <= self.end { 53 + self 54 + } else { 55 + Self { 56 + start: self.end, 57 + end: self.start, 58 + } 59 + } 60 + } 61 + } 62 + 63 + /// All possible editor actions. 64 + /// 65 + /// These represent semantic operations on the document, decoupled from 66 + /// how they're triggered (keyboard, mouse, touch, voice, etc.). 67 + #[derive(Debug, Clone, PartialEq)] 68 + pub enum EditorAction { 69 + // === Text Insertion === 70 + /// Insert text at the given range (replacing any selected content). 71 + Insert { text: String, range: Range }, 72 + 73 + /// Insert a soft line break (Shift+Enter, `<br>` equivalent). 74 + InsertLineBreak { range: Range }, 75 + 76 + /// Insert a paragraph break (Enter). 77 + InsertParagraph { range: Range }, 78 + 79 + // === Deletion === 80 + /// Delete content backward (Backspace). 81 + DeleteBackward { range: Range }, 82 + 83 + /// Delete content forward (Delete key). 84 + DeleteForward { range: Range }, 85 + 86 + /// Delete word backward (Ctrl/Alt+Backspace). 87 + DeleteWordBackward { range: Range }, 88 + 89 + /// Delete word forward (Ctrl/Alt+Delete). 90 + DeleteWordForward { range: Range }, 91 + 92 + /// Delete to start of line (Cmd+Backspace on Mac). 93 + DeleteToLineStart { range: Range }, 94 + 95 + /// Delete to end of line (Cmd+Delete on Mac). 96 + DeleteToLineEnd { range: Range }, 97 + 98 + /// Delete to start of soft line (visual line in wrapped text). 99 + DeleteSoftLineBackward { range: Range }, 100 + 101 + /// Delete to end of soft line. 102 + DeleteSoftLineForward { range: Range }, 103 + 104 + // === History === 105 + /// Undo the last change. 106 + Undo, 107 + 108 + /// Redo the last undone change. 109 + Redo, 110 + 111 + // === Formatting === 112 + /// Toggle bold on selection. 113 + ToggleBold, 114 + 115 + /// Toggle italic on selection. 116 + ToggleItalic, 117 + 118 + /// Toggle inline code on selection. 119 + ToggleCode, 120 + 121 + /// Toggle strikethrough on selection. 122 + ToggleStrikethrough, 123 + 124 + /// Insert/wrap with link. 125 + InsertLink, 126 + 127 + // === Clipboard === 128 + /// Cut selection to clipboard. 129 + Cut, 130 + 131 + /// Copy selection to clipboard. 132 + Copy, 133 + 134 + /// Paste from clipboard at range. 135 + Paste { range: Range }, 136 + 137 + /// Copy selection as rendered HTML. 138 + CopyAsHtml, 139 + 140 + // === Selection === 141 + /// Select all content. 142 + SelectAll, 143 + 144 + // === Navigation (for command palette / programmatic use) === 145 + /// Move cursor to position. 146 + MoveCursor { offset: usize }, 147 + 148 + /// Extend selection to position. 149 + ExtendSelection { offset: usize }, 150 + } 151 + 152 + impl EditorAction { 153 + /// Update the range in actions that use one. 154 + /// Actions without a range are returned unchanged. 155 + pub fn with_range(self, range: Range) -> Self { 156 + match self { 157 + Self::Insert { text, .. } => Self::Insert { text, range }, 158 + Self::InsertLineBreak { .. } => Self::InsertLineBreak { range }, 159 + Self::InsertParagraph { .. } => Self::InsertParagraph { range }, 160 + Self::DeleteBackward { .. } => Self::DeleteBackward { range }, 161 + Self::DeleteForward { .. } => Self::DeleteForward { range }, 162 + Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range }, 163 + Self::DeleteWordForward { .. } => Self::DeleteWordForward { range }, 164 + Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range }, 165 + Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range }, 166 + Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range }, 167 + Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range }, 168 + Self::Paste { .. } => Self::Paste { range }, 169 + // Actions without range stay unchanged 170 + other => other, 171 + } 172 + } 173 + } 174 + 175 + /// Key values for keyboard input. 176 + /// 177 + /// Mirrors the keyboard-types crate's Key enum structure. Character keys use 178 + /// SmolStr to efficiently handle both single characters and composed sequences 179 + /// (from dead keys, IME, etc.). 180 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 181 + pub enum Key { 182 + /// A character key. The string corresponds to the character typed, 183 + /// taking into account locale, modifiers, and keyboard mapping. 184 + /// May be multiple characters for composed sequences. 185 + Character(SmolStr), 186 + 187 + /// Unknown/unidentified key. 188 + Unidentified, 189 + 190 + // === Whitespace / editing === 191 + Backspace, 192 + Delete, 193 + Enter, 194 + Tab, 195 + Escape, 196 + Space, 197 + Insert, 198 + Clear, 199 + 200 + // === Navigation === 201 + ArrowLeft, 202 + ArrowRight, 203 + ArrowUp, 204 + ArrowDown, 205 + Home, 206 + End, 207 + PageUp, 208 + PageDown, 209 + 210 + // === Modifiers === 211 + Alt, 212 + AltGraph, 213 + CapsLock, 214 + Control, 215 + Fn, 216 + FnLock, 217 + Meta, 218 + NumLock, 219 + ScrollLock, 220 + Shift, 221 + Symbol, 222 + SymbolLock, 223 + Hyper, 224 + Super, 225 + 226 + // === Function keys === 227 + F1, 228 + F2, 229 + F3, 230 + F4, 231 + F5, 232 + F6, 233 + F7, 234 + F8, 235 + F9, 236 + F10, 237 + F11, 238 + F12, 239 + F13, 240 + F14, 241 + F15, 242 + F16, 243 + F17, 244 + F18, 245 + F19, 246 + F20, 247 + 248 + // === UI keys === 249 + ContextMenu, 250 + PrintScreen, 251 + Pause, 252 + Help, 253 + 254 + // === Clipboard / editing commands === 255 + Copy, 256 + Cut, 257 + Paste, 258 + Undo, 259 + Redo, 260 + Find, 261 + Select, 262 + 263 + // === Media keys === 264 + MediaPlayPause, 265 + MediaStop, 266 + MediaTrackNext, 267 + MediaTrackPrevious, 268 + AudioVolumeDown, 269 + AudioVolumeUp, 270 + AudioVolumeMute, 271 + 272 + // === IME / composition === 273 + Compose, 274 + Convert, 275 + NonConvert, 276 + Dead, 277 + 278 + // === CJK IME keys === 279 + HangulMode, 280 + HanjaMode, 281 + JunjaMode, 282 + Eisu, 283 + Hankaku, 284 + Hiragana, 285 + HiraganaKatakana, 286 + KanaMode, 287 + KanjiMode, 288 + Katakana, 289 + Romaji, 290 + Zenkaku, 291 + ZenkakuHankaku, 292 + } 293 + 294 + impl Key { 295 + /// Create a character key from a string. 296 + pub fn character(s: impl Into<SmolStr>) -> Self { 297 + Self::Character(s.into()) 298 + } 299 + 300 + /// Convert from a dioxus keyboard_types::Key. 301 + pub fn from_keyboard_types(key: dioxus::prelude::keyboard_types::Key) -> Self { 302 + use dioxus::prelude::keyboard_types::Key as KT; 303 + 304 + match key { 305 + KT::Character(s) => Self::Character(s.as_str().into()), 306 + KT::Unidentified => Self::Unidentified, 307 + 308 + // Whitespace / editing 309 + KT::Backspace => Self::Backspace, 310 + KT::Delete => Self::Delete, 311 + KT::Enter => Self::Enter, 312 + KT::Tab => Self::Tab, 313 + KT::Escape => Self::Escape, 314 + KT::Insert => Self::Insert, 315 + KT::Clear => Self::Clear, 316 + 317 + // Navigation 318 + KT::ArrowLeft => Self::ArrowLeft, 319 + KT::ArrowRight => Self::ArrowRight, 320 + KT::ArrowUp => Self::ArrowUp, 321 + KT::ArrowDown => Self::ArrowDown, 322 + KT::Home => Self::Home, 323 + KT::End => Self::End, 324 + KT::PageUp => Self::PageUp, 325 + KT::PageDown => Self::PageDown, 326 + 327 + // Modifiers 328 + KT::Alt => Self::Alt, 329 + KT::AltGraph => Self::AltGraph, 330 + KT::CapsLock => Self::CapsLock, 331 + KT::Control => Self::Control, 332 + KT::Fn => Self::Fn, 333 + KT::FnLock => Self::FnLock, 334 + KT::Meta => Self::Meta, 335 + KT::NumLock => Self::NumLock, 336 + KT::ScrollLock => Self::ScrollLock, 337 + KT::Shift => Self::Shift, 338 + KT::Symbol => Self::Symbol, 339 + KT::SymbolLock => Self::SymbolLock, 340 + KT::Hyper => Self::Hyper, 341 + KT::Super => Self::Super, 342 + 343 + // Function keys 344 + KT::F1 => Self::F1, 345 + KT::F2 => Self::F2, 346 + KT::F3 => Self::F3, 347 + KT::F4 => Self::F4, 348 + KT::F5 => Self::F5, 349 + KT::F6 => Self::F6, 350 + KT::F7 => Self::F7, 351 + KT::F8 => Self::F8, 352 + KT::F9 => Self::F9, 353 + KT::F10 => Self::F10, 354 + KT::F11 => Self::F11, 355 + KT::F12 => Self::F12, 356 + KT::F13 => Self::F13, 357 + KT::F14 => Self::F14, 358 + KT::F15 => Self::F15, 359 + KT::F16 => Self::F16, 360 + KT::F17 => Self::F17, 361 + KT::F18 => Self::F18, 362 + KT::F19 => Self::F19, 363 + KT::F20 => Self::F20, 364 + 365 + // UI keys 366 + KT::ContextMenu => Self::ContextMenu, 367 + KT::PrintScreen => Self::PrintScreen, 368 + KT::Pause => Self::Pause, 369 + KT::Help => Self::Help, 370 + 371 + // Clipboard / editing commands 372 + KT::Copy => Self::Copy, 373 + KT::Cut => Self::Cut, 374 + KT::Paste => Self::Paste, 375 + KT::Undo => Self::Undo, 376 + KT::Redo => Self::Redo, 377 + KT::Find => Self::Find, 378 + KT::Select => Self::Select, 379 + 380 + // Media keys 381 + KT::MediaPlayPause => Self::MediaPlayPause, 382 + KT::MediaStop => Self::MediaStop, 383 + KT::MediaTrackNext => Self::MediaTrackNext, 384 + KT::MediaTrackPrevious => Self::MediaTrackPrevious, 385 + KT::AudioVolumeDown => Self::AudioVolumeDown, 386 + KT::AudioVolumeUp => Self::AudioVolumeUp, 387 + KT::AudioVolumeMute => Self::AudioVolumeMute, 388 + 389 + // IME / composition 390 + KT::Compose => Self::Compose, 391 + KT::Convert => Self::Convert, 392 + KT::NonConvert => Self::NonConvert, 393 + KT::Dead => Self::Dead, 394 + 395 + // CJK IME keys 396 + KT::HangulMode => Self::HangulMode, 397 + KT::HanjaMode => Self::HanjaMode, 398 + KT::JunjaMode => Self::JunjaMode, 399 + KT::Eisu => Self::Eisu, 400 + KT::Hankaku => Self::Hankaku, 401 + KT::Hiragana => Self::Hiragana, 402 + KT::HiraganaKatakana => Self::HiraganaKatakana, 403 + KT::KanaMode => Self::KanaMode, 404 + KT::KanjiMode => Self::KanjiMode, 405 + KT::Katakana => Self::Katakana, 406 + KT::Romaji => Self::Romaji, 407 + KT::Zenkaku => Self::Zenkaku, 408 + KT::ZenkakuHankaku => Self::ZenkakuHankaku, 409 + 410 + // Everything else falls through to Unidentified 411 + _ => Self::Unidentified, 412 + } 413 + } 414 + 415 + /// Check if this is a navigation key that should pass through to browser. 416 + pub fn is_navigation(&self) -> bool { 417 + matches!( 418 + self, 419 + Self::ArrowLeft 420 + | Self::ArrowRight 421 + | Self::ArrowUp 422 + | Self::ArrowDown 423 + | Self::Home 424 + | Self::End 425 + | Self::PageUp 426 + | Self::PageDown 427 + ) 428 + } 429 + 430 + /// Check if this is a modifier key. 431 + pub fn is_modifier(&self) -> bool { 432 + matches!( 433 + self, 434 + Self::Alt 435 + | Self::AltGraph 436 + | Self::CapsLock 437 + | Self::Control 438 + | Self::Fn 439 + | Self::FnLock 440 + | Self::Meta 441 + | Self::NumLock 442 + | Self::ScrollLock 443 + | Self::Shift 444 + | Self::Symbol 445 + | Self::SymbolLock 446 + | Self::Hyper 447 + | Self::Super 448 + ) 449 + } 450 + } 451 + 452 + /// Modifier key state for a key combination. 453 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 454 + pub struct Modifiers { 455 + pub ctrl: bool, 456 + pub alt: bool, 457 + pub shift: bool, 458 + pub meta: bool, 459 + pub hyper: bool, 460 + pub super_: bool, // `super` is a keyword 461 + } 462 + 463 + impl Modifiers { 464 + pub const NONE: Self = Self { 465 + ctrl: false, 466 + alt: false, 467 + shift: false, 468 + meta: false, 469 + hyper: false, 470 + super_: false, 471 + }; 472 + pub const CTRL: Self = Self { 473 + ctrl: true, 474 + alt: false, 475 + shift: false, 476 + meta: false, 477 + hyper: false, 478 + super_: false, 479 + }; 480 + pub const ALT: Self = Self { 481 + ctrl: false, 482 + alt: true, 483 + shift: false, 484 + meta: false, 485 + hyper: false, 486 + super_: false, 487 + }; 488 + pub const SHIFT: Self = Self { 489 + ctrl: false, 490 + alt: false, 491 + shift: true, 492 + meta: false, 493 + hyper: false, 494 + super_: false, 495 + }; 496 + pub const META: Self = Self { 497 + ctrl: false, 498 + alt: false, 499 + shift: false, 500 + meta: true, 501 + hyper: false, 502 + super_: false, 503 + }; 504 + pub const HYPER: Self = Self { 505 + ctrl: false, 506 + alt: false, 507 + shift: false, 508 + meta: false, 509 + hyper: true, 510 + super_: false, 511 + }; 512 + pub const SUPER: Self = Self { 513 + ctrl: false, 514 + alt: false, 515 + shift: false, 516 + meta: false, 517 + hyper: false, 518 + super_: true, 519 + }; 520 + pub const CTRL_SHIFT: Self = Self { 521 + ctrl: true, 522 + alt: false, 523 + shift: true, 524 + meta: false, 525 + hyper: false, 526 + super_: false, 527 + }; 528 + pub const META_SHIFT: Self = Self { 529 + ctrl: false, 530 + alt: false, 531 + shift: true, 532 + meta: true, 533 + hyper: false, 534 + super_: false, 535 + }; 536 + 537 + /// Get the platform's primary modifier (Cmd on Mac, Ctrl elsewhere). 538 + pub fn cmd_or_ctrl(platform: &Platform) -> Self { 539 + if platform.mac { Self::META } else { Self::CTRL } 540 + } 541 + 542 + /// Get the platform's primary modifier + Shift. 543 + pub fn cmd_or_ctrl_shift(platform: &Platform) -> Self { 544 + if platform.mac { 545 + Self::META_SHIFT 546 + } else { 547 + Self::CTRL_SHIFT 548 + } 549 + } 550 + } 551 + 552 + /// A key combination for triggering an action. 553 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 554 + pub struct KeyCombo { 555 + pub key: Key, 556 + pub modifiers: Modifiers, 557 + } 558 + 559 + impl KeyCombo { 560 + pub fn new(key: Key) -> Self { 561 + Self { 562 + key, 563 + modifiers: Modifiers::NONE, 564 + } 565 + } 566 + 567 + pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self { 568 + Self { key, modifiers } 569 + } 570 + 571 + pub fn ctrl(key: Key) -> Self { 572 + Self { 573 + key, 574 + modifiers: Modifiers::CTRL, 575 + } 576 + } 577 + 578 + pub fn meta(key: Key) -> Self { 579 + Self { 580 + key, 581 + modifiers: Modifiers::META, 582 + } 583 + } 584 + 585 + pub fn shift(key: Key) -> Self { 586 + Self { 587 + key, 588 + modifiers: Modifiers::SHIFT, 589 + } 590 + } 591 + 592 + pub fn cmd_or_ctrl(key: Key, platform: &Platform) -> Self { 593 + Self { 594 + key, 595 + modifiers: Modifiers::cmd_or_ctrl(platform), 596 + } 597 + } 598 + 599 + pub fn cmd_or_ctrl_shift(key: Key, platform: &Platform) -> Self { 600 + Self { 601 + key, 602 + modifiers: Modifiers::cmd_or_ctrl_shift(platform), 603 + } 604 + } 605 + 606 + /// Create a KeyCombo from a dioxus keyboard event. 607 + pub fn from_keyboard_event(event: &dioxus::events::KeyboardData) -> Self { 608 + let key = Key::from_keyboard_types(event.key()); 609 + let modifiers = Modifiers { 610 + ctrl: event.modifiers().ctrl(), 611 + alt: event.modifiers().alt(), 612 + shift: event.modifiers().shift(), 613 + meta: event.modifiers().meta(), 614 + // dioxus doesn't expose hyper/super separately, they typically map to meta 615 + hyper: false, 616 + super_: false, 617 + }; 618 + Self { key, modifiers } 619 + } 620 + } 621 + 622 + /// Keybinding configuration for the editor. 623 + /// 624 + /// Uses a HashMap for O(1) keybinding lookup. 625 + #[derive(Debug, Clone)] 626 + pub struct KeybindingConfig { 627 + bindings: HashMap<KeyCombo, EditorAction>, 628 + } 629 + 630 + impl KeybindingConfig { 631 + /// Create default keybindings for the given platform. 632 + pub fn default_for_platform(platform: &Platform) -> Self { 633 + let mut bindings = HashMap::new(); 634 + 635 + // === Formatting === 636 + bindings.insert( 637 + KeyCombo::cmd_or_ctrl(Key::character("b"), platform), 638 + EditorAction::ToggleBold, 639 + ); 640 + bindings.insert( 641 + KeyCombo::cmd_or_ctrl(Key::character("i"), platform), 642 + EditorAction::ToggleItalic, 643 + ); 644 + bindings.insert( 645 + KeyCombo::cmd_or_ctrl(Key::character("e"), platform), 646 + EditorAction::CopyAsHtml, 647 + ); 648 + 649 + // === History === 650 + bindings.insert( 651 + KeyCombo::cmd_or_ctrl(Key::character("z"), platform), 652 + EditorAction::Undo, 653 + ); 654 + 655 + // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere 656 + if platform.mac { 657 + bindings.insert( 658 + KeyCombo::cmd_or_ctrl_shift(Key::character("Z"), platform), 659 + EditorAction::Redo, 660 + ); 661 + } else { 662 + bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo); 663 + bindings.insert( 664 + KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT), 665 + EditorAction::Redo, 666 + ); 667 + } 668 + 669 + // === Selection === 670 + bindings.insert( 671 + KeyCombo::cmd_or_ctrl(Key::character("a"), platform), 672 + EditorAction::SelectAll, 673 + ); 674 + 675 + // === Line deletion === 676 + if platform.mac { 677 + bindings.insert( 678 + KeyCombo::meta(Key::Backspace), 679 + EditorAction::DeleteToLineStart { 680 + range: Range::caret(0), 681 + }, 682 + ); 683 + bindings.insert( 684 + KeyCombo::meta(Key::Delete), 685 + EditorAction::DeleteToLineEnd { 686 + range: Range::caret(0), 687 + }, 688 + ); 689 + } 690 + 691 + // === Enter behaviour === 692 + // Enter = soft break (single newline) 693 + bindings.insert( 694 + KeyCombo::new(Key::Enter), 695 + EditorAction::InsertLineBreak { 696 + range: Range::caret(0), 697 + }, 698 + ); 699 + // Shift+Enter = paragraph break (double newline) 700 + bindings.insert( 701 + KeyCombo::shift(Key::Enter), 702 + EditorAction::InsertParagraph { 703 + range: Range::caret(0), 704 + }, 705 + ); 706 + 707 + // === Dedicated editing keys (for custom keyboards, etc.) === 708 + bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo); 709 + bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo); 710 + bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy); 711 + bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut); 712 + bindings.insert( 713 + KeyCombo::new(Key::Paste), 714 + EditorAction::Paste { 715 + range: Range::caret(0), 716 + }, 717 + ); 718 + bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 719 + 720 + Self { bindings } 721 + } 722 + 723 + /// Look up an action for the given key combo, with the current range applied. 724 + pub fn lookup(&self, combo: KeyCombo, range: Range) -> Option<EditorAction> { 725 + self.bindings.get(&combo).cloned().map(|a| a.with_range(range)) 726 + } 727 + 728 + /// Look up an action for the given key and modifiers, with the current range applied. 729 + pub fn lookup_key(&self, key: Key, modifiers: Modifiers, range: Range) -> Option<EditorAction> { 730 + self.lookup(KeyCombo::with_modifiers(key, modifiers), range) 731 + } 732 + 733 + /// Add or replace a keybinding. 734 + pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) { 735 + self.bindings.insert(combo, action); 736 + } 737 + 738 + /// Remove a keybinding. 739 + pub fn unbind(&mut self, combo: KeyCombo) { 740 + self.bindings.remove(&combo); 741 + } 742 + } 743 + 744 + /// Execute an editor action on a document. 745 + /// 746 + /// This is the central dispatch point for all editor operations. 747 + /// Returns true if the action was handled and the document was modified. 748 + pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool { 749 + use super::formatting::{self, FormatAction}; 750 + use super::input::{ 751 + detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, 752 + }; 753 + use super::offset_map::SnapDirection; 754 + 755 + match action { 756 + EditorAction::Insert { text, range } => { 757 + let range = range.normalize(); 758 + if range.is_caret() { 759 + // Simple insert 760 + let offset = range.start; 761 + 762 + // Clean up any preceding zero-width chars 763 + let mut delete_start = offset; 764 + while delete_start > 0 { 765 + match get_char_at(doc.loro_text(), delete_start - 1) { 766 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 767 + _ => break, 768 + } 769 + } 770 + 771 + let zw_count = offset - delete_start; 772 + if zw_count > 0 { 773 + let _ = doc.replace_tracked(delete_start, zw_count, text); 774 + doc.cursor.write().offset = delete_start + text.chars().count(); 775 + } else if offset == doc.len_chars() { 776 + let _ = doc.push_tracked(text); 777 + doc.cursor.write().offset = offset + text.chars().count(); 778 + } else { 779 + let _ = doc.insert_tracked(offset, text); 780 + doc.cursor.write().offset = offset + text.chars().count(); 781 + } 782 + } else { 783 + // Replace range 784 + let _ = doc.replace_tracked(range.start, range.len(), text); 785 + doc.cursor.write().offset = range.start + text.chars().count(); 786 + } 787 + doc.selection.set(None); 788 + true 789 + } 790 + 791 + EditorAction::InsertLineBreak { range } => { 792 + let range = range.normalize(); 793 + doc.pending_snap.set(Some(SnapDirection::Forward)); 794 + 795 + if !range.is_caret() { 796 + let _ = doc.remove_tracked(range.start, range.len()); 797 + } 798 + 799 + let mut offset = range.start; 800 + 801 + // Check if we're right after a soft break (newline + zero-width char). 802 + // If so, convert to paragraph break by replacing the zero-width char 803 + // with a newline. 804 + let mut is_double_enter = false; 805 + if offset >= 2 { 806 + let prev_char = get_char_at(doc.loro_text(), offset - 1); 807 + let prev_prev_char = get_char_at(doc.loro_text(), offset - 2); 808 + if prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') { 809 + // Replace zero-width char with newline 810 + let _ = doc.replace_tracked(offset - 1, 1, "\n"); 811 + doc.cursor.write().offset = offset; 812 + is_double_enter = true; 813 + } 814 + } 815 + 816 + if !is_double_enter { 817 + // Normal soft break: insert newline + zero-width char for cursor positioning. 818 + // The renderer emits <br> for soft breaks, so we don't need 819 + // trailing spaces for visual line breaks. 820 + let _ = doc.insert_tracked(offset, "\n\u{200C}"); 821 + doc.cursor.write().offset = offset + 2; 822 + } 823 + 824 + doc.selection.set(None); 825 + true 826 + } 827 + 828 + EditorAction::InsertParagraph { range } => { 829 + let range = range.normalize(); 830 + doc.pending_snap.set(Some(SnapDirection::Forward)); 831 + 832 + if !range.is_caret() { 833 + let _ = doc.remove_tracked(range.start, range.len()); 834 + } 835 + 836 + let cursor_offset = range.start; 837 + 838 + // Check for list context 839 + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 840 + if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { 841 + // Empty item - exit list 842 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 843 + let line_end = find_line_end(doc.loro_text(), cursor_offset); 844 + let delete_end = (line_end + 1).min(doc.len_chars()); 845 + 846 + let _ = doc.replace_tracked( 847 + line_start, 848 + delete_end.saturating_sub(line_start), 849 + "\n\n\u{200C}\n", 850 + ); 851 + doc.cursor.write().offset = line_start + 2; 852 + } else { 853 + // Continue list 854 + let continuation = match ctx { 855 + super::input::ListContext::Unordered { indent, marker } => { 856 + format!("\n{}{} ", indent, marker) 857 + } 858 + super::input::ListContext::Ordered { indent, number } => { 859 + format!("\n{}{}. ", indent, number + 1) 860 + } 861 + }; 862 + let len = continuation.chars().count(); 863 + let _ = doc.insert_tracked(cursor_offset, &continuation); 864 + doc.cursor.write().offset = cursor_offset + len; 865 + } 866 + } else { 867 + // Normal paragraph break 868 + let _ = doc.insert_tracked(cursor_offset, "\n\n"); 869 + doc.cursor.write().offset = cursor_offset + 2; 870 + } 871 + 872 + doc.selection.set(None); 873 + true 874 + } 875 + 876 + EditorAction::DeleteBackward { range } => { 877 + let range = range.normalize(); 878 + doc.pending_snap.set(Some(SnapDirection::Backward)); 879 + 880 + if !range.is_caret() { 881 + // Delete selection 882 + let _ = doc.remove_tracked(range.start, range.len()); 883 + doc.cursor.write().offset = range.start; 884 + } else if range.start > 0 { 885 + let cursor_offset = range.start; 886 + let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); 887 + 888 + if prev_char == Some('\n') { 889 + // Deleting a newline - handle paragraph merging 890 + let newline_pos = cursor_offset - 1; 891 + let mut delete_start = newline_pos; 892 + let mut delete_end = cursor_offset; 893 + 894 + // Check for empty paragraph (double newline) 895 + if newline_pos > 0 { 896 + if get_char_at(doc.loro_text(), newline_pos - 1) == Some('\n') { 897 + delete_start = newline_pos - 1; 898 + } 899 + } 900 + 901 + // Check for trailing zero-width char 902 + if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 903 + if ch == '\u{200C}' || ch == '\u{200B}' { 904 + delete_end += 1; 905 + } 906 + } 907 + 908 + // Scan backwards through zero-width chars 909 + while delete_start > 0 { 910 + match get_char_at(doc.loro_text(), delete_start - 1) { 911 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 912 + Some('\n') | _ => break, 913 + } 914 + } 915 + 916 + let _ = 917 + doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 918 + doc.cursor.write().offset = delete_start; 919 + } else { 920 + // Normal single char delete 921 + let _ = doc.remove_tracked(cursor_offset - 1, 1); 922 + doc.cursor.write().offset = cursor_offset - 1; 923 + } 924 + } 925 + 926 + doc.selection.set(None); 927 + true 928 + } 929 + 930 + EditorAction::DeleteForward { range } => { 931 + let range = range.normalize(); 932 + doc.pending_snap.set(Some(SnapDirection::Forward)); 933 + 934 + if !range.is_caret() { 935 + let _ = doc.remove_tracked(range.start, range.len()); 936 + doc.cursor.write().offset = range.start; 937 + } else if range.start < doc.len_chars() { 938 + let _ = doc.remove_tracked(range.start, 1); 939 + // Cursor stays at same position 940 + } 941 + 942 + doc.selection.set(None); 943 + true 944 + } 945 + 946 + EditorAction::DeleteWordBackward { range } => { 947 + let range = range.normalize(); 948 + doc.pending_snap.set(Some(SnapDirection::Backward)); 949 + 950 + if !range.is_caret() { 951 + let _ = doc.remove_tracked(range.start, range.len()); 952 + doc.cursor.write().offset = range.start; 953 + } else { 954 + // Find word boundary backwards 955 + let cursor = range.start; 956 + let word_start = find_word_boundary_backward(doc, cursor); 957 + if word_start < cursor { 958 + let _ = doc.remove_tracked(word_start, cursor - word_start); 959 + doc.cursor.write().offset = word_start; 960 + } 961 + } 962 + 963 + doc.selection.set(None); 964 + true 965 + } 966 + 967 + EditorAction::DeleteWordForward { range } => { 968 + let range = range.normalize(); 969 + doc.pending_snap.set(Some(SnapDirection::Forward)); 970 + 971 + if !range.is_caret() { 972 + let _ = doc.remove_tracked(range.start, range.len()); 973 + doc.cursor.write().offset = range.start; 974 + } else { 975 + // Find word boundary forward 976 + let cursor = range.start; 977 + let word_end = find_word_boundary_forward(doc, cursor); 978 + if word_end > cursor { 979 + let _ = doc.remove_tracked(cursor, word_end - cursor); 980 + } 981 + } 982 + 983 + doc.selection.set(None); 984 + true 985 + } 986 + 987 + EditorAction::DeleteToLineStart { range } => { 988 + let range = range.normalize(); 989 + doc.pending_snap.set(Some(SnapDirection::Backward)); 990 + 991 + let cursor = if range.is_caret() { 992 + range.start 993 + } else { 994 + range.start 995 + }; 996 + let line_start = find_line_start(doc.loro_text(), cursor); 997 + 998 + if line_start < cursor { 999 + let _ = doc.remove_tracked(line_start, cursor - line_start); 1000 + doc.cursor.write().offset = line_start; 1001 + } 1002 + 1003 + doc.selection.set(None); 1004 + true 1005 + } 1006 + 1007 + EditorAction::DeleteToLineEnd { range } => { 1008 + let range = range.normalize(); 1009 + doc.pending_snap.set(Some(SnapDirection::Forward)); 1010 + 1011 + let cursor = if range.is_caret() { 1012 + range.start 1013 + } else { 1014 + range.end 1015 + }; 1016 + let line_end = find_line_end(doc.loro_text(), cursor); 1017 + 1018 + if cursor < line_end { 1019 + let _ = doc.remove_tracked(cursor, line_end - cursor); 1020 + } 1021 + 1022 + doc.selection.set(None); 1023 + true 1024 + } 1025 + 1026 + EditorAction::DeleteSoftLineBackward { range } => { 1027 + // For now, treat same as DeleteToLineStart 1028 + // TODO: Handle visual line wrapping if needed 1029 + execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) 1030 + } 1031 + 1032 + EditorAction::DeleteSoftLineForward { range } => { 1033 + // For now, treat same as DeleteToLineEnd 1034 + execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) 1035 + } 1036 + 1037 + EditorAction::Undo => { 1038 + if let Ok(true) = doc.undo() { 1039 + let max = doc.len_chars(); 1040 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 1041 + doc.selection.set(None); 1042 + true 1043 + } else { 1044 + false 1045 + } 1046 + } 1047 + 1048 + EditorAction::Redo => { 1049 + if let Ok(true) = doc.redo() { 1050 + let max = doc.len_chars(); 1051 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 1052 + doc.selection.set(None); 1053 + true 1054 + } else { 1055 + false 1056 + } 1057 + } 1058 + 1059 + EditorAction::ToggleBold => { 1060 + formatting::apply_formatting(doc, FormatAction::Bold); 1061 + true 1062 + } 1063 + 1064 + EditorAction::ToggleItalic => { 1065 + formatting::apply_formatting(doc, FormatAction::Italic); 1066 + true 1067 + } 1068 + 1069 + EditorAction::ToggleCode => { 1070 + formatting::apply_formatting(doc, FormatAction::Code); 1071 + true 1072 + } 1073 + 1074 + EditorAction::ToggleStrikethrough => { 1075 + formatting::apply_formatting(doc, FormatAction::Strikethrough); 1076 + true 1077 + } 1078 + 1079 + EditorAction::InsertLink => { 1080 + formatting::apply_formatting(doc, FormatAction::Link); 1081 + true 1082 + } 1083 + 1084 + EditorAction::Cut => { 1085 + // Handled separately via clipboard events 1086 + false 1087 + } 1088 + 1089 + EditorAction::Copy => { 1090 + // Handled separately via clipboard events 1091 + false 1092 + } 1093 + 1094 + EditorAction::Paste { range: _ } => { 1095 + // Handled separately via clipboard events (needs async clipboard access) 1096 + false 1097 + } 1098 + 1099 + EditorAction::CopyAsHtml => { 1100 + // Handled in component with async clipboard access 1101 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1102 + { 1103 + if let Some(sel) = *doc.selection.read() { 1104 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1105 + if start != end { 1106 + if let Some(markdown) = doc.slice(start, end) { 1107 + let clean_md = markdown.replace('\u{200C}', "").replace('\u{200B}', ""); 1108 + wasm_bindgen_futures::spawn_local(async move { 1109 + if let Err(e) = super::input::copy_as_html(&clean_md).await { 1110 + tracing::warn!("[COPY HTML] Failed: {:?}", e); 1111 + } 1112 + }); 1113 + return true; 1114 + } 1115 + } 1116 + } 1117 + } 1118 + false 1119 + } 1120 + 1121 + EditorAction::SelectAll => { 1122 + let len = doc.len_chars(); 1123 + doc.selection.set(Some(super::document::Selection { 1124 + anchor: 0, 1125 + head: len, 1126 + })); 1127 + doc.cursor.write().offset = len; 1128 + true 1129 + } 1130 + 1131 + EditorAction::MoveCursor { offset } => { 1132 + let offset = (*offset).min(doc.len_chars()); 1133 + doc.cursor.write().offset = offset; 1134 + doc.selection.set(None); 1135 + true 1136 + } 1137 + 1138 + EditorAction::ExtendSelection { offset } => { 1139 + let offset = (*offset).min(doc.len_chars()); 1140 + let current_sel = *doc.selection.read(); 1141 + let anchor = current_sel 1142 + .map(|s| s.anchor) 1143 + .unwrap_or(doc.cursor.read().offset); 1144 + doc.selection.set(Some(super::document::Selection { 1145 + anchor, 1146 + head: offset, 1147 + })); 1148 + doc.cursor.write().offset = offset; 1149 + true 1150 + } 1151 + } 1152 + } 1153 + 1154 + /// Find word boundary backward from cursor. 1155 + fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize { 1156 + use super::input::get_char_at; 1157 + 1158 + if cursor == 0 { 1159 + return 0; 1160 + } 1161 + 1162 + let mut pos = cursor; 1163 + 1164 + // Skip any whitespace/punctuation immediately before cursor 1165 + while pos > 0 { 1166 + match get_char_at(doc.loro_text(), pos - 1) { 1167 + Some(c) if c.is_alphanumeric() || c == '_' => break, 1168 + Some(_) => pos -= 1, 1169 + None => break, 1170 + } 1171 + } 1172 + 1173 + // Skip the word characters 1174 + while pos > 0 { 1175 + match get_char_at(doc.loro_text(), pos - 1) { 1176 + Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1, 1177 + _ => break, 1178 + } 1179 + } 1180 + 1181 + pos 1182 + } 1183 + 1184 + /// Find word boundary forward from cursor. 1185 + fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize { 1186 + use super::input::get_char_at; 1187 + 1188 + let len = doc.len_chars(); 1189 + if cursor >= len { 1190 + return len; 1191 + } 1192 + 1193 + let mut pos = cursor; 1194 + 1195 + // Skip word characters first 1196 + while pos < len { 1197 + match get_char_at(doc.loro_text(), pos) { 1198 + Some(c) if c.is_alphanumeric() || c == '_' => pos += 1, 1199 + _ => break, 1200 + } 1201 + } 1202 + 1203 + // Then skip whitespace/punctuation 1204 + while pos < len { 1205 + match get_char_at(doc.loro_text(), pos) { 1206 + Some(c) if c.is_alphanumeric() || c == '_' => break, 1207 + Some(_) => pos += 1, 1208 + None => break, 1209 + } 1210 + } 1211 + 1212 + pos 1213 + } 1214 + 1215 + /// Result of handling a keydown event. 1216 + #[derive(Debug, Clone, PartialEq)] 1217 + pub enum KeydownResult { 1218 + /// Event was handled, prevent default. 1219 + Handled, 1220 + /// Event was not a keybinding, let browser/beforeinput handle it. 1221 + NotHandled, 1222 + /// Event should be passed through (navigation, etc.). 1223 + PassThrough, 1224 + } 1225 + 1226 + /// Handle a keydown event using the keybinding configuration. 1227 + /// 1228 + /// This handles keyboard shortcuts only. Text input and deletion 1229 + /// are handled by beforeinput. Navigation (arrows, etc.) is passed 1230 + /// through to the browser. 1231 + /// 1232 + /// # Arguments 1233 + /// * `doc` - The editor document 1234 + /// * `config` - Keybinding configuration 1235 + /// * `combo` - The key combination from the keyboard event 1236 + /// * `range` - Current cursor position / selection range 1237 + /// 1238 + /// # Returns 1239 + /// Whether the event was handled. 1240 + pub fn handle_keydown_with_bindings( 1241 + doc: &mut EditorDocument, 1242 + config: &KeybindingConfig, 1243 + combo: KeyCombo, 1244 + range: Range, 1245 + ) -> KeydownResult { 1246 + // Look up keybinding (range is applied by lookup) 1247 + if let Some(action) = config.lookup(combo.clone(), range) { 1248 + execute_action(doc, &action); 1249 + return KeydownResult::Handled; 1250 + } 1251 + 1252 + // No keybinding matched - check if this is navigation or content 1253 + if combo.key.is_navigation() { 1254 + return KeydownResult::PassThrough; 1255 + } 1256 + 1257 + // Modifier-only keypresses should pass through 1258 + if combo.key.is_modifier() { 1259 + return KeydownResult::PassThrough; 1260 + } 1261 + 1262 + // Content keys (typing, backspace, etc.) - let beforeinput handle 1263 + KeydownResult::NotHandled 1264 + }
+530
crates/weaver-app/src/components/editor/beforeinput.rs
···
··· 1 + //! BeforeInput event handling for the editor. 2 + //! 3 + //! This module provides the primary input handling via the `beforeinput` event, 4 + //! which gives us semantic information about what the browser wants to do 5 + //! (insert text, delete backward, etc.) rather than raw key codes. 6 + //! 7 + //! ## Browser Support 8 + //! 9 + //! `beforeinput` is well-supported in modern browsers, but has quirks: 10 + //! - Android: `getTargetRanges()` can be unreliable during composition 11 + //! - Safari: Some input types may not fire or have wrong data 12 + //! - All: `isComposing` flag behavior varies 13 + //! 14 + //! We handle these with platform-specific workarounds inherited from the 15 + //! battle-tested patterns in ProseMirror. 16 + 17 + use dioxus::prelude::*; 18 + 19 + use super::actions::{EditorAction, Range, execute_action}; 20 + use super::document::EditorDocument; 21 + use super::platform::Platform; 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 + } 52 + 53 + #[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 + pub enum InputType { 61 + // === Insertion === 62 + /// Insert typed text. 63 + InsertText, 64 + /// Insert text from IME composition. 65 + InsertCompositionText, 66 + /// Insert a line break (`<br>`, Shift+Enter). 67 + InsertLineBreak, 68 + /// Insert a paragraph break (Enter). 69 + InsertParagraph, 70 + /// Insert from paste operation. 71 + InsertFromPaste, 72 + /// Insert from drop operation. 73 + InsertFromDrop, 74 + /// Insert replacement text (e.g., spell check correction). 75 + InsertReplacementText, 76 + /// Insert from voice input or other source. 77 + InsertFromYank, 78 + /// Insert a horizontal rule. 79 + InsertHorizontalRule, 80 + /// Insert an ordered list. 81 + InsertOrderedList, 82 + /// Insert an unordered list. 83 + InsertUnorderedList, 84 + /// Insert a link. 85 + InsertLink, 86 + 87 + // === Deletion === 88 + /// Delete content backward (Backspace). 89 + DeleteContentBackward, 90 + /// Delete content forward (Delete key). 91 + DeleteContentForward, 92 + /// Delete word backward (Ctrl/Alt+Backspace). 93 + DeleteWordBackward, 94 + /// Delete word forward (Ctrl/Alt+Delete). 95 + DeleteWordForward, 96 + /// Delete to soft line boundary backward. 97 + DeleteSoftLineBackward, 98 + /// Delete to soft line boundary forward. 99 + DeleteSoftLineForward, 100 + /// Delete to hard line boundary backward (Cmd+Backspace on Mac). 101 + DeleteHardLineBackward, 102 + /// Delete to hard line boundary forward (Cmd+Delete on Mac). 103 + DeleteHardLineForward, 104 + /// Delete by cut operation. 105 + DeleteByCut, 106 + /// Delete by drag operation. 107 + DeleteByDrag, 108 + /// Generic content deletion. 109 + DeleteContent, 110 + /// Delete entire word backward (Ctrl+W on some systems). 111 + DeleteEntireWordBackward, 112 + /// Delete entire word forward. 113 + DeleteEntireWordForward, 114 + 115 + // === History === 116 + /// Undo. 117 + HistoryUndo, 118 + /// Redo. 119 + HistoryRedo, 120 + 121 + // === Formatting (rarely used, most apps handle via shortcuts) === 122 + FormatBold, 123 + FormatItalic, 124 + FormatUnderline, 125 + FormatStrikethrough, 126 + FormatSuperscript, 127 + FormatSubscript, 128 + 129 + // === Unknown === 130 + /// Unrecognized input type. 131 + Unknown(String), 132 + } 133 + 134 + impl InputType { 135 + /// Parse from the browser's inputType string. 136 + pub fn from_str(s: &str) -> Self { 137 + match s { 138 + // Insertion 139 + "insertText" => Self::InsertText, 140 + "insertCompositionText" => Self::InsertCompositionText, 141 + "insertLineBreak" => Self::InsertLineBreak, 142 + "insertParagraph" => Self::InsertParagraph, 143 + "insertFromPaste" => Self::InsertFromPaste, 144 + "insertFromDrop" => Self::InsertFromDrop, 145 + "insertReplacementText" => Self::InsertReplacementText, 146 + "insertFromYank" => Self::InsertFromYank, 147 + "insertHorizontalRule" => Self::InsertHorizontalRule, 148 + "insertOrderedList" => Self::InsertOrderedList, 149 + "insertUnorderedList" => Self::InsertUnorderedList, 150 + "insertLink" => Self::InsertLink, 151 + 152 + // Deletion 153 + "deleteContentBackward" => Self::DeleteContentBackward, 154 + "deleteContentForward" => Self::DeleteContentForward, 155 + "deleteWordBackward" => Self::DeleteWordBackward, 156 + "deleteWordForward" => Self::DeleteWordForward, 157 + "deleteSoftLineBackward" => Self::DeleteSoftLineBackward, 158 + "deleteSoftLineForward" => Self::DeleteSoftLineForward, 159 + "deleteHardLineBackward" => Self::DeleteHardLineBackward, 160 + "deleteHardLineForward" => Self::DeleteHardLineForward, 161 + "deleteByCut" => Self::DeleteByCut, 162 + "deleteByDrag" => Self::DeleteByDrag, 163 + "deleteContent" => Self::DeleteContent, 164 + "deleteEntireSoftLine" => Self::DeleteSoftLineBackward, // Treat as soft line 165 + "deleteEntireWordBackward" => Self::DeleteEntireWordBackward, 166 + "deleteEntireWordForward" => Self::DeleteEntireWordForward, 167 + 168 + // History 169 + "historyUndo" => Self::HistoryUndo, 170 + "historyRedo" => Self::HistoryRedo, 171 + 172 + // Formatting 173 + "formatBold" => Self::FormatBold, 174 + "formatItalic" => Self::FormatItalic, 175 + "formatUnderline" => Self::FormatUnderline, 176 + "formatStrikethrough" => Self::FormatStrikethrough, 177 + "formatSuperscript" => Self::FormatSuperscript, 178 + "formatSubscript" => Self::FormatSubscript, 179 + 180 + // Unknown 181 + other => Self::Unknown(other.to_string()), 182 + } 183 + } 184 + 185 + /// Whether this input type is a deletion operation. 186 + pub fn is_deletion(&self) -> bool { 187 + matches!( 188 + self, 189 + Self::DeleteContentBackward 190 + | Self::DeleteContentForward 191 + | Self::DeleteWordBackward 192 + | Self::DeleteWordForward 193 + | Self::DeleteSoftLineBackward 194 + | Self::DeleteSoftLineForward 195 + | Self::DeleteHardLineBackward 196 + | Self::DeleteHardLineForward 197 + | Self::DeleteByCut 198 + | Self::DeleteByDrag 199 + | Self::DeleteContent 200 + | Self::DeleteEntireWordBackward 201 + | Self::DeleteEntireWordForward 202 + ) 203 + } 204 + 205 + /// Whether this input type is an insertion operation. 206 + pub fn is_insertion(&self) -> bool { 207 + matches!( 208 + self, 209 + Self::InsertText 210 + | Self::InsertCompositionText 211 + | Self::InsertLineBreak 212 + | Self::InsertParagraph 213 + | Self::InsertFromPaste 214 + | Self::InsertFromDrop 215 + | Self::InsertReplacementText 216 + | Self::InsertFromYank 217 + ) 218 + } 219 + } 220 + 221 + /// Result of handling a beforeinput event. 222 + #[derive(Debug, Clone)] 223 + pub enum BeforeInputResult { 224 + /// Event was handled, prevent default browser behavior. 225 + Handled, 226 + /// Event should be handled by browser (e.g., during composition). 227 + PassThrough, 228 + /// Event was handled but requires async follow-up (e.g., paste). 229 + HandledAsync, 230 + /// Android backspace workaround: defer and check if browser handled it. 231 + DeferredCheck { 232 + /// The action to execute if browser didn't handle it. 233 + fallback_action: EditorAction, 234 + }, 235 + } 236 + 237 + /// Context for beforeinput handling. 238 + pub struct BeforeInputContext<'a> { 239 + /// The input type. 240 + pub input_type: InputType, 241 + /// The data (text to insert, if any). 242 + pub data: Option<String>, 243 + /// Target range from getTargetRanges(), if available. 244 + /// This is the range the browser wants to modify. 245 + pub target_range: Option<Range>, 246 + /// Whether the event is part of an IME composition. 247 + pub is_composing: bool, 248 + /// Platform info for quirks handling. 249 + pub platform: &'a Platform, 250 + } 251 + 252 + /// Handle a beforeinput event. 253 + /// 254 + /// This is the main entry point for beforeinput-based input handling. 255 + /// Returns whether the event was handled and default should be prevented. 256 + pub fn handle_beforeinput( 257 + doc: &mut EditorDocument, 258 + ctx: BeforeInputContext<'_>, 259 + ) -> BeforeInputResult { 260 + // During composition, let the browser handle most things. 261 + // We'll commit the final text in compositionend. 262 + if ctx.is_composing { 263 + match ctx.input_type { 264 + // These can happen during composition but should still be handled 265 + InputType::HistoryUndo | InputType::HistoryRedo => { 266 + // Handle undo/redo even during composition 267 + } 268 + InputType::InsertCompositionText => { 269 + // Let browser handle composition preview 270 + return BeforeInputResult::PassThrough; 271 + } 272 + _ => { 273 + // Let browser handle 274 + return BeforeInputResult::PassThrough; 275 + } 276 + } 277 + } 278 + 279 + // Get the range to operate on 280 + let range = ctx.target_range.unwrap_or_else(|| get_current_range(doc)); 281 + 282 + match ctx.input_type { 283 + // === Insertion === 284 + InputType::InsertText => { 285 + if let Some(text) = ctx.data { 286 + let action = EditorAction::Insert { text, range }; 287 + execute_action(doc, &action); 288 + BeforeInputResult::Handled 289 + } else { 290 + BeforeInputResult::PassThrough 291 + } 292 + } 293 + 294 + InputType::InsertLineBreak => { 295 + let action = EditorAction::InsertLineBreak { range }; 296 + execute_action(doc, &action); 297 + BeforeInputResult::Handled 298 + } 299 + 300 + InputType::InsertParagraph => { 301 + let action = EditorAction::InsertParagraph { range }; 302 + execute_action(doc, &action); 303 + BeforeInputResult::Handled 304 + } 305 + 306 + InputType::InsertFromPaste | InputType::InsertReplacementText => { 307 + // For paste, we need the data from the event or clipboard 308 + if let Some(text) = ctx.data { 309 + let action = EditorAction::Insert { text, range }; 310 + execute_action(doc, &action); 311 + BeforeInputResult::Handled 312 + } else { 313 + // No data in event - need to handle via clipboard API 314 + BeforeInputResult::PassThrough 315 + } 316 + } 317 + 318 + InputType::InsertFromDrop => { 319 + // Let browser handle drops for now 320 + BeforeInputResult::PassThrough 321 + } 322 + 323 + InputType::InsertCompositionText => { 324 + // Should be caught by is_composing check above, but just in case 325 + BeforeInputResult::PassThrough 326 + } 327 + 328 + // === Deletion === 329 + InputType::DeleteContentBackward => { 330 + // Android Chrome workaround: backspace sometimes doesn't work properly 331 + // after uneditable nodes. Use deferred check pattern from ProseMirror. 332 + // BUT only for caret deletions - selections we handle directly since 333 + // the browser might only delete one char instead of the whole selection. 334 + if ctx.platform.android && ctx.platform.chrome && range.is_caret() { 335 + let action = EditorAction::DeleteBackward { range }; 336 + return BeforeInputResult::DeferredCheck { 337 + fallback_action: action, 338 + }; 339 + } 340 + 341 + let action = EditorAction::DeleteBackward { range }; 342 + execute_action(doc, &action); 343 + BeforeInputResult::Handled 344 + } 345 + 346 + InputType::DeleteContentForward => { 347 + let action = EditorAction::DeleteForward { range }; 348 + execute_action(doc, &action); 349 + BeforeInputResult::Handled 350 + } 351 + 352 + InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { 353 + let action = EditorAction::DeleteWordBackward { range }; 354 + execute_action(doc, &action); 355 + BeforeInputResult::Handled 356 + } 357 + 358 + InputType::DeleteWordForward | InputType::DeleteEntireWordForward => { 359 + let action = EditorAction::DeleteWordForward { range }; 360 + execute_action(doc, &action); 361 + BeforeInputResult::Handled 362 + } 363 + 364 + InputType::DeleteSoftLineBackward => { 365 + let action = EditorAction::DeleteSoftLineBackward { range }; 366 + execute_action(doc, &action); 367 + BeforeInputResult::Handled 368 + } 369 + 370 + InputType::DeleteSoftLineForward => { 371 + let action = EditorAction::DeleteSoftLineForward { range }; 372 + execute_action(doc, &action); 373 + BeforeInputResult::Handled 374 + } 375 + 376 + InputType::DeleteHardLineBackward => { 377 + let action = EditorAction::DeleteToLineStart { range }; 378 + execute_action(doc, &action); 379 + BeforeInputResult::Handled 380 + } 381 + 382 + InputType::DeleteHardLineForward => { 383 + let action = EditorAction::DeleteToLineEnd { range }; 384 + execute_action(doc, &action); 385 + BeforeInputResult::Handled 386 + } 387 + 388 + InputType::DeleteByCut => { 389 + // Cut is handled separately via clipboard events 390 + // But we should delete the selection here 391 + if !range.is_caret() { 392 + let action = EditorAction::DeleteBackward { range }; 393 + execute_action(doc, &action); 394 + } 395 + BeforeInputResult::Handled 396 + } 397 + 398 + InputType::DeleteByDrag | InputType::DeleteContent => { 399 + if !range.is_caret() { 400 + let action = EditorAction::DeleteBackward { range }; 401 + execute_action(doc, &action); 402 + } 403 + BeforeInputResult::Handled 404 + } 405 + 406 + // === History === 407 + InputType::HistoryUndo => { 408 + execute_action(doc, &EditorAction::Undo); 409 + BeforeInputResult::Handled 410 + } 411 + 412 + InputType::HistoryRedo => { 413 + execute_action(doc, &EditorAction::Redo); 414 + BeforeInputResult::Handled 415 + } 416 + 417 + // === Formatting === 418 + InputType::FormatBold => { 419 + execute_action(doc, &EditorAction::ToggleBold); 420 + BeforeInputResult::Handled 421 + } 422 + 423 + InputType::FormatItalic => { 424 + execute_action(doc, &EditorAction::ToggleItalic); 425 + BeforeInputResult::Handled 426 + } 427 + 428 + InputType::FormatStrikethrough => { 429 + execute_action(doc, &EditorAction::ToggleStrikethrough); 430 + BeforeInputResult::Handled 431 + } 432 + 433 + // === Other === 434 + InputType::InsertFromYank 435 + | InputType::InsertHorizontalRule 436 + | InputType::InsertOrderedList 437 + | InputType::InsertUnorderedList 438 + | InputType::InsertLink 439 + | InputType::FormatUnderline 440 + | InputType::FormatSuperscript 441 + | InputType::FormatSubscript 442 + | InputType::Unknown(_) => { 443 + // Not handled - let browser do its thing or ignore 444 + BeforeInputResult::PassThrough 445 + } 446 + } 447 + } 448 + 449 + /// Get the current range based on cursor and selection state. 450 + fn get_current_range(doc: &EditorDocument) -> Range { 451 + if let Some(sel) = *doc.selection.read() { 452 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 453 + Range::new(start, end) 454 + } else { 455 + Range::caret(doc.cursor.read().offset) 456 + } 457 + } 458 + 459 + /// Extract target range from a beforeinput event. 460 + /// 461 + /// Uses getTargetRanges() to get the browser's intended range for this operation. 462 + /// This requires mapping DOM positions to document character offsets via paragraphs. 463 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 464 + pub fn get_target_range_from_event( 465 + event: &web_sys::InputEvent, 466 + editor_id: &str, 467 + paragraphs: &[super::paragraph::ParagraphRender], 468 + ) -> Option<Range> { 469 + use super::dom_sync::dom_position_to_text_offset; 470 + use wasm_bindgen::JsCast; 471 + 472 + let ranges = event.get_target_ranges(); 473 + if ranges.length() == 0 { 474 + return None; 475 + } 476 + 477 + // Get the first range (there's usually only one) 478 + // getTargetRanges returns an array of StaticRange objects 479 + let static_range: StaticRange = ranges.get(0).unchecked_into(); 480 + 481 + let window = web_sys::window()?; 482 + let dom_document = window.document()?; 483 + let editor_element = dom_document.get_element_by_id(editor_id)?; 484 + 485 + let start_container = static_range.startContainer(); 486 + let start_offset = static_range.startOffset() as usize; 487 + let end_container = static_range.endContainer(); 488 + let end_offset = static_range.endOffset() as usize; 489 + 490 + let start = dom_position_to_text_offset( 491 + &dom_document, 492 + &editor_element, 493 + &start_container, 494 + start_offset, 495 + paragraphs, 496 + None, 497 + )?; 498 + let end = dom_position_to_text_offset( 499 + &dom_document, 500 + &editor_element, 501 + &end_container, 502 + end_offset, 503 + paragraphs, 504 + None, 505 + )?; 506 + 507 + Some(Range::new(start, end)) 508 + } 509 + 510 + /// Get data from a beforeinput event, handling different sources. 511 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 512 + pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> { 513 + // First try the data property 514 + if let Some(data) = event.data() { 515 + if !data.is_empty() { 516 + return Some(data); 517 + } 518 + } 519 + 520 + // For paste/drop, try dataTransfer 521 + if let Some(data_transfer) = event.data_transfer() { 522 + if let Ok(text) = data_transfer.get_data("text/plain") { 523 + if !text.is_empty() { 524 + return Some(text); 525 + } 526 + } 527 + } 528 + 529 + None 530 + }
+109 -52
crates/weaver-app/src/components/editor/component.rs
··· 13 use crate::components::editor::ReportButton; 14 use crate::fetch::Fetcher; 15 16 - use super::document::{CompositionState, EditorDocument}; 17 use super::dom_sync::{ 18 sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom, 19 }; 20 use super::formatting; 21 - use super::input::{ 22 - get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, 23 - }; 24 use super::offset_map::SnapDirection; 25 use super::paragraph::ParagraphRender; 26 use super::platform; 27 - use super::document::LoadedDocState; 28 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 29 use super::render; 30 use super::storage; ··· 382 interval.forget(); 383 }); 384 385 - // Set up beforeinput listener for iOS/Android virtual keyboard quirks 386 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 387 let doc_for_beforeinput = document.clone(); 388 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 389 use_effect(move || { 390 use wasm_bindgen::JsCast; 391 use wasm_bindgen::prelude::*; 392 - 393 - let plat = platform::platform(); 394 - 395 - // Only needed on mobile 396 - if !plat.mobile { 397 - return; 398 - } 399 400 let window = match web_sys::window() { 401 Some(w) => w, ··· 414 let cached_paras = cached_paragraphs; 415 416 let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 417 - let input_type = evt.input_type(); 418 - tracing::debug!(input_type = %input_type, "beforeinput"); 419 420 let plat = platform::platform(); 421 422 - // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak 423 - // without proper keydown events. Handle them here. 424 - if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { 425 - tracing::debug!("iOS: intercepting {} via beforeinput", input_type); 426 - evt.prevent_default(); 427 428 - // Handle as Enter key 429 - let sel = doc.selection.write().take(); 430 - if let Some(sel) = sel { 431 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 432 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 433 - doc.cursor.write().offset = start; 434 } 435 436 - let cursor_offset = doc.cursor.read().offset; 437 - if input_type == "insertLineBreak" { 438 - // Soft break (like Shift+Enter) 439 - let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); 440 - doc.cursor.write().offset = cursor_offset + 3; 441 - } else { 442 - // Paragraph break 443 - let _ = doc.insert_tracked(cursor_offset, "\n\n"); 444 - doc.cursor.write().offset = cursor_offset + 2; 445 } 446 } 447 448 // Android workaround: When swipe keyboard picks a suggestion, 449 - // DOM mutations fire before selection moves. We detect this pattern 450 - // and defer cursor sync. 451 - if plat.android && input_type == "insertText" { 452 - // Check if this might be a suggestion pick (has data that looks like a word) 453 if let Some(data) = evt.data() { 454 if data.contains(' ') || data.len() > 3 { 455 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 456 - // Defer cursor sync by 20ms to let selection settle 457 let paras = cached_paras; 458 let mut doc_for_timeout = doc.clone(); 459 let window = web_sys::window(); ··· 582 583 onkeydown: { 584 let mut doc = document.clone(); 585 move |evt| { 586 use dioxus::prelude::keyboard_types::Key; 587 use std::time::Duration; ··· 626 } 627 } 628 629 - // Android workaround: Chrome Android gets confused by Enter during/after 630 - // composition. Defer Enter handling to onkeypress instead. 631 - if plat.android && evt.key() == Key::Enter { 632 - tracing::debug!("Android: deferring Enter to keypress"); 633 - return; 634 } 635 636 - // Only prevent default for operations that modify content 637 - // Let browser handle arrow keys, Home/End naturally 638 - if should_intercept_key(&evt) { 639 - evt.prevent_default(); 640 - handle_keydown(evt, &mut doc); 641 - } 642 } 643 }, 644 ··· 769 if plat.android && evt.key() == Key::Enter { 770 tracing::debug!("Android: handling Enter in keypress"); 771 evt.prevent_default(); 772 - handle_keydown(evt, &mut doc); 773 } 774 } 775 },
··· 13 use crate::components::editor::ReportButton; 14 use crate::fetch::Fetcher; 15 16 + use super::actions::{ 17 + execute_action, handle_keydown_with_bindings, EditorAction, Key, KeyCombo, KeybindingConfig, 18 + KeydownResult, Range, 19 + }; 20 + use super::beforeinput::{handle_beforeinput, BeforeInputContext, BeforeInputResult, InputType}; 21 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 22 + use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 23 + use super::document::{CompositionState, EditorDocument, LoadedDocState}; 24 use super::dom_sync::{ 25 sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom, 26 }; 27 use super::formatting; 28 + use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 29 use super::offset_map::SnapDirection; 30 use super::paragraph::ParagraphRender; 31 use super::platform; 32 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 33 use super::render; 34 use super::storage; ··· 386 interval.forget(); 387 }); 388 389 + // Set up beforeinput listener for all text input handling. 390 + // This is the primary handler for text insertion, deletion, etc. 391 + // Keydown only handles shortcuts now. 392 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 393 let doc_for_beforeinput = document.clone(); 394 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 395 use_effect(move || { 396 use wasm_bindgen::JsCast; 397 use wasm_bindgen::prelude::*; 398 399 let window = match web_sys::window() { 400 Some(w) => w, ··· 413 let cached_paras = cached_paragraphs; 414 415 let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 416 + let input_type_str = evt.input_type(); 417 + tracing::debug!(input_type = %input_type_str, "beforeinput"); 418 419 let plat = platform::platform(); 420 + let input_type = InputType::from_str(&input_type_str); 421 + let is_composing = evt.is_composing(); 422 423 + // Get target range from the event if available 424 + let paras = cached_paras.peek().clone(); 425 + let target_range = get_target_range_from_event(&evt, editor_id, &paras); 426 + 427 + // Get data from the event 428 + let data = get_data_from_event(&evt); 429 + 430 + // Build context and handle 431 + let ctx = BeforeInputContext { 432 + input_type: input_type.clone(), 433 + data, 434 + target_range, 435 + is_composing, 436 + platform: &plat, 437 + }; 438 + 439 + let result = handle_beforeinput(&mut doc, ctx); 440 441 + match result { 442 + BeforeInputResult::Handled => { 443 + evt.prevent_default(); 444 + } 445 + BeforeInputResult::PassThrough => { 446 + // Let browser handle (e.g., during composition) 447 + } 448 + BeforeInputResult::HandledAsync => { 449 + evt.prevent_default(); 450 + // Async follow-up will happen elsewhere 451 } 452 + BeforeInputResult::DeferredCheck { fallback_action } => { 453 + // Android backspace workaround: let browser try first, 454 + // check in 50ms if anything happened, if not execute fallback 455 + let mut doc_for_timeout = doc.clone(); 456 + let doc_len_before = doc.len_chars(); 457 458 + let window = web_sys::window(); 459 + if let Some(window) = window { 460 + let closure = Closure::once(move || { 461 + // Check if the document changed 462 + if doc_for_timeout.len_chars() == doc_len_before { 463 + // Nothing happened - execute fallback 464 + tracing::debug!("Android backspace fallback triggered"); 465 + // Refocus to work around virtual keyboard issues 466 + if let Some(window) = web_sys::window() { 467 + if let Some(doc) = window.document() { 468 + if let Some(elem) = doc.get_element_by_id(editor_id) { 469 + if let Some(html_elem) = elem.dyn_ref::<web_sys::HtmlElement>() { 470 + let _ = html_elem.blur(); 471 + let _ = html_elem.focus(); 472 + } 473 + } 474 + } 475 + } 476 + execute_action(&mut doc_for_timeout, &fallback_action); 477 + } 478 + }); 479 + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 480 + closure.as_ref().unchecked_ref(), 481 + 50, 482 + ); 483 + closure.forget(); 484 + } 485 } 486 } 487 488 // Android workaround: When swipe keyboard picks a suggestion, 489 + // DOM mutations fire before selection moves. Defer cursor sync. 490 + if plat.android && matches!(input_type, InputType::InsertText) { 491 if let Some(data) = evt.data() { 492 if data.contains(' ') || data.len() > 3 { 493 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 494 let paras = cached_paras; 495 let mut doc_for_timeout = doc.clone(); 496 let window = web_sys::window(); ··· 619 620 onkeydown: { 621 let mut doc = document.clone(); 622 + let keybindings = KeybindingConfig::default_for_platform(&platform::platform()); 623 move |evt| { 624 use dioxus::prelude::keyboard_types::Key; 625 use std::time::Duration; ··· 664 } 665 } 666 667 + // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) 668 + let combo = KeyCombo::from_keyboard_event(&evt.data()); 669 + let cursor_offset = doc.cursor.read().offset; 670 + let selection = *doc.selection.read(); 671 + let range = selection 672 + .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 673 + .unwrap_or_else(|| Range::caret(cursor_offset)); 674 + match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { 675 + KeydownResult::Handled => { 676 + evt.prevent_default(); 677 + return; 678 + } 679 + KeydownResult::PassThrough => { 680 + // Navigation keys - let browser handle, sync in keyup 681 + return; 682 + } 683 + KeydownResult::NotHandled => { 684 + // Text input - let beforeinput handle it 685 + } 686 } 687 688 + // Text input keys: let beforeinput handle them 689 + // We don't prevent default here - beforeinput will do that 690 } 691 }, 692 ··· 817 if plat.android && evt.key() == Key::Enter { 818 tracing::debug!("Android: handling Enter in keypress"); 819 evt.prevent_default(); 820 + 821 + // Get current range 822 + let range = if let Some(sel) = *doc.selection.read() { 823 + Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 824 + } else { 825 + Range::caret(doc.cursor.read().offset) 826 + }; 827 + 828 + let action = EditorAction::InsertParagraph { range }; 829 + execute_action(&mut doc, &action); 830 } 831 } 832 },
+7 -5
crates/weaver-app/src/components/editor/dom_sync.rs
··· 6 use dioxus::prelude::*; 7 8 use super::document::{EditorDocument, Selection}; 9 - use super::offset_map::{find_nearest_valid_position, is_valid_cursor_position, SnapDirection}; 10 use super::paragraph::ParagraphRender; 11 12 /// Sync internal cursor and selection state from browser DOM selection. ··· 72 let focus_offset = selection.focus_offset() as usize; 73 74 // Convert both DOM positions to rope offsets using cached paragraphs 75 - let anchor_rope = dom_position_to_rope_offset( 76 &dom_document, 77 &editor_element, 78 &anchor_node, ··· 80 paragraphs, 81 direction_hint, 82 ); 83 - let focus_rope = dom_position_to_rope_offset( 84 &dom_document, 85 &editor_element, 86 &focus_node, ··· 114 /// The `direction_hint` is used when snapping from invisible content to determine 115 /// which direction to prefer. 116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 117 - fn dom_position_to_rope_offset( 118 dom_document: &web_sys::Document, 119 editor_element: &web_sys::Element, 120 node: &web_sys::Node, ··· 215 // No mapping found - try to find any valid position in paragraphs 216 // This handles clicks on non-text elements like images 217 for para in paragraphs { 218 - if let Some(snapped) = find_nearest_valid_position(&para.offset_map, para.char_range.start, direction_hint) { 219 return Some(snapped.char_offset()); 220 } 221 }
··· 6 use dioxus::prelude::*; 7 8 use super::document::{EditorDocument, Selection}; 9 + use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 10 use super::paragraph::ParagraphRender; 11 12 /// Sync internal cursor and selection state from browser DOM selection. ··· 72 let focus_offset = selection.focus_offset() as usize; 73 74 // Convert both DOM positions to rope offsets using cached paragraphs 75 + let anchor_rope = dom_position_to_text_offset( 76 &dom_document, 77 &editor_element, 78 &anchor_node, ··· 80 paragraphs, 81 direction_hint, 82 ); 83 + let focus_rope = dom_position_to_text_offset( 84 &dom_document, 85 &editor_element, 86 &focus_node, ··· 114 /// The `direction_hint` is used when snapping from invisible content to determine 115 /// which direction to prefer. 116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 117 + pub fn dom_position_to_text_offset( 118 dom_document: &web_sys::Document, 119 editor_element: &web_sys::Element, 120 node: &web_sys::Node, ··· 215 // No mapping found - try to find any valid position in paragraphs 216 // This handles clicks on non-text elements like images 217 for para in paragraphs { 218 + if let Some(snapped) = 219 + find_nearest_valid_position(&para.offset_map, para.char_range.start, direction_hint) 220 + { 221 return Some(snapped.char_offset()); 222 } 223 }
+2
crates/weaver-app/src/components/editor/mod.rs
··· 4 //! characters are hidden contextually based on cursor position, while still 5 //! editing plain markdown text under the hood. 6 7 mod component; 8 mod cursor; 9 mod document;
··· 4 //! characters are hidden contextually based on cursor position, while still 5 //! editing plain markdown text under the hood. 6 7 + mod actions; 8 + mod beforeinput; 9 mod component; 10 mod cursor; 11 mod document;
+31 -1
crates/weaver-app/src/components/editor/writer.rs
··· 1322 self.record_mapping(range.clone(), char_start..char_end); 1323 self.last_char_offset = char_end; 1324 } 1325 - SoftBreak => self.write_newline()?, 1326 HardBreak => { 1327 // Emit the two spaces as visible (dimmed) text, then <br> 1328 let gap = &self.source[range.clone()];
··· 1322 self.record_mapping(range.clone(), char_start..char_end); 1323 self.last_char_offset = char_end; 1324 } 1325 + SoftBreak => { 1326 + // Emit <br> for visual line break, plus a space for cursor positioning. 1327 + // This space maps to the \n so the cursor can land here when navigating. 1328 + let char_start = self.last_char_offset; 1329 + 1330 + // Emit <br> 1331 + self.write("<br />")?; 1332 + self.current_node_child_count += 1; 1333 + 1334 + // Emit space for cursor positioning - this gives the browser somewhere 1335 + // to place the cursor when navigating to this line 1336 + self.write(" ")?; 1337 + self.current_node_child_count += 1; 1338 + 1339 + // Map the space to the newline position - cursor landing here means 1340 + // we're at the end of the line (after the \n) 1341 + if let Some(ref node_id) = self.current_node_id { 1342 + let mapping = OffsetMapping { 1343 + byte_range: range.clone(), 1344 + char_range: char_start..char_start + 1, 1345 + node_id: node_id.clone(), 1346 + char_offset_in_node: self.current_node_char_offset, 1347 + child_index: None, 1348 + utf16_len: 1, // the space we emitted 1349 + }; 1350 + self.offset_maps.push(mapping); 1351 + self.current_node_char_offset += 1; 1352 + } 1353 + 1354 + self.last_char_offset = char_start + 1; // +1 for the \n 1355 + } 1356 HardBreak => { 1357 // Emit the two spaces as visible (dimmed) text, then <br> 1358 let gap = &self.source[range.clone()];