atproto blogging
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}