diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs
new file mode 100644
index 0000000..5c238c3
--- /dev/null
+++ b/crates/weaver-app/src/components/editor/actions.rs
@@ -0,0 +1,1264 @@
+//! Editor actions and keybinding system.
+//!
+//! This module defines all editor operations as an enum, providing a clean
+//! abstraction layer between input events and document mutations. This enables:
+//!
+//! - Configurable keybindings (user can remap shortcuts)
+//! - Platform-specific defaults (Cmd vs Ctrl, etc.)
+//! - Consistent action handling regardless of input source
+//! - Potential for command palette, macros, etc.
+
+use std::collections::HashMap;
+
+use dioxus::prelude::*;
+use jacquard::smol_str::SmolStr;
+
+use super::document::EditorDocument;
+use super::platform::Platform;
+
+/// A range in the document, measured in character offsets.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Range {
+ pub start: usize,
+ pub end: usize,
+}
+
+impl Range {
+ pub fn new(start: usize, end: usize) -> Self {
+ Self { start, end }
+ }
+
+ pub fn caret(offset: usize) -> Self {
+ Self {
+ start: offset,
+ end: offset,
+ }
+ }
+
+ pub fn is_caret(&self) -> bool {
+ self.start == self.end
+ }
+
+ pub fn len(&self) -> usize {
+ self.end.saturating_sub(self.start)
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ /// Normalize range so start <= end.
+ pub fn normalize(self) -> Self {
+ if self.start <= self.end {
+ self
+ } else {
+ Self {
+ start: self.end,
+ end: self.start,
+ }
+ }
+ }
+}
+
+/// All possible editor actions.
+///
+/// These represent semantic operations on the document, decoupled from
+/// how they're triggered (keyboard, mouse, touch, voice, etc.).
+#[derive(Debug, Clone, PartialEq)]
+pub enum EditorAction {
+ // === Text Insertion ===
+ /// Insert text at the given range (replacing any selected content).
+ Insert { text: String, range: Range },
+
+ /// Insert a soft line break (Shift+Enter, `
` equivalent).
+ InsertLineBreak { range: Range },
+
+ /// Insert a paragraph break (Enter).
+ InsertParagraph { range: Range },
+
+ // === Deletion ===
+ /// Delete content backward (Backspace).
+ DeleteBackward { range: Range },
+
+ /// Delete content forward (Delete key).
+ DeleteForward { range: Range },
+
+ /// Delete word backward (Ctrl/Alt+Backspace).
+ DeleteWordBackward { range: Range },
+
+ /// Delete word forward (Ctrl/Alt+Delete).
+ DeleteWordForward { range: Range },
+
+ /// Delete to start of line (Cmd+Backspace on Mac).
+ DeleteToLineStart { range: Range },
+
+ /// Delete to end of line (Cmd+Delete on Mac).
+ DeleteToLineEnd { range: Range },
+
+ /// Delete to start of soft line (visual line in wrapped text).
+ DeleteSoftLineBackward { range: Range },
+
+ /// Delete to end of soft line.
+ DeleteSoftLineForward { range: Range },
+
+ // === History ===
+ /// Undo the last change.
+ Undo,
+
+ /// Redo the last undone change.
+ Redo,
+
+ // === Formatting ===
+ /// Toggle bold on selection.
+ ToggleBold,
+
+ /// Toggle italic on selection.
+ ToggleItalic,
+
+ /// Toggle inline code on selection.
+ ToggleCode,
+
+ /// Toggle strikethrough on selection.
+ ToggleStrikethrough,
+
+ /// Insert/wrap with link.
+ InsertLink,
+
+ // === Clipboard ===
+ /// Cut selection to clipboard.
+ Cut,
+
+ /// Copy selection to clipboard.
+ Copy,
+
+ /// Paste from clipboard at range.
+ Paste { range: Range },
+
+ /// Copy selection as rendered HTML.
+ CopyAsHtml,
+
+ // === Selection ===
+ /// Select all content.
+ SelectAll,
+
+ // === Navigation (for command palette / programmatic use) ===
+ /// Move cursor to position.
+ MoveCursor { offset: usize },
+
+ /// Extend selection to position.
+ ExtendSelection { offset: usize },
+}
+
+impl EditorAction {
+ /// Update the range in actions that use one.
+ /// Actions without a range are returned unchanged.
+ pub fn with_range(self, range: Range) -> Self {
+ match self {
+ Self::Insert { text, .. } => Self::Insert { text, range },
+ Self::InsertLineBreak { .. } => Self::InsertLineBreak { range },
+ Self::InsertParagraph { .. } => Self::InsertParagraph { range },
+ Self::DeleteBackward { .. } => Self::DeleteBackward { range },
+ Self::DeleteForward { .. } => Self::DeleteForward { range },
+ Self::DeleteWordBackward { .. } => Self::DeleteWordBackward { range },
+ Self::DeleteWordForward { .. } => Self::DeleteWordForward { range },
+ Self::DeleteToLineStart { .. } => Self::DeleteToLineStart { range },
+ Self::DeleteToLineEnd { .. } => Self::DeleteToLineEnd { range },
+ Self::DeleteSoftLineBackward { .. } => Self::DeleteSoftLineBackward { range },
+ Self::DeleteSoftLineForward { .. } => Self::DeleteSoftLineForward { range },
+ Self::Paste { .. } => Self::Paste { range },
+ // Actions without range stay unchanged
+ other => other,
+ }
+ }
+}
+
+/// Key values for keyboard input.
+///
+/// Mirrors the keyboard-types crate's Key enum structure. Character keys use
+/// SmolStr to efficiently handle both single characters and composed sequences
+/// (from dead keys, IME, etc.).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum Key {
+ /// A character key. The string corresponds to the character typed,
+ /// taking into account locale, modifiers, and keyboard mapping.
+ /// May be multiple characters for composed sequences.
+ Character(SmolStr),
+
+ /// Unknown/unidentified key.
+ Unidentified,
+
+ // === Whitespace / editing ===
+ Backspace,
+ Delete,
+ Enter,
+ Tab,
+ Escape,
+ Space,
+ Insert,
+ Clear,
+
+ // === Navigation ===
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ ArrowDown,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+
+ // === Modifiers ===
+ Alt,
+ AltGraph,
+ CapsLock,
+ Control,
+ Fn,
+ FnLock,
+ Meta,
+ NumLock,
+ ScrollLock,
+ Shift,
+ Symbol,
+ SymbolLock,
+ Hyper,
+ Super,
+
+ // === Function keys ===
+ F1,
+ F2,
+ F3,
+ F4,
+ F5,
+ F6,
+ F7,
+ F8,
+ F9,
+ F10,
+ F11,
+ F12,
+ F13,
+ F14,
+ F15,
+ F16,
+ F17,
+ F18,
+ F19,
+ F20,
+
+ // === UI keys ===
+ ContextMenu,
+ PrintScreen,
+ Pause,
+ Help,
+
+ // === Clipboard / editing commands ===
+ Copy,
+ Cut,
+ Paste,
+ Undo,
+ Redo,
+ Find,
+ Select,
+
+ // === Media keys ===
+ MediaPlayPause,
+ MediaStop,
+ MediaTrackNext,
+ MediaTrackPrevious,
+ AudioVolumeDown,
+ AudioVolumeUp,
+ AudioVolumeMute,
+
+ // === IME / composition ===
+ Compose,
+ Convert,
+ NonConvert,
+ Dead,
+
+ // === CJK IME keys ===
+ HangulMode,
+ HanjaMode,
+ JunjaMode,
+ Eisu,
+ Hankaku,
+ Hiragana,
+ HiraganaKatakana,
+ KanaMode,
+ KanjiMode,
+ Katakana,
+ Romaji,
+ Zenkaku,
+ ZenkakuHankaku,
+}
+
+impl Key {
+ /// Create a character key from a string.
+ pub fn character(s: impl Into) -> Self {
+ Self::Character(s.into())
+ }
+
+ /// Convert from a dioxus keyboard_types::Key.
+ pub fn from_keyboard_types(key: dioxus::prelude::keyboard_types::Key) -> Self {
+ use dioxus::prelude::keyboard_types::Key as KT;
+
+ match key {
+ KT::Character(s) => Self::Character(s.as_str().into()),
+ KT::Unidentified => Self::Unidentified,
+
+ // Whitespace / editing
+ KT::Backspace => Self::Backspace,
+ KT::Delete => Self::Delete,
+ KT::Enter => Self::Enter,
+ KT::Tab => Self::Tab,
+ KT::Escape => Self::Escape,
+ KT::Insert => Self::Insert,
+ KT::Clear => Self::Clear,
+
+ // Navigation
+ KT::ArrowLeft => Self::ArrowLeft,
+ KT::ArrowRight => Self::ArrowRight,
+ KT::ArrowUp => Self::ArrowUp,
+ KT::ArrowDown => Self::ArrowDown,
+ KT::Home => Self::Home,
+ KT::End => Self::End,
+ KT::PageUp => Self::PageUp,
+ KT::PageDown => Self::PageDown,
+
+ // Modifiers
+ KT::Alt => Self::Alt,
+ KT::AltGraph => Self::AltGraph,
+ KT::CapsLock => Self::CapsLock,
+ KT::Control => Self::Control,
+ KT::Fn => Self::Fn,
+ KT::FnLock => Self::FnLock,
+ KT::Meta => Self::Meta,
+ KT::NumLock => Self::NumLock,
+ KT::ScrollLock => Self::ScrollLock,
+ KT::Shift => Self::Shift,
+ KT::Symbol => Self::Symbol,
+ KT::SymbolLock => Self::SymbolLock,
+ KT::Hyper => Self::Hyper,
+ KT::Super => Self::Super,
+
+ // Function keys
+ KT::F1 => Self::F1,
+ KT::F2 => Self::F2,
+ KT::F3 => Self::F3,
+ KT::F4 => Self::F4,
+ KT::F5 => Self::F5,
+ KT::F6 => Self::F6,
+ KT::F7 => Self::F7,
+ KT::F8 => Self::F8,
+ KT::F9 => Self::F9,
+ KT::F10 => Self::F10,
+ KT::F11 => Self::F11,
+ KT::F12 => Self::F12,
+ KT::F13 => Self::F13,
+ KT::F14 => Self::F14,
+ KT::F15 => Self::F15,
+ KT::F16 => Self::F16,
+ KT::F17 => Self::F17,
+ KT::F18 => Self::F18,
+ KT::F19 => Self::F19,
+ KT::F20 => Self::F20,
+
+ // UI keys
+ KT::ContextMenu => Self::ContextMenu,
+ KT::PrintScreen => Self::PrintScreen,
+ KT::Pause => Self::Pause,
+ KT::Help => Self::Help,
+
+ // Clipboard / editing commands
+ KT::Copy => Self::Copy,
+ KT::Cut => Self::Cut,
+ KT::Paste => Self::Paste,
+ KT::Undo => Self::Undo,
+ KT::Redo => Self::Redo,
+ KT::Find => Self::Find,
+ KT::Select => Self::Select,
+
+ // Media keys
+ KT::MediaPlayPause => Self::MediaPlayPause,
+ KT::MediaStop => Self::MediaStop,
+ KT::MediaTrackNext => Self::MediaTrackNext,
+ KT::MediaTrackPrevious => Self::MediaTrackPrevious,
+ KT::AudioVolumeDown => Self::AudioVolumeDown,
+ KT::AudioVolumeUp => Self::AudioVolumeUp,
+ KT::AudioVolumeMute => Self::AudioVolumeMute,
+
+ // IME / composition
+ KT::Compose => Self::Compose,
+ KT::Convert => Self::Convert,
+ KT::NonConvert => Self::NonConvert,
+ KT::Dead => Self::Dead,
+
+ // CJK IME keys
+ KT::HangulMode => Self::HangulMode,
+ KT::HanjaMode => Self::HanjaMode,
+ KT::JunjaMode => Self::JunjaMode,
+ KT::Eisu => Self::Eisu,
+ KT::Hankaku => Self::Hankaku,
+ KT::Hiragana => Self::Hiragana,
+ KT::HiraganaKatakana => Self::HiraganaKatakana,
+ KT::KanaMode => Self::KanaMode,
+ KT::KanjiMode => Self::KanjiMode,
+ KT::Katakana => Self::Katakana,
+ KT::Romaji => Self::Romaji,
+ KT::Zenkaku => Self::Zenkaku,
+ KT::ZenkakuHankaku => Self::ZenkakuHankaku,
+
+ // Everything else falls through to Unidentified
+ _ => Self::Unidentified,
+ }
+ }
+
+ /// Check if this is a navigation key that should pass through to browser.
+ pub fn is_navigation(&self) -> bool {
+ matches!(
+ self,
+ Self::ArrowLeft
+ | Self::ArrowRight
+ | Self::ArrowUp
+ | Self::ArrowDown
+ | Self::Home
+ | Self::End
+ | Self::PageUp
+ | Self::PageDown
+ )
+ }
+
+ /// Check if this is a modifier key.
+ pub fn is_modifier(&self) -> bool {
+ matches!(
+ self,
+ Self::Alt
+ | Self::AltGraph
+ | Self::CapsLock
+ | Self::Control
+ | Self::Fn
+ | Self::FnLock
+ | Self::Meta
+ | Self::NumLock
+ | Self::ScrollLock
+ | Self::Shift
+ | Self::Symbol
+ | Self::SymbolLock
+ | Self::Hyper
+ | Self::Super
+ )
+ }
+}
+
+/// Modifier key state for a key combination.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
+pub struct Modifiers {
+ pub ctrl: bool,
+ pub alt: bool,
+ pub shift: bool,
+ pub meta: bool,
+ pub hyper: bool,
+ pub super_: bool, // `super` is a keyword
+}
+
+impl Modifiers {
+ pub const NONE: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: false,
+ meta: false,
+ hyper: false,
+ super_: false,
+ };
+ pub const CTRL: Self = Self {
+ ctrl: true,
+ alt: false,
+ shift: false,
+ meta: false,
+ hyper: false,
+ super_: false,
+ };
+ pub const ALT: Self = Self {
+ ctrl: false,
+ alt: true,
+ shift: false,
+ meta: false,
+ hyper: false,
+ super_: false,
+ };
+ pub const SHIFT: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: true,
+ meta: false,
+ hyper: false,
+ super_: false,
+ };
+ pub const META: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: false,
+ meta: true,
+ hyper: false,
+ super_: false,
+ };
+ pub const HYPER: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: false,
+ meta: false,
+ hyper: true,
+ super_: false,
+ };
+ pub const SUPER: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: false,
+ meta: false,
+ hyper: false,
+ super_: true,
+ };
+ pub const CTRL_SHIFT: Self = Self {
+ ctrl: true,
+ alt: false,
+ shift: true,
+ meta: false,
+ hyper: false,
+ super_: false,
+ };
+ pub const META_SHIFT: Self = Self {
+ ctrl: false,
+ alt: false,
+ shift: true,
+ meta: true,
+ hyper: false,
+ super_: false,
+ };
+
+ /// Get the platform's primary modifier (Cmd on Mac, Ctrl elsewhere).
+ pub fn cmd_or_ctrl(platform: &Platform) -> Self {
+ if platform.mac { Self::META } else { Self::CTRL }
+ }
+
+ /// Get the platform's primary modifier + Shift.
+ pub fn cmd_or_ctrl_shift(platform: &Platform) -> Self {
+ if platform.mac {
+ Self::META_SHIFT
+ } else {
+ Self::CTRL_SHIFT
+ }
+ }
+}
+
+/// A key combination for triggering an action.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct KeyCombo {
+ pub key: Key,
+ pub modifiers: Modifiers,
+}
+
+impl KeyCombo {
+ pub fn new(key: Key) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::NONE,
+ }
+ }
+
+ pub fn with_modifiers(key: Key, modifiers: Modifiers) -> Self {
+ Self { key, modifiers }
+ }
+
+ pub fn ctrl(key: Key) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::CTRL,
+ }
+ }
+
+ pub fn meta(key: Key) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::META,
+ }
+ }
+
+ pub fn shift(key: Key) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::SHIFT,
+ }
+ }
+
+ pub fn cmd_or_ctrl(key: Key, platform: &Platform) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::cmd_or_ctrl(platform),
+ }
+ }
+
+ pub fn cmd_or_ctrl_shift(key: Key, platform: &Platform) -> Self {
+ Self {
+ key,
+ modifiers: Modifiers::cmd_or_ctrl_shift(platform),
+ }
+ }
+
+ /// Create a KeyCombo from a dioxus keyboard event.
+ pub fn from_keyboard_event(event: &dioxus::events::KeyboardData) -> Self {
+ let key = Key::from_keyboard_types(event.key());
+ let modifiers = Modifiers {
+ ctrl: event.modifiers().ctrl(),
+ alt: event.modifiers().alt(),
+ shift: event.modifiers().shift(),
+ meta: event.modifiers().meta(),
+ // dioxus doesn't expose hyper/super separately, they typically map to meta
+ hyper: false,
+ super_: false,
+ };
+ Self { key, modifiers }
+ }
+}
+
+/// Keybinding configuration for the editor.
+///
+/// Uses a HashMap for O(1) keybinding lookup.
+#[derive(Debug, Clone)]
+pub struct KeybindingConfig {
+ bindings: HashMap,
+}
+
+impl KeybindingConfig {
+ /// Create default keybindings for the given platform.
+ pub fn default_for_platform(platform: &Platform) -> Self {
+ let mut bindings = HashMap::new();
+
+ // === Formatting ===
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl(Key::character("b"), platform),
+ EditorAction::ToggleBold,
+ );
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl(Key::character("i"), platform),
+ EditorAction::ToggleItalic,
+ );
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl(Key::character("e"), platform),
+ EditorAction::CopyAsHtml,
+ );
+
+ // === History ===
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl(Key::character("z"), platform),
+ EditorAction::Undo,
+ );
+
+ // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere
+ if platform.mac {
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl_shift(Key::character("Z"), platform),
+ EditorAction::Redo,
+ );
+ } else {
+ bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo);
+ bindings.insert(
+ KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT),
+ EditorAction::Redo,
+ );
+ }
+
+ // === Selection ===
+ bindings.insert(
+ KeyCombo::cmd_or_ctrl(Key::character("a"), platform),
+ EditorAction::SelectAll,
+ );
+
+ // === Line deletion ===
+ if platform.mac {
+ bindings.insert(
+ KeyCombo::meta(Key::Backspace),
+ EditorAction::DeleteToLineStart {
+ range: Range::caret(0),
+ },
+ );
+ bindings.insert(
+ KeyCombo::meta(Key::Delete),
+ EditorAction::DeleteToLineEnd {
+ range: Range::caret(0),
+ },
+ );
+ }
+
+ // === Enter behaviour ===
+ // Enter = soft break (single newline)
+ bindings.insert(
+ KeyCombo::new(Key::Enter),
+ EditorAction::InsertLineBreak {
+ range: Range::caret(0),
+ },
+ );
+ // Shift+Enter = paragraph break (double newline)
+ bindings.insert(
+ KeyCombo::shift(Key::Enter),
+ EditorAction::InsertParagraph {
+ range: Range::caret(0),
+ },
+ );
+
+ // === Dedicated editing keys (for custom keyboards, etc.) ===
+ bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo);
+ bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo);
+ bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy);
+ bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut);
+ bindings.insert(
+ KeyCombo::new(Key::Paste),
+ EditorAction::Paste {
+ range: Range::caret(0),
+ },
+ );
+ bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll);
+
+ Self { bindings }
+ }
+
+ /// Look up an action for the given key combo, with the current range applied.
+ pub fn lookup(&self, combo: KeyCombo, range: Range) -> Option {
+ self.bindings.get(&combo).cloned().map(|a| a.with_range(range))
+ }
+
+ /// Look up an action for the given key and modifiers, with the current range applied.
+ pub fn lookup_key(&self, key: Key, modifiers: Modifiers, range: Range) -> Option {
+ self.lookup(KeyCombo::with_modifiers(key, modifiers), range)
+ }
+
+ /// Add or replace a keybinding.
+ pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
+ self.bindings.insert(combo, action);
+ }
+
+ /// Remove a keybinding.
+ pub fn unbind(&mut self, combo: KeyCombo) {
+ self.bindings.remove(&combo);
+ }
+}
+
+/// Execute an editor action on a document.
+///
+/// This is the central dispatch point for all editor operations.
+/// Returns true if the action was handled and the document was modified.
+pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool {
+ use super::formatting::{self, FormatAction};
+ use super::input::{
+ detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty,
+ };
+ use super::offset_map::SnapDirection;
+
+ match action {
+ EditorAction::Insert { text, range } => {
+ let range = range.normalize();
+ if range.is_caret() {
+ // Simple insert
+ let offset = range.start;
+
+ // Clean up any preceding zero-width chars
+ let mut delete_start = offset;
+ while delete_start > 0 {
+ match get_char_at(doc.loro_text(), delete_start - 1) {
+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
+ _ => break,
+ }
+ }
+
+ let zw_count = offset - delete_start;
+ if zw_count > 0 {
+ let _ = doc.replace_tracked(delete_start, zw_count, text);
+ doc.cursor.write().offset = delete_start + text.chars().count();
+ } else if offset == doc.len_chars() {
+ let _ = doc.push_tracked(text);
+ doc.cursor.write().offset = offset + text.chars().count();
+ } else {
+ let _ = doc.insert_tracked(offset, text);
+ doc.cursor.write().offset = offset + text.chars().count();
+ }
+ } else {
+ // Replace range
+ let _ = doc.replace_tracked(range.start, range.len(), text);
+ doc.cursor.write().offset = range.start + text.chars().count();
+ }
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::InsertLineBreak { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Forward));
+
+ if !range.is_caret() {
+ let _ = doc.remove_tracked(range.start, range.len());
+ }
+
+ let mut offset = range.start;
+
+ // Check if we're right after a soft break (newline + zero-width char).
+ // If so, convert to paragraph break by replacing the zero-width char
+ // with a newline.
+ let mut is_double_enter = false;
+ if offset >= 2 {
+ let prev_char = get_char_at(doc.loro_text(), offset - 1);
+ let prev_prev_char = get_char_at(doc.loro_text(), offset - 2);
+ if prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') {
+ // Replace zero-width char with newline
+ let _ = doc.replace_tracked(offset - 1, 1, "\n");
+ doc.cursor.write().offset = offset;
+ is_double_enter = true;
+ }
+ }
+
+ if !is_double_enter {
+ // Normal soft break: insert newline + zero-width char for cursor positioning.
+ // The renderer emits
for soft breaks, so we don't need
+ // trailing spaces for visual line breaks.
+ let _ = doc.insert_tracked(offset, "\n\u{200C}");
+ doc.cursor.write().offset = offset + 2;
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::InsertParagraph { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Forward));
+
+ if !range.is_caret() {
+ let _ = doc.remove_tracked(range.start, range.len());
+ }
+
+ let cursor_offset = range.start;
+
+ // Check for list context
+ if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) {
+ if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) {
+ // Empty item - exit list
+ let line_start = find_line_start(doc.loro_text(), cursor_offset);
+ let line_end = find_line_end(doc.loro_text(), cursor_offset);
+ let delete_end = (line_end + 1).min(doc.len_chars());
+
+ let _ = doc.replace_tracked(
+ line_start,
+ delete_end.saturating_sub(line_start),
+ "\n\n\u{200C}\n",
+ );
+ doc.cursor.write().offset = line_start + 2;
+ } else {
+ // Continue list
+ let continuation = match ctx {
+ super::input::ListContext::Unordered { indent, marker } => {
+ format!("\n{}{} ", indent, marker)
+ }
+ super::input::ListContext::Ordered { indent, number } => {
+ format!("\n{}{}. ", indent, number + 1)
+ }
+ };
+ let len = continuation.chars().count();
+ let _ = doc.insert_tracked(cursor_offset, &continuation);
+ doc.cursor.write().offset = cursor_offset + len;
+ }
+ } else {
+ // Normal paragraph break
+ let _ = doc.insert_tracked(cursor_offset, "\n\n");
+ doc.cursor.write().offset = cursor_offset + 2;
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteBackward { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Backward));
+
+ if !range.is_caret() {
+ // Delete selection
+ let _ = doc.remove_tracked(range.start, range.len());
+ doc.cursor.write().offset = range.start;
+ } else if range.start > 0 {
+ let cursor_offset = range.start;
+ let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1);
+
+ if prev_char == Some('\n') {
+ // Deleting a newline - handle paragraph merging
+ let newline_pos = cursor_offset - 1;
+ let mut delete_start = newline_pos;
+ let mut delete_end = cursor_offset;
+
+ // Check for empty paragraph (double newline)
+ if newline_pos > 0 {
+ if get_char_at(doc.loro_text(), newline_pos - 1) == Some('\n') {
+ delete_start = newline_pos - 1;
+ }
+ }
+
+ // Check for trailing zero-width char
+ if let Some(ch) = get_char_at(doc.loro_text(), delete_end) {
+ if ch == '\u{200C}' || ch == '\u{200B}' {
+ delete_end += 1;
+ }
+ }
+
+ // Scan backwards through zero-width chars
+ while delete_start > 0 {
+ match get_char_at(doc.loro_text(), delete_start - 1) {
+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
+ Some('\n') | _ => break,
+ }
+ }
+
+ let _ =
+ doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start));
+ doc.cursor.write().offset = delete_start;
+ } else {
+ // Normal single char delete
+ let _ = doc.remove_tracked(cursor_offset - 1, 1);
+ doc.cursor.write().offset = cursor_offset - 1;
+ }
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteForward { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Forward));
+
+ if !range.is_caret() {
+ let _ = doc.remove_tracked(range.start, range.len());
+ doc.cursor.write().offset = range.start;
+ } else if range.start < doc.len_chars() {
+ let _ = doc.remove_tracked(range.start, 1);
+ // Cursor stays at same position
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteWordBackward { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Backward));
+
+ if !range.is_caret() {
+ let _ = doc.remove_tracked(range.start, range.len());
+ doc.cursor.write().offset = range.start;
+ } else {
+ // Find word boundary backwards
+ let cursor = range.start;
+ let word_start = find_word_boundary_backward(doc, cursor);
+ if word_start < cursor {
+ let _ = doc.remove_tracked(word_start, cursor - word_start);
+ doc.cursor.write().offset = word_start;
+ }
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteWordForward { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Forward));
+
+ if !range.is_caret() {
+ let _ = doc.remove_tracked(range.start, range.len());
+ doc.cursor.write().offset = range.start;
+ } else {
+ // Find word boundary forward
+ let cursor = range.start;
+ let word_end = find_word_boundary_forward(doc, cursor);
+ if word_end > cursor {
+ let _ = doc.remove_tracked(cursor, word_end - cursor);
+ }
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteToLineStart { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Backward));
+
+ let cursor = if range.is_caret() {
+ range.start
+ } else {
+ range.start
+ };
+ let line_start = find_line_start(doc.loro_text(), cursor);
+
+ if line_start < cursor {
+ let _ = doc.remove_tracked(line_start, cursor - line_start);
+ doc.cursor.write().offset = line_start;
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteToLineEnd { range } => {
+ let range = range.normalize();
+ doc.pending_snap.set(Some(SnapDirection::Forward));
+
+ let cursor = if range.is_caret() {
+ range.start
+ } else {
+ range.end
+ };
+ let line_end = find_line_end(doc.loro_text(), cursor);
+
+ if cursor < line_end {
+ let _ = doc.remove_tracked(cursor, line_end - cursor);
+ }
+
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::DeleteSoftLineBackward { range } => {
+ // For now, treat same as DeleteToLineStart
+ // TODO: Handle visual line wrapping if needed
+ execute_action(doc, &EditorAction::DeleteToLineStart { range: *range })
+ }
+
+ EditorAction::DeleteSoftLineForward { range } => {
+ // For now, treat same as DeleteToLineEnd
+ execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range })
+ }
+
+ EditorAction::Undo => {
+ if let Ok(true) = doc.undo() {
+ let max = doc.len_chars();
+ doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
+ doc.selection.set(None);
+ true
+ } else {
+ false
+ }
+ }
+
+ EditorAction::Redo => {
+ if let Ok(true) = doc.redo() {
+ let max = doc.len_chars();
+ doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
+ doc.selection.set(None);
+ true
+ } else {
+ false
+ }
+ }
+
+ EditorAction::ToggleBold => {
+ formatting::apply_formatting(doc, FormatAction::Bold);
+ true
+ }
+
+ EditorAction::ToggleItalic => {
+ formatting::apply_formatting(doc, FormatAction::Italic);
+ true
+ }
+
+ EditorAction::ToggleCode => {
+ formatting::apply_formatting(doc, FormatAction::Code);
+ true
+ }
+
+ EditorAction::ToggleStrikethrough => {
+ formatting::apply_formatting(doc, FormatAction::Strikethrough);
+ true
+ }
+
+ EditorAction::InsertLink => {
+ formatting::apply_formatting(doc, FormatAction::Link);
+ true
+ }
+
+ EditorAction::Cut => {
+ // Handled separately via clipboard events
+ false
+ }
+
+ EditorAction::Copy => {
+ // Handled separately via clipboard events
+ false
+ }
+
+ EditorAction::Paste { range: _ } => {
+ // Handled separately via clipboard events (needs async clipboard access)
+ false
+ }
+
+ EditorAction::CopyAsHtml => {
+ // Handled in component with async clipboard access
+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+ {
+ if let Some(sel) = *doc.selection.read() {
+ let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
+ if start != end {
+ if let Some(markdown) = doc.slice(start, end) {
+ let clean_md = markdown.replace('\u{200C}', "").replace('\u{200B}', "");
+ wasm_bindgen_futures::spawn_local(async move {
+ if let Err(e) = super::input::copy_as_html(&clean_md).await {
+ tracing::warn!("[COPY HTML] Failed: {:?}", e);
+ }
+ });
+ return true;
+ }
+ }
+ }
+ }
+ false
+ }
+
+ EditorAction::SelectAll => {
+ let len = doc.len_chars();
+ doc.selection.set(Some(super::document::Selection {
+ anchor: 0,
+ head: len,
+ }));
+ doc.cursor.write().offset = len;
+ true
+ }
+
+ EditorAction::MoveCursor { offset } => {
+ let offset = (*offset).min(doc.len_chars());
+ doc.cursor.write().offset = offset;
+ doc.selection.set(None);
+ true
+ }
+
+ EditorAction::ExtendSelection { offset } => {
+ let offset = (*offset).min(doc.len_chars());
+ let current_sel = *doc.selection.read();
+ let anchor = current_sel
+ .map(|s| s.anchor)
+ .unwrap_or(doc.cursor.read().offset);
+ doc.selection.set(Some(super::document::Selection {
+ anchor,
+ head: offset,
+ }));
+ doc.cursor.write().offset = offset;
+ true
+ }
+ }
+}
+
+/// Find word boundary backward from cursor.
+fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize {
+ use super::input::get_char_at;
+
+ if cursor == 0 {
+ return 0;
+ }
+
+ let mut pos = cursor;
+
+ // Skip any whitespace/punctuation immediately before cursor
+ while pos > 0 {
+ match get_char_at(doc.loro_text(), pos - 1) {
+ Some(c) if c.is_alphanumeric() || c == '_' => break,
+ Some(_) => pos -= 1,
+ None => break,
+ }
+ }
+
+ // Skip the word characters
+ while pos > 0 {
+ match get_char_at(doc.loro_text(), pos - 1) {
+ Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1,
+ _ => break,
+ }
+ }
+
+ pos
+}
+
+/// Find word boundary forward from cursor.
+fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize {
+ use super::input::get_char_at;
+
+ let len = doc.len_chars();
+ if cursor >= len {
+ return len;
+ }
+
+ let mut pos = cursor;
+
+ // Skip word characters first
+ while pos < len {
+ match get_char_at(doc.loro_text(), pos) {
+ Some(c) if c.is_alphanumeric() || c == '_' => pos += 1,
+ _ => break,
+ }
+ }
+
+ // Then skip whitespace/punctuation
+ while pos < len {
+ match get_char_at(doc.loro_text(), pos) {
+ Some(c) if c.is_alphanumeric() || c == '_' => break,
+ Some(_) => pos += 1,
+ None => break,
+ }
+ }
+
+ pos
+}
+
+/// Result of handling a keydown event.
+#[derive(Debug, Clone, PartialEq)]
+pub enum KeydownResult {
+ /// Event was handled, prevent default.
+ Handled,
+ /// Event was not a keybinding, let browser/beforeinput handle it.
+ NotHandled,
+ /// Event should be passed through (navigation, etc.).
+ PassThrough,
+}
+
+/// Handle a keydown event using the keybinding configuration.
+///
+/// This handles keyboard shortcuts only. Text input and deletion
+/// are handled by beforeinput. Navigation (arrows, etc.) is passed
+/// through to the browser.
+///
+/// # Arguments
+/// * `doc` - The editor document
+/// * `config` - Keybinding configuration
+/// * `combo` - The key combination from the keyboard event
+/// * `range` - Current cursor position / selection range
+///
+/// # Returns
+/// Whether the event was handled.
+pub fn handle_keydown_with_bindings(
+ doc: &mut EditorDocument,
+ config: &KeybindingConfig,
+ combo: KeyCombo,
+ range: Range,
+) -> KeydownResult {
+ // Look up keybinding (range is applied by lookup)
+ if let Some(action) = config.lookup(combo.clone(), range) {
+ execute_action(doc, &action);
+ return KeydownResult::Handled;
+ }
+
+ // No keybinding matched - check if this is navigation or content
+ if combo.key.is_navigation() {
+ return KeydownResult::PassThrough;
+ }
+
+ // Modifier-only keypresses should pass through
+ if combo.key.is_modifier() {
+ return KeydownResult::PassThrough;
+ }
+
+ // Content keys (typing, backspace, etc.) - let beforeinput handle
+ KeydownResult::NotHandled
+}
diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs
new file mode 100644
index 0000000..61f1ca1
--- /dev/null
+++ b/crates/weaver-app/src/components/editor/beforeinput.rs
@@ -0,0 +1,530 @@
+//! BeforeInput event handling for the editor.
+//!
+//! This module provides the primary input handling via the `beforeinput` event,
+//! which gives us semantic information about what the browser wants to do
+//! (insert text, delete backward, etc.) rather than raw key codes.
+//!
+//! ## Browser Support
+//!
+//! `beforeinput` is well-supported in modern browsers, but has quirks:
+//! - Android: `getTargetRanges()` can be unreliable during composition
+//! - Safari: Some input types may not fire or have wrong data
+//! - All: `isComposing` flag behavior varies
+//!
+//! We handle these with platform-specific workarounds inherited from the
+//! battle-tested patterns in ProseMirror.
+
+use dioxus::prelude::*;
+
+use super::actions::{EditorAction, Range, execute_action};
+use super::document::EditorDocument;
+use super::platform::Platform;
+
+// Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it.
+// StaticRange is returned by InputEvent.getTargetRanges() and represents
+// a fixed range that doesn't update when the DOM changes.
+// https://developer.mozilla.org/en-US/docs/Web/API/StaticRange
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+mod static_range {
+ use wasm_bindgen::prelude::*;
+
+ #[wasm_bindgen]
+ extern "C" {
+ /// The StaticRange interface represents a static range of text in the DOM.
+ pub type StaticRange;
+
+ #[wasm_bindgen(method, getter, structural)]
+ pub fn startContainer(this: &StaticRange) -> web_sys::Node;
+
+ #[wasm_bindgen(method, getter, structural)]
+ pub fn startOffset(this: &StaticRange) -> u32;
+
+ #[wasm_bindgen(method, getter, structural)]
+ pub fn endContainer(this: &StaticRange) -> web_sys::Node;
+
+ #[wasm_bindgen(method, getter, structural)]
+ pub fn endOffset(this: &StaticRange) -> u32;
+
+ #[wasm_bindgen(method, getter, structural)]
+ pub fn collapsed(this: &StaticRange) -> bool;
+ }
+}
+
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+pub use static_range::StaticRange;
+
+/// Input types from the beforeinput event.
+///
+/// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum InputType {
+ // === Insertion ===
+ /// Insert typed text.
+ InsertText,
+ /// Insert text from IME composition.
+ InsertCompositionText,
+ /// Insert a line break (`
`, Shift+Enter).
+ InsertLineBreak,
+ /// Insert a paragraph break (Enter).
+ InsertParagraph,
+ /// Insert from paste operation.
+ InsertFromPaste,
+ /// Insert from drop operation.
+ InsertFromDrop,
+ /// Insert replacement text (e.g., spell check correction).
+ InsertReplacementText,
+ /// Insert from voice input or other source.
+ InsertFromYank,
+ /// Insert a horizontal rule.
+ InsertHorizontalRule,
+ /// Insert an ordered list.
+ InsertOrderedList,
+ /// Insert an unordered list.
+ InsertUnorderedList,
+ /// Insert a link.
+ InsertLink,
+
+ // === Deletion ===
+ /// Delete content backward (Backspace).
+ DeleteContentBackward,
+ /// Delete content forward (Delete key).
+ DeleteContentForward,
+ /// Delete word backward (Ctrl/Alt+Backspace).
+ DeleteWordBackward,
+ /// Delete word forward (Ctrl/Alt+Delete).
+ DeleteWordForward,
+ /// Delete to soft line boundary backward.
+ DeleteSoftLineBackward,
+ /// Delete to soft line boundary forward.
+ DeleteSoftLineForward,
+ /// Delete to hard line boundary backward (Cmd+Backspace on Mac).
+ DeleteHardLineBackward,
+ /// Delete to hard line boundary forward (Cmd+Delete on Mac).
+ DeleteHardLineForward,
+ /// Delete by cut operation.
+ DeleteByCut,
+ /// Delete by drag operation.
+ DeleteByDrag,
+ /// Generic content deletion.
+ DeleteContent,
+ /// Delete entire word backward (Ctrl+W on some systems).
+ DeleteEntireWordBackward,
+ /// Delete entire word forward.
+ DeleteEntireWordForward,
+
+ // === History ===
+ /// Undo.
+ HistoryUndo,
+ /// Redo.
+ HistoryRedo,
+
+ // === Formatting (rarely used, most apps handle via shortcuts) ===
+ FormatBold,
+ FormatItalic,
+ FormatUnderline,
+ FormatStrikethrough,
+ FormatSuperscript,
+ FormatSubscript,
+
+ // === Unknown ===
+ /// Unrecognized input type.
+ Unknown(String),
+}
+
+impl InputType {
+ /// Parse from the browser's inputType string.
+ pub fn from_str(s: &str) -> Self {
+ match s {
+ // Insertion
+ "insertText" => Self::InsertText,
+ "insertCompositionText" => Self::InsertCompositionText,
+ "insertLineBreak" => Self::InsertLineBreak,
+ "insertParagraph" => Self::InsertParagraph,
+ "insertFromPaste" => Self::InsertFromPaste,
+ "insertFromDrop" => Self::InsertFromDrop,
+ "insertReplacementText" => Self::InsertReplacementText,
+ "insertFromYank" => Self::InsertFromYank,
+ "insertHorizontalRule" => Self::InsertHorizontalRule,
+ "insertOrderedList" => Self::InsertOrderedList,
+ "insertUnorderedList" => Self::InsertUnorderedList,
+ "insertLink" => Self::InsertLink,
+
+ // Deletion
+ "deleteContentBackward" => Self::DeleteContentBackward,
+ "deleteContentForward" => Self::DeleteContentForward,
+ "deleteWordBackward" => Self::DeleteWordBackward,
+ "deleteWordForward" => Self::DeleteWordForward,
+ "deleteSoftLineBackward" => Self::DeleteSoftLineBackward,
+ "deleteSoftLineForward" => Self::DeleteSoftLineForward,
+ "deleteHardLineBackward" => Self::DeleteHardLineBackward,
+ "deleteHardLineForward" => Self::DeleteHardLineForward,
+ "deleteByCut" => Self::DeleteByCut,
+ "deleteByDrag" => Self::DeleteByDrag,
+ "deleteContent" => Self::DeleteContent,
+ "deleteEntireSoftLine" => Self::DeleteSoftLineBackward, // Treat as soft line
+ "deleteEntireWordBackward" => Self::DeleteEntireWordBackward,
+ "deleteEntireWordForward" => Self::DeleteEntireWordForward,
+
+ // History
+ "historyUndo" => Self::HistoryUndo,
+ "historyRedo" => Self::HistoryRedo,
+
+ // Formatting
+ "formatBold" => Self::FormatBold,
+ "formatItalic" => Self::FormatItalic,
+ "formatUnderline" => Self::FormatUnderline,
+ "formatStrikethrough" => Self::FormatStrikethrough,
+ "formatSuperscript" => Self::FormatSuperscript,
+ "formatSubscript" => Self::FormatSubscript,
+
+ // Unknown
+ other => Self::Unknown(other.to_string()),
+ }
+ }
+
+ /// Whether this input type is a deletion operation.
+ pub fn is_deletion(&self) -> bool {
+ matches!(
+ self,
+ Self::DeleteContentBackward
+ | Self::DeleteContentForward
+ | Self::DeleteWordBackward
+ | Self::DeleteWordForward
+ | Self::DeleteSoftLineBackward
+ | Self::DeleteSoftLineForward
+ | Self::DeleteHardLineBackward
+ | Self::DeleteHardLineForward
+ | Self::DeleteByCut
+ | Self::DeleteByDrag
+ | Self::DeleteContent
+ | Self::DeleteEntireWordBackward
+ | Self::DeleteEntireWordForward
+ )
+ }
+
+ /// Whether this input type is an insertion operation.
+ pub fn is_insertion(&self) -> bool {
+ matches!(
+ self,
+ Self::InsertText
+ | Self::InsertCompositionText
+ | Self::InsertLineBreak
+ | Self::InsertParagraph
+ | Self::InsertFromPaste
+ | Self::InsertFromDrop
+ | Self::InsertReplacementText
+ | Self::InsertFromYank
+ )
+ }
+}
+
+/// Result of handling a beforeinput event.
+#[derive(Debug, Clone)]
+pub enum BeforeInputResult {
+ /// Event was handled, prevent default browser behavior.
+ Handled,
+ /// Event should be handled by browser (e.g., during composition).
+ PassThrough,
+ /// Event was handled but requires async follow-up (e.g., paste).
+ HandledAsync,
+ /// Android backspace workaround: defer and check if browser handled it.
+ DeferredCheck {
+ /// The action to execute if browser didn't handle it.
+ fallback_action: EditorAction,
+ },
+}
+
+/// Context for beforeinput handling.
+pub struct BeforeInputContext<'a> {
+ /// The input type.
+ pub input_type: InputType,
+ /// The data (text to insert, if any).
+ pub data: Option,
+ /// Target range from getTargetRanges(), if available.
+ /// This is the range the browser wants to modify.
+ pub target_range: Option,
+ /// Whether the event is part of an IME composition.
+ pub is_composing: bool,
+ /// Platform info for quirks handling.
+ pub platform: &'a Platform,
+}
+
+/// Handle a beforeinput event.
+///
+/// This is the main entry point for beforeinput-based input handling.
+/// Returns whether the event was handled and default should be prevented.
+pub fn handle_beforeinput(
+ doc: &mut EditorDocument,
+ ctx: BeforeInputContext<'_>,
+) -> BeforeInputResult {
+ // During composition, let the browser handle most things.
+ // We'll commit the final text in compositionend.
+ if ctx.is_composing {
+ match ctx.input_type {
+ // These can happen during composition but should still be handled
+ InputType::HistoryUndo | InputType::HistoryRedo => {
+ // Handle undo/redo even during composition
+ }
+ InputType::InsertCompositionText => {
+ // Let browser handle composition preview
+ return BeforeInputResult::PassThrough;
+ }
+ _ => {
+ // Let browser handle
+ return BeforeInputResult::PassThrough;
+ }
+ }
+ }
+
+ // Get the range to operate on
+ let range = ctx.target_range.unwrap_or_else(|| get_current_range(doc));
+
+ match ctx.input_type {
+ // === Insertion ===
+ InputType::InsertText => {
+ if let Some(text) = ctx.data {
+ let action = EditorAction::Insert { text, range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ } else {
+ BeforeInputResult::PassThrough
+ }
+ }
+
+ InputType::InsertLineBreak => {
+ let action = EditorAction::InsertLineBreak { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::InsertParagraph => {
+ let action = EditorAction::InsertParagraph { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::InsertFromPaste | InputType::InsertReplacementText => {
+ // For paste, we need the data from the event or clipboard
+ if let Some(text) = ctx.data {
+ let action = EditorAction::Insert { text, range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ } else {
+ // No data in event - need to handle via clipboard API
+ BeforeInputResult::PassThrough
+ }
+ }
+
+ InputType::InsertFromDrop => {
+ // Let browser handle drops for now
+ BeforeInputResult::PassThrough
+ }
+
+ InputType::InsertCompositionText => {
+ // Should be caught by is_composing check above, but just in case
+ BeforeInputResult::PassThrough
+ }
+
+ // === Deletion ===
+ InputType::DeleteContentBackward => {
+ // Android Chrome workaround: backspace sometimes doesn't work properly
+ // after uneditable nodes. Use deferred check pattern from ProseMirror.
+ // BUT only for caret deletions - selections we handle directly since
+ // the browser might only delete one char instead of the whole selection.
+ if ctx.platform.android && ctx.platform.chrome && range.is_caret() {
+ let action = EditorAction::DeleteBackward { range };
+ return BeforeInputResult::DeferredCheck {
+ fallback_action: action,
+ };
+ }
+
+ let action = EditorAction::DeleteBackward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteContentForward => {
+ let action = EditorAction::DeleteForward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => {
+ let action = EditorAction::DeleteWordBackward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteWordForward | InputType::DeleteEntireWordForward => {
+ let action = EditorAction::DeleteWordForward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteSoftLineBackward => {
+ let action = EditorAction::DeleteSoftLineBackward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteSoftLineForward => {
+ let action = EditorAction::DeleteSoftLineForward { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteHardLineBackward => {
+ let action = EditorAction::DeleteToLineStart { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteHardLineForward => {
+ let action = EditorAction::DeleteToLineEnd { range };
+ execute_action(doc, &action);
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteByCut => {
+ // Cut is handled separately via clipboard events
+ // But we should delete the selection here
+ if !range.is_caret() {
+ let action = EditorAction::DeleteBackward { range };
+ execute_action(doc, &action);
+ }
+ BeforeInputResult::Handled
+ }
+
+ InputType::DeleteByDrag | InputType::DeleteContent => {
+ if !range.is_caret() {
+ let action = EditorAction::DeleteBackward { range };
+ execute_action(doc, &action);
+ }
+ BeforeInputResult::Handled
+ }
+
+ // === History ===
+ InputType::HistoryUndo => {
+ execute_action(doc, &EditorAction::Undo);
+ BeforeInputResult::Handled
+ }
+
+ InputType::HistoryRedo => {
+ execute_action(doc, &EditorAction::Redo);
+ BeforeInputResult::Handled
+ }
+
+ // === Formatting ===
+ InputType::FormatBold => {
+ execute_action(doc, &EditorAction::ToggleBold);
+ BeforeInputResult::Handled
+ }
+
+ InputType::FormatItalic => {
+ execute_action(doc, &EditorAction::ToggleItalic);
+ BeforeInputResult::Handled
+ }
+
+ InputType::FormatStrikethrough => {
+ execute_action(doc, &EditorAction::ToggleStrikethrough);
+ BeforeInputResult::Handled
+ }
+
+ // === Other ===
+ InputType::InsertFromYank
+ | InputType::InsertHorizontalRule
+ | InputType::InsertOrderedList
+ | InputType::InsertUnorderedList
+ | InputType::InsertLink
+ | InputType::FormatUnderline
+ | InputType::FormatSuperscript
+ | InputType::FormatSubscript
+ | InputType::Unknown(_) => {
+ // Not handled - let browser do its thing or ignore
+ BeforeInputResult::PassThrough
+ }
+ }
+}
+
+/// Get the current range based on cursor and selection state.
+fn get_current_range(doc: &EditorDocument) -> Range {
+ if let Some(sel) = *doc.selection.read() {
+ let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
+ Range::new(start, end)
+ } else {
+ Range::caret(doc.cursor.read().offset)
+ }
+}
+
+/// Extract target range from a beforeinput event.
+///
+/// Uses getTargetRanges() to get the browser's intended range for this operation.
+/// This requires mapping DOM positions to document character offsets via paragraphs.
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+pub fn get_target_range_from_event(
+ event: &web_sys::InputEvent,
+ editor_id: &str,
+ paragraphs: &[super::paragraph::ParagraphRender],
+) -> Option {
+ use super::dom_sync::dom_position_to_text_offset;
+ use wasm_bindgen::JsCast;
+
+ let ranges = event.get_target_ranges();
+ if ranges.length() == 0 {
+ return None;
+ }
+
+ // Get the first range (there's usually only one)
+ // getTargetRanges returns an array of StaticRange objects
+ let static_range: StaticRange = ranges.get(0).unchecked_into();
+
+ let window = web_sys::window()?;
+ let dom_document = window.document()?;
+ let editor_element = dom_document.get_element_by_id(editor_id)?;
+
+ let start_container = static_range.startContainer();
+ let start_offset = static_range.startOffset() as usize;
+ let end_container = static_range.endContainer();
+ let end_offset = static_range.endOffset() as usize;
+
+ let start = dom_position_to_text_offset(
+ &dom_document,
+ &editor_element,
+ &start_container,
+ start_offset,
+ paragraphs,
+ None,
+ )?;
+ let end = dom_position_to_text_offset(
+ &dom_document,
+ &editor_element,
+ &end_container,
+ end_offset,
+ paragraphs,
+ None,
+ )?;
+
+ Some(Range::new(start, end))
+}
+
+/// Get data from a beforeinput event, handling different sources.
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option {
+ // First try the data property
+ if let Some(data) = event.data() {
+ if !data.is_empty() {
+ return Some(data);
+ }
+ }
+
+ // For paste/drop, try dataTransfer
+ if let Some(data_transfer) = event.data_transfer() {
+ if let Ok(text) = data_transfer.get_data("text/plain") {
+ if !text.is_empty() {
+ return Some(text);
+ }
+ }
+ }
+
+ None
+}
diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs
index 37f665d..8f049e8 100644
--- a/crates/weaver-app/src/components/editor/component.rs
+++ b/crates/weaver-app/src/components/editor/component.rs
@@ -13,18 +13,22 @@ use crate::auth::AuthState;
use crate::components::editor::ReportButton;
use crate::fetch::Fetcher;
-use super::document::{CompositionState, EditorDocument};
+use super::actions::{
+ execute_action, handle_keydown_with_bindings, EditorAction, Key, KeyCombo, KeybindingConfig,
+ KeydownResult, Range,
+};
+use super::beforeinput::{handle_beforeinput, BeforeInputContext, BeforeInputResult, InputType};
+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+use super::beforeinput::{get_data_from_event, get_target_range_from_event};
+use super::document::{CompositionState, EditorDocument, LoadedDocState};
use super::dom_sync::{
sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom,
};
use super::formatting;
-use super::input::{
- get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key,
-};
+use super::input::{get_char_at, handle_copy, handle_cut, handle_paste};
use super::offset_map::SnapDirection;
use super::paragraph::ParagraphRender;
use super::platform;
-use super::document::LoadedDocState;
use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing};
use super::render;
use super::storage;
@@ -382,7 +386,9 @@ fn MarkdownEditorInner(
interval.forget();
});
- // Set up beforeinput listener for iOS/Android virtual keyboard quirks
+ // Set up beforeinput listener for all text input handling.
+ // This is the primary handler for text insertion, deletion, etc.
+ // Keydown only handles shortcuts now.
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
let doc_for_beforeinput = document.clone();
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
@@ -390,13 +396,6 @@ fn MarkdownEditorInner(
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
- let plat = platform::platform();
-
- // Only needed on mobile
- if !plat.mobile {
- return;
- }
-
let window = match web_sys::window() {
Some(w) => w,
None => return,
@@ -414,46 +413,84 @@ fn MarkdownEditorInner(
let cached_paras = cached_paragraphs;
let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| {
- let input_type = evt.input_type();
- tracing::debug!(input_type = %input_type, "beforeinput");
+ let input_type_str = evt.input_type();
+ tracing::debug!(input_type = %input_type_str, "beforeinput");
let plat = platform::platform();
-
- // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak
- // without proper keydown events. Handle them here.
- if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") {
- tracing::debug!("iOS: intercepting {} via beforeinput", input_type);
- evt.prevent_default();
-
- // Handle as Enter key
- let sel = doc.selection.write().take();
- if let Some(sel) = sel {
- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
- let _ = doc.remove_tracked(start, end.saturating_sub(start));
- doc.cursor.write().offset = start;
+ let input_type = InputType::from_str(&input_type_str);
+ let is_composing = evt.is_composing();
+
+ // Get target range from the event if available
+ let paras = cached_paras.peek().clone();
+ let target_range = get_target_range_from_event(&evt, editor_id, ¶s);
+
+ // Get data from the event
+ let data = get_data_from_event(&evt);
+
+ // Build context and handle
+ let ctx = BeforeInputContext {
+ input_type: input_type.clone(),
+ data,
+ target_range,
+ is_composing,
+ platform: &plat,
+ };
+
+ let result = handle_beforeinput(&mut doc, ctx);
+
+ match result {
+ BeforeInputResult::Handled => {
+ evt.prevent_default();
}
-
- let cursor_offset = doc.cursor.read().offset;
- if input_type == "insertLineBreak" {
- // Soft break (like Shift+Enter)
- let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}");
- doc.cursor.write().offset = cursor_offset + 3;
- } else {
- // Paragraph break
- let _ = doc.insert_tracked(cursor_offset, "\n\n");
- doc.cursor.write().offset = cursor_offset + 2;
+ BeforeInputResult::PassThrough => {
+ // Let browser handle (e.g., during composition)
+ }
+ BeforeInputResult::HandledAsync => {
+ evt.prevent_default();
+ // Async follow-up will happen elsewhere
+ }
+ BeforeInputResult::DeferredCheck { fallback_action } => {
+ // Android backspace workaround: let browser try first,
+ // check in 50ms if anything happened, if not execute fallback
+ let mut doc_for_timeout = doc.clone();
+ let doc_len_before = doc.len_chars();
+
+ let window = web_sys::window();
+ if let Some(window) = window {
+ let closure = Closure::once(move || {
+ // Check if the document changed
+ if doc_for_timeout.len_chars() == doc_len_before {
+ // Nothing happened - execute fallback
+ tracing::debug!("Android backspace fallback triggered");
+ // Refocus to work around virtual keyboard issues
+ if let Some(window) = web_sys::window() {
+ if let Some(doc) = window.document() {
+ if let Some(elem) = doc.get_element_by_id(editor_id) {
+ if let Some(html_elem) = elem.dyn_ref::() {
+ let _ = html_elem.blur();
+ let _ = html_elem.focus();
+ }
+ }
+ }
+ }
+ execute_action(&mut doc_for_timeout, &fallback_action);
+ }
+ });
+ let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
+ closure.as_ref().unchecked_ref(),
+ 50,
+ );
+ closure.forget();
+ }
}
}
// Android workaround: When swipe keyboard picks a suggestion,
- // DOM mutations fire before selection moves. We detect this pattern
- // and defer cursor sync.
- if plat.android && input_type == "insertText" {
- // Check if this might be a suggestion pick (has data that looks like a word)
+ // DOM mutations fire before selection moves. Defer cursor sync.
+ if plat.android && matches!(input_type, InputType::InsertText) {
if let Some(data) = evt.data() {
if data.contains(' ') || data.len() > 3 {
tracing::debug!("Android: possible suggestion pick, deferring cursor sync");
- // Defer cursor sync by 20ms to let selection settle
let paras = cached_paras;
let mut doc_for_timeout = doc.clone();
let window = web_sys::window();
@@ -582,6 +619,7 @@ fn MarkdownEditorInner(
onkeydown: {
let mut doc = document.clone();
+ let keybindings = KeybindingConfig::default_for_platform(&platform::platform());
move |evt| {
use dioxus::prelude::keyboard_types::Key;
use std::time::Duration;
@@ -626,19 +664,29 @@ fn MarkdownEditorInner(
}
}
- // Android workaround: Chrome Android gets confused by Enter during/after
- // composition. Defer Enter handling to onkeypress instead.
- if plat.android && evt.key() == Key::Enter {
- tracing::debug!("Android: deferring Enter to keypress");
- return;
+ // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.)
+ let combo = KeyCombo::from_keyboard_event(&evt.data());
+ let cursor_offset = doc.cursor.read().offset;
+ let selection = *doc.selection.read();
+ let range = selection
+ .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head)))
+ .unwrap_or_else(|| Range::caret(cursor_offset));
+ match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) {
+ KeydownResult::Handled => {
+ evt.prevent_default();
+ return;
+ }
+ KeydownResult::PassThrough => {
+ // Navigation keys - let browser handle, sync in keyup
+ return;
+ }
+ KeydownResult::NotHandled => {
+ // Text input - let beforeinput handle it
+ }
}
- // Only prevent default for operations that modify content
- // Let browser handle arrow keys, Home/End naturally
- if should_intercept_key(&evt) {
- evt.prevent_default();
- handle_keydown(evt, &mut doc);
- }
+ // Text input keys: let beforeinput handle them
+ // We don't prevent default here - beforeinput will do that
}
},
@@ -769,7 +817,16 @@ fn MarkdownEditorInner(
if plat.android && evt.key() == Key::Enter {
tracing::debug!("Android: handling Enter in keypress");
evt.prevent_default();
- handle_keydown(evt, &mut doc);
+
+ // Get current range
+ let range = if let Some(sel) = *doc.selection.read() {
+ Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head))
+ } else {
+ Range::caret(doc.cursor.read().offset)
+ };
+
+ let action = EditorAction::InsertParagraph { range };
+ execute_action(&mut doc, &action);
}
}
},
diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs
index 094d833..e9297f2 100644
--- a/crates/weaver-app/src/components/editor/dom_sync.rs
+++ b/crates/weaver-app/src/components/editor/dom_sync.rs
@@ -6,7 +6,7 @@
use dioxus::prelude::*;
use super::document::{EditorDocument, Selection};
-use super::offset_map::{find_nearest_valid_position, is_valid_cursor_position, SnapDirection};
+use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position};
use super::paragraph::ParagraphRender;
/// Sync internal cursor and selection state from browser DOM selection.
@@ -72,7 +72,7 @@ pub fn sync_cursor_from_dom_with_direction(
let focus_offset = selection.focus_offset() as usize;
// Convert both DOM positions to rope offsets using cached paragraphs
- let anchor_rope = dom_position_to_rope_offset(
+ let anchor_rope = dom_position_to_text_offset(
&dom_document,
&editor_element,
&anchor_node,
@@ -80,7 +80,7 @@ pub fn sync_cursor_from_dom_with_direction(
paragraphs,
direction_hint,
);
- let focus_rope = dom_position_to_rope_offset(
+ let focus_rope = dom_position_to_text_offset(
&dom_document,
&editor_element,
&focus_node,
@@ -114,7 +114,7 @@ pub fn sync_cursor_from_dom_with_direction(
/// The `direction_hint` is used when snapping from invisible content to determine
/// which direction to prefer.
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-fn dom_position_to_rope_offset(
+pub fn dom_position_to_text_offset(
dom_document: &web_sys::Document,
editor_element: &web_sys::Element,
node: &web_sys::Node,
@@ -215,7 +215,9 @@ fn dom_position_to_rope_offset(
// No mapping found - try to find any valid position in paragraphs
// This handles clicks on non-text elements like images
for para in paragraphs {
- if let Some(snapped) = find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) {
+ if let Some(snapped) =
+ find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint)
+ {
return Some(snapped.char_offset());
}
}
diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs
index 00c94b5..b894f2d 100644
--- a/crates/weaver-app/src/components/editor/mod.rs
+++ b/crates/weaver-app/src/components/editor/mod.rs
@@ -4,6 +4,8 @@
//! characters are hidden contextually based on cursor position, while still
//! editing plain markdown text under the hood.
+mod actions;
+mod beforeinput;
mod component;
mod cursor;
mod document;
diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs
index e5f22d0..bf354b6 100644
--- a/crates/weaver-app/src/components/editor/writer.rs
+++ b/crates/weaver-app/src/components/editor/writer.rs
@@ -1322,7 +1322,37 @@ impl<
self.record_mapping(range.clone(), char_start..char_end);
self.last_char_offset = char_end;
}
- SoftBreak => self.write_newline()?,
+ SoftBreak => {
+ // Emit
for visual line break, plus a space for cursor positioning.
+ // This space maps to the \n so the cursor can land here when navigating.
+ let char_start = self.last_char_offset;
+
+ // Emit
+ self.write("
")?;
+ self.current_node_child_count += 1;
+
+ // Emit space for cursor positioning - this gives the browser somewhere
+ // to place the cursor when navigating to this line
+ self.write(" ")?;
+ self.current_node_child_count += 1;
+
+ // Map the space to the newline position - cursor landing here means
+ // we're at the end of the line (after the \n)
+ if let Some(ref node_id) = self.current_node_id {
+ let mapping = OffsetMapping {
+ byte_range: range.clone(),
+ char_range: char_start..char_start + 1,
+ node_id: node_id.clone(),
+ char_offset_in_node: self.current_node_char_offset,
+ child_index: None,
+ utf16_len: 1, // the space we emitted
+ };
+ self.offset_maps.push(mapping);
+ self.current_node_char_offset += 1;
+ }
+
+ self.last_char_offset = char_start + 1; // +1 for the \n
+ }
HardBreak => {
// Emit the two spaces as visible (dimmed) text, then
let gap = &self.source[range.clone()];