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()];