at main 820 lines 21 kB view raw
1//! Editor actions and input types. 2//! 3//! Platform-agnostic definitions for editor operations. The `EditorAction` enum 4//! represents semantic editing operations, while `InputType` represents the 5//! semantic intent from input events (browser beforeinput, native input methods, etc.). 6 7use smol_str::SmolStr; 8 9/// A range in the document, measured in character offsets. 10#[derive(Debug, Clone, Copy, PartialEq, Eq)] 11pub struct Range { 12 pub start: usize, 13 pub end: usize, 14} 15 16impl Range { 17 pub fn new(start: usize, end: usize) -> Self { 18 Self { start, end } 19 } 20 21 pub fn caret(offset: usize) -> Self { 22 Self { 23 start: offset, 24 end: offset, 25 } 26 } 27 28 pub fn is_caret(&self) -> bool { 29 self.start == self.end 30 } 31 32 pub fn len(&self) -> usize { 33 self.end.saturating_sub(self.start) 34 } 35 36 pub fn is_empty(&self) -> bool { 37 self.len() == 0 38 } 39 40 /// Normalize range so start <= end. 41 pub fn normalize(self) -> Self { 42 if self.start <= self.end { 43 self 44 } else { 45 Self { 46 start: self.end, 47 end: self.start, 48 } 49 } 50 } 51} 52 53impl From<std::ops::Range<usize>> for Range { 54 fn from(r: std::ops::Range<usize>) -> Self { 55 Self::new(r.start, r.end) 56 } 57} 58 59impl From<Range> for std::ops::Range<usize> { 60 fn from(r: Range) -> Self { 61 r.start..r.end 62 } 63} 64 65/// Semantic input types from input events. 66/// 67/// These represent the semantic intent of an input operation, abstracted from 68/// the platform-specific event source. Browser `beforeinput` events, native 69/// input methods, and programmatic input can all produce these types. 70/// 71/// Based on the W3C Input Events specification, but usable across platforms. 72#[derive(Debug, Clone, PartialEq, Eq)] 73pub enum InputType { 74 // === Insertion === 75 /// Insert typed text. 76 InsertText, 77 /// Insert text from IME composition. 78 InsertCompositionText, 79 /// Insert a line break (`<br>`, Shift+Enter). 80 InsertLineBreak, 81 /// Insert a paragraph break (Enter). 82 InsertParagraph, 83 /// Insert from paste operation. 84 InsertFromPaste, 85 /// Insert from drop operation. 86 InsertFromDrop, 87 /// Insert replacement text (e.g., spell check correction). 88 InsertReplacementText, 89 /// Insert from voice input or other source. 90 InsertFromYank, 91 /// Insert a horizontal rule. 92 InsertHorizontalRule, 93 /// Insert an ordered list. 94 InsertOrderedList, 95 /// Insert an unordered list. 96 InsertUnorderedList, 97 /// Insert a link. 98 InsertLink, 99 100 // === Deletion === 101 /// Delete content backward (Backspace). 102 DeleteContentBackward, 103 /// Delete content forward (Delete key). 104 DeleteContentForward, 105 /// Delete word backward (Ctrl/Alt+Backspace). 106 DeleteWordBackward, 107 /// Delete word forward (Ctrl/Alt+Delete). 108 DeleteWordForward, 109 /// Delete to soft line boundary backward. 110 DeleteSoftLineBackward, 111 /// Delete to soft line boundary forward. 112 DeleteSoftLineForward, 113 /// Delete to hard line boundary backward (Cmd+Backspace on Mac). 114 DeleteHardLineBackward, 115 /// Delete to hard line boundary forward (Cmd+Delete on Mac). 116 DeleteHardLineForward, 117 /// Delete by cut operation. 118 DeleteByCut, 119 /// Delete by drag operation. 120 DeleteByDrag, 121 /// Generic content deletion. 122 DeleteContent, 123 /// Delete entire word backward. 124 DeleteEntireWordBackward, 125 /// Delete entire word forward. 126 DeleteEntireWordForward, 127 128 // === History === 129 /// Undo. 130 HistoryUndo, 131 /// Redo. 132 HistoryRedo, 133 134 // === Formatting === 135 FormatBold, 136 FormatItalic, 137 FormatUnderline, 138 FormatStrikethrough, 139 FormatSuperscript, 140 FormatSubscript, 141 142 // === Unknown === 143 /// Unrecognized input type. 144 Unknown(String), 145} 146 147impl InputType { 148 /// Whether this input type is a deletion operation. 149 pub fn is_deletion(&self) -> bool { 150 matches!( 151 self, 152 Self::DeleteContentBackward 153 | Self::DeleteContentForward 154 | Self::DeleteWordBackward 155 | Self::DeleteWordForward 156 | Self::DeleteSoftLineBackward 157 | Self::DeleteSoftLineForward 158 | Self::DeleteHardLineBackward 159 | Self::DeleteHardLineForward 160 | Self::DeleteByCut 161 | Self::DeleteByDrag 162 | Self::DeleteContent 163 | Self::DeleteEntireWordBackward 164 | Self::DeleteEntireWordForward 165 ) 166 } 167 168 /// Whether this input type is an insertion operation. 169 pub fn is_insertion(&self) -> bool { 170 matches!( 171 self, 172 Self::InsertText 173 | Self::InsertCompositionText 174 | Self::InsertLineBreak 175 | Self::InsertParagraph 176 | Self::InsertFromPaste 177 | Self::InsertFromDrop 178 | Self::InsertReplacementText 179 | Self::InsertFromYank 180 ) 181 } 182} 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] 190pub 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 204/// All possible editor actions. 205/// 206/// These represent semantic operations on the document, decoupled from 207/// how they're triggered (keyboard, mouse, touch, voice, etc.). 208#[derive(Debug, Clone, PartialEq)] 209pub enum EditorAction { 210 // === Text Insertion === 211 /// Insert text at the given range (replacing any selected content). 212 Insert { text: String, range: Range }, 213 214 /// Insert a soft line break (Shift+Enter, `<br>` equivalent). 215 InsertLineBreak { range: Range }, 216 217 /// Insert a paragraph break (Enter). 218 InsertParagraph { range: Range }, 219 220 // === Deletion === 221 /// Delete content backward (Backspace). 222 DeleteBackward { range: Range }, 223 224 /// Delete content forward (Delete key). 225 DeleteForward { range: Range }, 226 227 /// Delete word backward (Ctrl/Alt+Backspace). 228 DeleteWordBackward { range: Range }, 229 230 /// Delete word forward (Ctrl/Alt+Delete). 231 DeleteWordForward { range: Range }, 232 233 /// Delete to start of line (Cmd+Backspace on Mac). 234 DeleteToLineStart { range: Range }, 235 236 /// Delete to end of line (Cmd+Delete on Mac). 237 DeleteToLineEnd { range: Range }, 238 239 /// Delete to start of soft line (visual line in wrapped text). 240 DeleteSoftLineBackward { range: Range }, 241 242 /// Delete to end of soft line. 243 DeleteSoftLineForward { range: Range }, 244 245 // === History === 246 /// Undo the last change. 247 Undo, 248 249 /// Redo the last undone change. 250 Redo, 251 252 // === Formatting === 253 /// Toggle bold on selection. 254 ToggleBold, 255 256 /// Toggle italic on selection. 257 ToggleItalic, 258 259 /// Toggle inline code on selection. 260 ToggleCode, 261 262 /// Toggle strikethrough on selection. 263 ToggleStrikethrough, 264 265 /// Insert/wrap with link. 266 InsertLink, 267 268 // === Clipboard === 269 /// Cut selection to clipboard. 270 Cut, 271 272 /// Copy selection to clipboard. 273 Copy, 274 275 /// Paste from clipboard at range. 276 Paste { range: Range }, 277 278 /// Copy selection as rendered HTML. 279 CopyAsHtml, 280 281 // === Selection === 282 /// Select all content. 283 SelectAll, 284 285 // === Navigation === 286 /// Move cursor to position. 287 MoveCursor { offset: usize }, 288 289 /// Extend selection to position. 290 ExtendSelection { offset: usize }, 291} 292 293impl EditorAction { 294 /// Update the range in actions that use one. 295 pub fn with_range(self, range: Range) -> Self { 296 match self { 297 Self::Insert { text, .. } => Self::Insert { text, range }, 298 Self::InsertLineBreak { .. } => Self::InsertLineBreak { range }, 299 Self::InsertParagraph { .. } => Self::InsertParagraph { range }, 300 Self::DeleteBackward { .. } => Self::DeleteBackward { range }, 301 Self::DeleteForward { .. } => Self::DeleteForward { range }, 302 Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range }, 303 Self::DeleteWordForward { .. } => Self::DeleteWordForward { range }, 304 Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range }, 305 Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range }, 306 Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range }, 307 Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range }, 308 Self::Paste { .. } => Self::Paste { range }, 309 other => other, 310 } 311 } 312} 313 314/// Key values for keyboard input. 315/// 316/// Platform-agnostic key representation. Platform-specific code converts 317/// from native key events to this enum. 318#[derive(Debug, Clone, PartialEq, Eq, Hash)] 319pub enum Key { 320 /// A character key. 321 Character(SmolStr), 322 323 /// Unknown/unidentified key. 324 Unidentified, 325 326 // === Whitespace / editing === 327 Backspace, 328 Delete, 329 Enter, 330 Tab, 331 Escape, 332 Space, 333 Insert, 334 Clear, 335 336 // === Navigation === 337 ArrowLeft, 338 ArrowRight, 339 ArrowUp, 340 ArrowDown, 341 Home, 342 End, 343 PageUp, 344 PageDown, 345 346 // === Modifiers === 347 Alt, 348 AltGraph, 349 CapsLock, 350 Control, 351 Fn, 352 FnLock, 353 Meta, 354 NumLock, 355 ScrollLock, 356 Shift, 357 Symbol, 358 SymbolLock, 359 Hyper, 360 Super, 361 362 // === Function keys === 363 F1, 364 F2, 365 F3, 366 F4, 367 F5, 368 F6, 369 F7, 370 F8, 371 F9, 372 F10, 373 F11, 374 F12, 375 F13, 376 F14, 377 F15, 378 F16, 379 F17, 380 F18, 381 F19, 382 F20, 383 384 // === UI keys === 385 ContextMenu, 386 PrintScreen, 387 Pause, 388 Help, 389 390 // === Clipboard / editing commands === 391 Copy, 392 Cut, 393 Paste, 394 Undo, 395 Redo, 396 Find, 397 Select, 398 399 // === Media keys === 400 MediaPlayPause, 401 MediaStop, 402 MediaTrackNext, 403 MediaTrackPrevious, 404 AudioVolumeDown, 405 AudioVolumeUp, 406 AudioVolumeMute, 407 408 // === IME / composition === 409 Compose, 410 Convert, 411 NonConvert, 412 Dead, 413 414 // === CJK IME keys === 415 HangulMode, 416 HanjaMode, 417 JunjaMode, 418 Eisu, 419 Hankaku, 420 Hiragana, 421 HiraganaKatakana, 422 KanaMode, 423 KanjiMode, 424 Katakana, 425 Romaji, 426 Zenkaku, 427 ZenkakuHankaku, 428} 429 430impl Key { 431 /// Create a character key. 432 pub fn character(s: impl Into<SmolStr>) -> Self { 433 Self::Character(s.into()) 434 } 435 436 /// Check if this is a navigation key. 437 pub fn is_navigation(&self) -> bool { 438 matches!( 439 self, 440 Self::ArrowLeft 441 | Self::ArrowRight 442 | Self::ArrowUp 443 | Self::ArrowDown 444 | Self::Home 445 | Self::End 446 | Self::PageUp 447 | Self::PageDown 448 ) 449 } 450 451 /// Check if this is a modifier key. 452 pub fn is_modifier(&self) -> bool { 453 matches!( 454 self, 455 Self::Alt 456 | Self::AltGraph 457 | Self::CapsLock 458 | Self::Control 459 | Self::Fn 460 | Self::FnLock 461 | Self::Meta 462 | Self::NumLock 463 | Self::ScrollLock 464 | Self::Shift 465 | Self::Symbol 466 | Self::SymbolLock 467 | Self::Hyper 468 | Self::Super 469 ) 470 } 471} 472 473/// Modifier key state for a key combination. 474#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 475pub struct Modifiers { 476 pub ctrl: bool, 477 pub alt: bool, 478 pub shift: bool, 479 pub meta: bool, 480 pub hyper: bool, 481 pub super_: bool, 482} 483 484impl Modifiers { 485 pub const NONE: Self = Self { 486 ctrl: false, 487 alt: false, 488 shift: false, 489 meta: false, 490 hyper: false, 491 super_: false, 492 }; 493 494 pub const CTRL: Self = Self { 495 ctrl: true, 496 alt: false, 497 shift: false, 498 meta: false, 499 hyper: false, 500 super_: false, 501 }; 502 503 pub const ALT: Self = Self { 504 ctrl: false, 505 alt: true, 506 shift: false, 507 meta: false, 508 hyper: false, 509 super_: false, 510 }; 511 512 pub const SHIFT: Self = Self { 513 ctrl: false, 514 alt: false, 515 shift: true, 516 meta: false, 517 hyper: false, 518 super_: false, 519 }; 520 521 pub const META: Self = Self { 522 ctrl: false, 523 alt: false, 524 shift: false, 525 meta: true, 526 hyper: false, 527 super_: false, 528 }; 529 530 pub const HYPER: Self = Self { 531 ctrl: false, 532 alt: false, 533 shift: false, 534 meta: false, 535 hyper: true, 536 super_: false, 537 }; 538 539 pub const SUPER: Self = Self { 540 ctrl: false, 541 alt: false, 542 shift: false, 543 meta: false, 544 hyper: false, 545 super_: true, 546 }; 547 548 pub const CTRL_SHIFT: Self = Self { 549 ctrl: true, 550 alt: false, 551 shift: true, 552 meta: false, 553 hyper: false, 554 super_: false, 555 }; 556 557 pub const META_SHIFT: Self = Self { 558 ctrl: false, 559 alt: false, 560 shift: true, 561 meta: true, 562 hyper: false, 563 super_: false, 564 }; 565 566 /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere). 567 pub fn primary(is_mac: bool) -> Self { 568 if is_mac { 569 Self::META 570 } else { 571 Self::CTRL 572 } 573 } 574 575 /// Get the primary modifier + Shift for the platform. 576 pub fn primary_shift(is_mac: bool) -> Self { 577 if is_mac { 578 Self::META_SHIFT 579 } else { 580 Self::CTRL_SHIFT 581 } 582 } 583} 584 585/// A key combination for triggering an action. 586#[derive(Debug, Clone, PartialEq, Eq, Hash)] 587pub struct KeyCombo { 588 pub key: Key, 589 pub modifiers: Modifiers, 590} 591 592impl KeyCombo { 593 pub fn new(key: Key) -> Self { 594 Self { 595 key, 596 modifiers: Modifiers::NONE, 597 } 598 } 599 600 pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self { 601 Self { key, modifiers } 602 } 603 604 pub fn ctrl(key: Key) -> Self { 605 Self { 606 key, 607 modifiers: Modifiers::CTRL, 608 } 609 } 610 611 pub fn meta(key: Key) -> Self { 612 Self { 613 key, 614 modifiers: Modifiers::META, 615 } 616 } 617 618 pub fn shift(key: Key) -> Self { 619 Self { 620 key, 621 modifiers: Modifiers::SHIFT, 622 } 623 } 624 625 pub fn primary(key: Key, is_mac: bool) -> Self { 626 Self { 627 key, 628 modifiers: Modifiers::primary(is_mac), 629 } 630 } 631 632 pub fn primary_shift(key: Key, is_mac: bool) -> Self { 633 Self { 634 key, 635 modifiers: Modifiers::primary_shift(is_mac), 636 } 637 } 638} 639 640/// Result of handling a keydown event. 641#[derive(Debug, Clone, PartialEq)] 642pub enum KeydownResult { 643 /// Event was handled, prevent default. 644 Handled, 645 /// Event was not a keybinding, let platform handle it. 646 NotHandled, 647 /// Event should be passed through (navigation, etc.). 648 PassThrough, 649} 650 651// === Keybinding configuration === 652 653use std::collections::HashMap; 654 655/// Keybinding configuration for the editor. 656/// 657/// Maps key combinations to editor actions. Platform-specific defaults 658/// can be created via `default_for_platform`. 659#[derive(Debug, Clone)] 660pub struct KeybindingConfig { 661 bindings: HashMap<KeyCombo, EditorAction>, 662} 663 664impl Default for KeybindingConfig { 665 fn default() -> Self { 666 Self::default_for_platform(false) 667 } 668} 669 670impl KeybindingConfig { 671 /// Create an empty keybinding configuration. 672 pub fn new() -> Self { 673 Self { 674 bindings: HashMap::new(), 675 } 676 } 677 678 /// Create default keybindings for the given platform. 679 /// 680 /// `is_mac` determines whether to use Cmd (true) or Ctrl (false) for shortcuts. 681 pub fn default_for_platform(is_mac: bool) -> Self { 682 let mut bindings = HashMap::new(); 683 684 // === Formatting === 685 bindings.insert( 686 KeyCombo::primary(Key::character("b"), is_mac), 687 EditorAction::ToggleBold, 688 ); 689 bindings.insert( 690 KeyCombo::primary(Key::character("i"), is_mac), 691 EditorAction::ToggleItalic, 692 ); 693 bindings.insert( 694 KeyCombo::primary(Key::character("e"), is_mac), 695 EditorAction::CopyAsHtml, 696 ); 697 698 // === History === 699 bindings.insert( 700 KeyCombo::primary(Key::character("z"), is_mac), 701 EditorAction::Undo, 702 ); 703 704 // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere 705 if is_mac { 706 bindings.insert( 707 KeyCombo::primary_shift(Key::character("Z"), is_mac), 708 EditorAction::Redo, 709 ); 710 } else { 711 bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo); 712 bindings.insert( 713 KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT), 714 EditorAction::Redo, 715 ); 716 } 717 718 // === Selection === 719 // Let browser handle Ctrl+A/Cmd+A natively - onselectionchange syncs to our state 720 // bindings.insert( 721 // KeyCombo::primary(Key::character("a"), is_mac), 722 // EditorAction::SelectAll, 723 // ); 724 725 // === Line deletion === 726 if is_mac { 727 bindings.insert( 728 KeyCombo::meta(Key::Backspace), 729 EditorAction::DeleteToLineStart { 730 range: Range::caret(0), 731 }, 732 ); 733 bindings.insert( 734 KeyCombo::meta(Key::Delete), 735 EditorAction::DeleteToLineEnd { 736 range: Range::caret(0), 737 }, 738 ); 739 } 740 741 // === Enter behaviour === 742 // Enter = soft break (single newline) 743 bindings.insert( 744 KeyCombo::new(Key::Enter), 745 EditorAction::InsertLineBreak { 746 range: Range::caret(0), 747 }, 748 ); 749 // Shift+Enter = paragraph break (double newline) 750 bindings.insert( 751 KeyCombo::shift(Key::Enter), 752 EditorAction::InsertParagraph { 753 range: Range::caret(0), 754 }, 755 ); 756 757 // === Dedicated keys === 758 bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo); 759 bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo); 760 bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy); 761 bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut); 762 bindings.insert( 763 KeyCombo::new(Key::Paste), 764 EditorAction::Paste { 765 range: Range::caret(0), 766 }, 767 ); 768 // bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 769 770 Self { bindings } 771 } 772 773 /// Look up an action for the given key combo. 774 /// 775 /// The range in the returned action is updated to the provided range. 776 /// Character keys are normalized to lowercase for matching (browsers report 777 /// uppercase when modifiers like Ctrl are held). 778 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> { 779 // Try exact match first 780 if let Some(action) = self.bindings.get(combo) { 781 return Some(action.clone().with_range(range)); 782 } 783 784 // For character keys, try lowercase version (browsers report "A" when Ctrl+A) 785 if let Key::Character(ref s) = combo.key { 786 let lower = s.to_lowercase(); 787 if lower != s.as_str() { 788 let normalized = KeyCombo { 789 key: Key::Character(lower.into()), 790 modifiers: combo.modifiers, 791 }; 792 if let Some(action) = self.bindings.get(&normalized) { 793 return Some(action.clone().with_range(range)); 794 } 795 } 796 } 797 798 None 799 } 800 801 /// Add or replace a keybinding. 802 pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) { 803 self.bindings.insert(combo, action); 804 } 805 806 /// Remove a keybinding. 807 pub fn unbind(&mut self, combo: &KeyCombo) { 808 self.bindings.remove(combo); 809 } 810 811 /// Check if a key combo has a binding. 812 pub fn has_binding(&self, combo: &KeyCombo) -> bool { 813 self.bindings.contains_key(combo) 814 } 815 816 /// Iterate over all bindings. 817 pub fn iter(&self) -> impl Iterator<Item = (&KeyCombo, &EditorAction)> { 818 self.bindings.iter() 819 } 820}