diff --git a/Cargo.lock b/Cargo.lock index 2384d17..9ca6b4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12197,6 +12197,7 @@ dependencies = [ "smol_str", "tracing", "wasm-bindgen", + "wasm-bindgen-futures", "wasm-bindgen-test", "weaver-editor-core", "web-sys", diff --git a/crates/weaver-editor-browser/Cargo.toml b/crates/weaver-editor-browser/Cargo.toml index 9f7e81c..7c355ed 100644 --- a/crates/weaver-editor-browser/Cargo.toml +++ b/crates/weaver-editor-browser/Cargo.toml @@ -18,6 +18,9 @@ gloo-utils = "0.2" # Logging tracing = { workspace = true } +# Async (for JS promise bridging) +wasm-bindgen-futures = "0.4" + # Utilities smol_str = "0.3" @@ -48,6 +51,10 @@ features = [ "DataTransferItemList", "FocusEvent", "MouseEvent", + "Blob", + "BlobPropertyBag", + "Clipboard", + "ClipboardItem", ] [features] diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs index 097e3c0..fbd092c 100644 --- a/crates/weaver-editor-browser/src/events.rs +++ b/crates/weaver-editor-browser/src/events.rs @@ -1,5 +1,257 @@ //! Browser event handling for the editor. //! -//! Handles beforeinput, keydown, paste, and other DOM events. +//! Provides browser-specific event extraction and input type parsing for +//! the `beforeinput` event and other DOM events. -// TODO: Migrate from weaver-app (beforeinput.rs, input.rs) +use wasm_bindgen::prelude::*; +use weaver_editor_core::{InputType, OffsetMapping, Range}; + +use crate::dom_sync::dom_position_to_text_offset; + +// === StaticRange binding === +// +// 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. + +#[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; +} + +// === InputType browser parsing === + +/// Parse a browser inputType string to an InputType enum. +/// +/// This handles the W3C Input Events inputType values as returned by +/// `InputEvent.inputType` in browsers. +pub fn parse_browser_input_type(s: &str) -> InputType { + match s { + // Insertion + "insertText" => InputType::InsertText, + "insertCompositionText" => InputType::InsertCompositionText, + "insertLineBreak" => InputType::InsertLineBreak, + "insertParagraph" => InputType::InsertParagraph, + "insertFromPaste" => InputType::InsertFromPaste, + "insertFromDrop" => InputType::InsertFromDrop, + "insertReplacementText" => InputType::InsertReplacementText, + "insertFromYank" => InputType::InsertFromYank, + "insertHorizontalRule" => InputType::InsertHorizontalRule, + "insertOrderedList" => InputType::InsertOrderedList, + "insertUnorderedList" => InputType::InsertUnorderedList, + "insertLink" => InputType::InsertLink, + + // Deletion + "deleteContentBackward" => InputType::DeleteContentBackward, + "deleteContentForward" => InputType::DeleteContentForward, + "deleteWordBackward" => InputType::DeleteWordBackward, + "deleteWordForward" => InputType::DeleteWordForward, + "deleteSoftLineBackward" => InputType::DeleteSoftLineBackward, + "deleteSoftLineForward" => InputType::DeleteSoftLineForward, + "deleteHardLineBackward" => InputType::DeleteHardLineBackward, + "deleteHardLineForward" => InputType::DeleteHardLineForward, + "deleteByCut" => InputType::DeleteByCut, + "deleteByDrag" => InputType::DeleteByDrag, + "deleteContent" => InputType::DeleteContent, + "deleteEntireSoftLine" => InputType::DeleteSoftLineBackward, + "deleteEntireWordBackward" => InputType::DeleteEntireWordBackward, + "deleteEntireWordForward" => InputType::DeleteEntireWordForward, + + // History + "historyUndo" => InputType::HistoryUndo, + "historyRedo" => InputType::HistoryRedo, + + // Formatting + "formatBold" => InputType::FormatBold, + "formatItalic" => InputType::FormatItalic, + "formatUnderline" => InputType::FormatUnderline, + "formatStrikethrough" => InputType::FormatStrikethrough, + "formatSuperscript" => InputType::FormatSuperscript, + "formatSubscript" => InputType::FormatSubscript, + + // Unknown + other => InputType::Unknown(other.to_string()), + } +} + +// === BeforeInput event handling === + +/// 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: weaver_editor_core::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. + pub target_range: Option, + /// Whether the event is part of an IME composition. + pub is_composing: bool, + /// Whether we're on Android. + pub is_android: bool, + /// Whether we're on Chrome. + pub is_chrome: bool, + /// Offset mappings for the document. + pub offset_map: &'a [OffsetMapping], +} + +/// Extract target range from a beforeinput event. +/// +/// Uses getTargetRanges() to get the browser's intended range for this operation. +pub fn get_target_range_from_event( + event: &web_sys::InputEvent, + editor_id: &str, + offset_map: &[OffsetMapping], +) -> Option { + use wasm_bindgen::JsCast; + + let ranges = event.get_target_ranges(); + if ranges.length() == 0 { + return None; + } + + 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, + offset_map, + None, + )?; + + let end = dom_position_to_text_offset( + &dom_document, + &editor_element, + &end_container, + end_offset, + offset_map, + None, + )?; + + Some(Range::new(start, end)) +} + +/// Get data from a beforeinput event, handling different sources. +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 +} + +/// Get input type from a beforeinput event. +pub fn get_input_type_from_event(event: &web_sys::InputEvent) -> InputType { + parse_browser_input_type(&event.input_type()) +} + +/// Check if the beforeinput event is during IME composition. +pub fn is_composing(event: &web_sys::InputEvent) -> bool { + event.is_composing() +} + +// === Clipboard helpers === + +/// Write text to clipboard with both text/plain and custom MIME type. +pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), JsValue> { + use js_sys::{Array, Object, Reflect}; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let navigator = window.navigator(); + let clipboard = navigator.clipboard(); + + let text_parts = Array::new(); + text_parts.push(&JsValue::from_str(text)); + + let text_opts = BlobPropertyBag::new(); + text_opts.set_type("text/plain"); + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; + + let custom_opts = BlobPropertyBag::new(); + custom_opts.set_type("text/markdown"); + let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; + + let item_data = Object::new(); + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; + Reflect::set( + &item_data, + &JsValue::from_str("text/markdown"), + &custom_blob, + )?; + + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; + let items = Array::new(); + items.push(&clipboard_item); + + let promise = clipboard.write(&items); + wasm_bindgen_futures::JsFuture::from(promise).await?; + + Ok(()) +} + +/// Read text from clipboard. +pub async fn read_clipboard_text() -> Result, JsValue> { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let navigator = window.navigator(); + let clipboard = navigator.clipboard(); + + let promise = clipboard.read_text(); + let result: JsValue = wasm_bindgen_futures::JsFuture::from(promise).await?; + + Ok(result.as_string()) +} diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs index 56ab36d..6617e5b 100644 --- a/crates/weaver-editor-browser/src/lib.rs +++ b/crates/weaver-editor-browser/src/lib.rs @@ -8,8 +8,7 @@ //! //! - `cursor`: Selection API handling and cursor restoration //! - `dom_sync`: DOM ↔ document state synchronization -//! - `events`: beforeinput, keydown, paste event handlers -//! - `contenteditable`: Editor element setup and management +//! - `events`: beforeinput event handling and clipboard helpers //! - `platform`: Browser/OS detection for platform-specific behavior //! //! # Re-exports @@ -29,8 +28,15 @@ pub mod platform; // Browser cursor implementation pub use cursor::BrowserCursor; +// DOM sync types +pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData}; + +// Event handling +pub use events::{ + BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, + get_input_type_from_event, get_target_range_from_event, is_composing, + parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, +}; + // Platform detection pub use platform::{Platform, platform}; - -// TODO: contenteditable module -// TODO: embed worker module diff --git a/crates/weaver-editor-core/src/actions.rs b/crates/weaver-editor-core/src/actions.rs index a5ecf12..e9e48df 100644 --- a/crates/weaver-editor-core/src/actions.rs +++ b/crates/weaver-editor-core/src/actions.rs @@ -457,6 +457,8 @@ pub struct Modifiers { pub alt: bool, pub shift: bool, pub meta: bool, + pub hyper: bool, + pub super_: bool, } impl Modifiers { @@ -465,6 +467,8 @@ impl Modifiers { alt: false, shift: false, meta: false, + hyper: false, + super_: false, }; pub const CTRL: Self = Self { @@ -472,6 +476,8 @@ impl Modifiers { alt: false, shift: false, meta: false, + hyper: false, + super_: false, }; pub const ALT: Self = Self { @@ -479,6 +485,8 @@ impl Modifiers { alt: true, shift: false, meta: false, + hyper: false, + super_: false, }; pub const SHIFT: Self = Self { @@ -486,6 +494,8 @@ impl Modifiers { alt: false, shift: true, meta: false, + hyper: false, + super_: false, }; pub const META: Self = Self { @@ -493,6 +503,26 @@ impl Modifiers { 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 { @@ -500,6 +530,8 @@ impl Modifiers { alt: false, shift: true, meta: false, + hyper: false, + super_: false, }; pub const META_SHIFT: Self = Self { @@ -507,6 +539,8 @@ impl Modifiers { alt: false, shift: true, meta: true, + hyper: false, + super_: false, }; /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere). @@ -593,3 +627,152 @@ pub enum KeydownResult { /// Event should be passed through (navigation, etc.). PassThrough, } + +// === Keybinding configuration === + +use std::collections::HashMap; + +/// Keybinding configuration for the editor. +/// +/// Maps key combinations to editor actions. Platform-specific defaults +/// can be created via `default_for_platform`. +#[derive(Debug, Clone)] +pub struct KeybindingConfig { + bindings: HashMap, +} + +impl Default for KeybindingConfig { + fn default() -> Self { + Self::default_for_platform(false) + } +} + +impl KeybindingConfig { + /// Create an empty keybinding configuration. + pub fn new() -> Self { + Self { + bindings: HashMap::new(), + } + } + + /// Create default keybindings for the given platform. + /// + /// `is_mac` determines whether to use Cmd (true) or Ctrl (false) for shortcuts. + pub fn default_for_platform(is_mac: bool) -> Self { + let mut bindings = HashMap::new(); + + // === Formatting === + bindings.insert( + KeyCombo::primary(Key::character("b"), is_mac), + EditorAction::ToggleBold, + ); + bindings.insert( + KeyCombo::primary(Key::character("i"), is_mac), + EditorAction::ToggleItalic, + ); + bindings.insert( + KeyCombo::primary(Key::character("e"), is_mac), + EditorAction::CopyAsHtml, + ); + + // === History === + bindings.insert( + KeyCombo::primary(Key::character("z"), is_mac), + EditorAction::Undo, + ); + + // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere + if is_mac { + bindings.insert( + KeyCombo::primary_shift(Key::character("Z"), is_mac), + 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::primary(Key::character("a"), is_mac), + EditorAction::SelectAll, + ); + + // === Line deletion === + if is_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 keys === + 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. + /// + /// The range in the returned action is updated to the provided range. + pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option { + self.bindings.get(combo).cloned().map(|a| a.with_range(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); + } + + /// Check if a key combo has a binding. + pub fn has_binding(&self, combo: &KeyCombo) -> bool { + self.bindings.contains_key(combo) + } + + /// Iterate over all bindings. + pub fn iter(&self) -> impl Iterator { + self.bindings.iter() + } +} diff --git a/crates/weaver-editor-core/src/execute.rs b/crates/weaver-editor-core/src/execute.rs new file mode 100644 index 0000000..41103ef --- /dev/null +++ b/crates/weaver-editor-core/src/execute.rs @@ -0,0 +1,522 @@ +//! Action execution for editor documents. +//! +//! This module provides the `execute_action` function that applies `EditorAction` +//! operations to any type implementing `EditorDocument`. The logic is generic +//! and platform-agnostic. + +use crate::actions::{EditorAction, Range}; +use crate::document::EditorDocument; +use crate::text_helpers::{ + ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, + find_word_boundary_forward, is_list_item_empty, +}; +use crate::types::Selection; + +/// 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 D, action: &EditorAction) -> bool { + match action { + EditorAction::Insert { text, range } => execute_insert(doc, text, *range), + EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range), + EditorAction::InsertParagraph { range } => execute_insert_paragraph(doc, *range), + EditorAction::DeleteBackward { range } => execute_delete_backward(doc, *range), + EditorAction::DeleteForward { range } => execute_delete_forward(doc, *range), + EditorAction::DeleteWordBackward { range } => execute_delete_word_backward(doc, *range), + EditorAction::DeleteWordForward { range } => execute_delete_word_forward(doc, *range), + EditorAction::DeleteToLineStart { range } => execute_delete_to_line_start(doc, *range), + EditorAction::DeleteToLineEnd { range } => execute_delete_to_line_end(doc, *range), + EditorAction::DeleteSoftLineBackward { range } => { + execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) + } + EditorAction::DeleteSoftLineForward { range } => { + execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) + } + EditorAction::Undo => execute_undo(doc), + EditorAction::Redo => execute_redo(doc), + EditorAction::ToggleBold => execute_toggle_format(doc, "**"), + EditorAction::ToggleItalic => execute_toggle_format(doc, "*"), + EditorAction::ToggleCode => execute_toggle_format(doc, "`"), + EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"), + EditorAction::InsertLink => execute_insert_link(doc), + EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => { + // Clipboard operations are handled by platform layer. + false + } + EditorAction::Paste { range: _ } => { + // Paste is handled by platform layer with clipboard access. + false + } + EditorAction::SelectAll => execute_select_all(doc), + EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset), + EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset), + } +} + +fn execute_insert(doc: &mut D, text: &str, range: Range) -> bool { + let range = range.normalize(); + + // Clean up any preceding zero-width chars. + let mut delete_start = range.start; + while delete_start > 0 { + match doc.char_at(delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + _ => break, + } + } + + let zw_count = range.start - delete_start; + + if range.is_caret() { + if zw_count > 0 { + doc.replace(delete_start..range.start, text); + } else if range.start == doc.len_chars() { + doc.insert(range.start, text); + } else { + doc.insert(range.start, text); + } + } else { + // Replace selection. + if zw_count > 0 { + // Delete zero-width chars before selection start too. + doc.replace(delete_start..range.end, text); + } else { + doc.replace(range.start..range.end, text); + } + } + + doc.set_selection(None); + true +} + +fn execute_insert_line_break(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + let offset = range.start; + + // Delete selection if any. + if !range.is_caret() { + doc.delete(offset..range.end); + } + + // Check if we're right after a soft break (newline + zero-width char). + let is_double_enter = if offset >= 2 { + let prev_char = doc.char_at(offset - 1); + let prev_prev_char = doc.char_at(offset - 2); + prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') + } else { + false + }; + + if !is_double_enter { + // Check for list context. + if let Some(ctx) = detect_list_context(doc, offset) { + if is_list_item_empty(doc, offset, &ctx) { + // Empty item - exit list. + let line_start = find_line_start(doc, offset); + let line_end = find_line_end(doc, offset); + let delete_end = (line_end + 1).min(doc.len_chars()); + doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); + doc.set_cursor_offset(line_start + 2); + } else { + // Continue list. + let continuation = list_continuation(&ctx); + let len = continuation.chars().count(); + doc.insert(offset, &continuation); + doc.set_cursor_offset(offset + len); + } + } else { + // Normal soft break: insert newline + zero-width char. + doc.insert(offset, "\n\u{200C}"); + doc.set_cursor_offset(offset + 2); + } + } else { + // Replace zero-width char with newline. + doc.replace(offset - 1..offset, "\n"); + } + + doc.set_selection(None); + true +} + +fn execute_insert_paragraph(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + let cursor_offset = range.start; + + // Delete selection if any. + if !range.is_caret() { + doc.delete(cursor_offset..range.end); + } + + // Check for list context. + if let Some(ctx) = detect_list_context(doc, cursor_offset) { + if is_list_item_empty(doc, cursor_offset, &ctx) { + // Empty item - exit list. + let line_start = find_line_start(doc, cursor_offset); + let line_end = find_line_end(doc, cursor_offset); + let delete_end = (line_end + 1).min(doc.len_chars()); + doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); + doc.set_cursor_offset(line_start + 2); + } else { + // Continue list. + let continuation = list_continuation(&ctx); + let len = continuation.chars().count(); + doc.insert(cursor_offset, &continuation); + doc.set_cursor_offset(cursor_offset + len); + } + } else { + // Normal paragraph break. + doc.insert(cursor_offset, "\n\n"); + doc.set_cursor_offset(cursor_offset + 2); + } + + doc.set_selection(None); + true +} + +fn execute_delete_backward(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + + if !range.is_caret() { + // Delete selection. + doc.delete(range.start..range.end); + return true; + } + + if range.start == 0 { + return false; + } + + let cursor_offset = range.start; + let prev_char = doc.char_at(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 && doc.char_at(newline_pos - 1) == Some('\n') { + delete_start = newline_pos - 1; + } + + // Check for trailing zero-width char. + if let Some(ch) = doc.char_at(delete_end) { + if ch == '\u{200C}' || ch == '\u{200B}' { + delete_end += 1; + } + } + + // Scan backwards through zero-width chars. + while delete_start > 0 { + match doc.char_at(delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + Some('\n') | _ => break, + } + } + + doc.delete(delete_start..delete_end); + } else { + // Normal single char delete. + doc.delete(cursor_offset - 1..cursor_offset); + } + + doc.set_selection(None); + true +} + +fn execute_delete_forward(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + + if !range.is_caret() { + doc.delete(range.start..range.end); + return true; + } + + if range.start >= doc.len_chars() { + return false; + } + + doc.delete(range.start..range.start + 1); + doc.set_selection(None); + true +} + +fn execute_delete_word_backward(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + + if !range.is_caret() { + doc.delete(range.start..range.end); + return true; + } + + let cursor = range.start; + let word_start = find_word_boundary_backward(doc, cursor); + if word_start < cursor { + doc.delete(word_start..cursor); + } + + doc.set_selection(None); + true +} + +fn execute_delete_word_forward(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + + if !range.is_caret() { + doc.delete(range.start..range.end); + return true; + } + + let cursor = range.start; + let word_end = find_word_boundary_forward(doc, cursor); + if word_end > cursor { + doc.delete(cursor..word_end); + } + + doc.set_selection(None); + true +} + +fn execute_delete_to_line_start(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + let cursor = range.start; + let line_start = find_line_start(doc, cursor); + + if line_start < cursor { + doc.delete(line_start..cursor); + } + + doc.set_selection(None); + true +} + +fn execute_delete_to_line_end(doc: &mut D, range: Range) -> bool { + let range = range.normalize(); + let cursor = if range.is_caret() { + range.start + } else { + range.end + }; + let line_end = find_line_end(doc, cursor); + + if cursor < line_end { + doc.delete(cursor..line_end); + } + + doc.set_selection(None); + true +} + +fn execute_undo(doc: &mut D) -> bool { + if doc.undo() { + let max = doc.len_chars(); + let cursor = doc.cursor(); + if cursor.offset > max { + doc.set_cursor_offset(max); + } + doc.set_selection(None); + true + } else { + false + } +} + +fn execute_redo(doc: &mut D) -> bool { + if doc.redo() { + let max = doc.len_chars(); + let cursor = doc.cursor(); + if cursor.offset > max { + doc.set_cursor_offset(max); + } + doc.set_selection(None); + true + } else { + false + } +} + +fn execute_toggle_format(doc: &mut D, marker: &str) -> bool { + let cursor_offset = doc.cursor_offset(); + let (start, end) = if let Some(sel) = doc.selection() { + (sel.start(), sel.end()) + } else { + find_word_boundaries(doc, cursor_offset) + }; + + // Insert end marker first so start position stays valid. + doc.insert(end, marker); + doc.insert(start, marker); + doc.set_cursor_offset(end + marker.len() * 2); + doc.set_selection(None); + true +} + +fn execute_insert_link(doc: &mut D) -> bool { + let cursor_offset = doc.cursor_offset(); + let (start, end) = if let Some(sel) = doc.selection() { + (sel.start(), sel.end()) + } else { + find_word_boundaries(doc, cursor_offset) + }; + + // Insert [selected text](url) + doc.insert(end, "](url)"); + doc.insert(start, "["); + doc.set_cursor_offset(end + 8); + doc.set_selection(None); + true +} + +fn execute_select_all(doc: &mut D) -> bool { + let len = doc.len_chars(); + doc.set_selection(Some(Selection::new(0, len))); + doc.set_cursor_offset(len); + true +} + +fn execute_move_cursor(doc: &mut D, offset: usize) -> bool { + let offset = offset.min(doc.len_chars()); + doc.set_cursor_offset(offset); + doc.set_selection(None); + true +} + +fn execute_extend_selection(doc: &mut D, offset: usize) -> bool { + let offset = offset.min(doc.len_chars()); + let anchor = doc + .selection() + .map(|s| s.anchor) + .unwrap_or_else(|| doc.cursor_offset()); + doc.set_selection(Some(Selection::new(anchor, offset))); + doc.set_cursor_offset(offset); + true +} + +/// Find word boundaries around cursor position. +fn find_word_boundaries(doc: &D, offset: usize) -> (usize, usize) { + let len = doc.len_chars(); + + // Find start by scanning backwards. + let mut start = 0; + for i in (0..offset).rev() { + match doc.char_at(i) { + Some(c) if c.is_whitespace() => { + start = i + 1; + break; + } + Some(_) => continue, + None => break, + } + } + + // Find end by scanning forwards. + let mut end = len; + for i in offset..len { + match doc.char_at(i) { + Some(c) if c.is_whitespace() => { + end = i; + break; + } + Some(_) => continue, + None => break, + } + } + + (start, end) +} + +/// Generate list continuation text. +fn list_continuation(ctx: &ListContext) -> String { + match ctx { + ListContext::Unordered { indent, marker } => { + format!("\n{}{} ", indent, marker) + } + ListContext::Ordered { indent, number } => { + format!("\n{}{}. ", indent, number + 1) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EditorRope, PlainEditor, UndoableBuffer}; + + type TestEditor = PlainEditor>; + + fn make_editor(content: &str) -> TestEditor { + let rope = EditorRope::from_str(content); + let buf = UndoableBuffer::new(rope, 100); + PlainEditor::new(buf) + } + + #[test] + fn test_insert() { + let mut editor = make_editor("hello"); + let action = EditorAction::Insert { + text: " world".to_string(), + range: Range::caret(5), + }; + assert!(execute_action(&mut editor, &action)); + assert_eq!(editor.content_string(), "hello world"); + } + + #[test] + fn test_delete_backward() { + let mut editor = make_editor("hello"); + editor.set_cursor_offset(5); + let action = EditorAction::DeleteBackward { + range: Range::caret(5), + }; + assert!(execute_action(&mut editor, &action)); + assert_eq!(editor.content_string(), "hell"); + } + + #[test] + fn test_delete_selection() { + let mut editor = make_editor("hello world"); + editor.set_selection(Some(Selection::new(5, 11))); + let action = EditorAction::DeleteBackward { + range: Range::new(5, 11), + }; + assert!(execute_action(&mut editor, &action)); + assert_eq!(editor.content_string(), "hello"); + } + + #[test] + fn test_undo_redo() { + let mut editor = make_editor("hello"); + + let action = EditorAction::Insert { + text: " world".to_string(), + range: Range::caret(5), + }; + execute_action(&mut editor, &action); + assert_eq!(editor.content_string(), "hello world"); + + assert!(execute_action(&mut editor, &EditorAction::Undo)); + assert_eq!(editor.content_string(), "hello"); + + assert!(execute_action(&mut editor, &EditorAction::Redo)); + assert_eq!(editor.content_string(), "hello world"); + } + + #[test] + fn test_select_all() { + let mut editor = make_editor("hello world"); + assert!(execute_action(&mut editor, &EditorAction::SelectAll)); + let sel = editor.selection().unwrap(); + assert_eq!(sel.start(), 0); + assert_eq!(sel.end(), 11); + } + + #[test] + fn test_toggle_bold() { + let mut editor = make_editor("hello"); + editor.set_selection(Some(Selection::new(0, 5))); + assert!(execute_action(&mut editor, &EditorAction::ToggleBold)); + assert_eq!(editor.content_string(), "**hello**"); + } +} diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index 5edb914..4a92da4 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -6,15 +6,19 @@ //! - `UndoableBuffer` - TextBuffer wrapper with undo/redo //! - `EditorDocument` trait - interface for editor implementations //! - `PlainEditor` - simple field-based EditorDocument impl +//! - `EditorAction`, `InputType`, `Key` - platform-agnostic input/action types //! - Rendering types and offset mapping utilities +pub mod actions; pub mod document; +pub mod execute; pub mod offset_map; pub mod paragraph; pub mod platform; pub mod render; pub mod syntax; pub mod text; +pub mod text_helpers; pub mod types; pub mod undo; pub mod visibility; @@ -38,3 +42,12 @@ pub use undo::{UndoManager, UndoableBuffer}; pub use visibility::VisibilityState; pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; pub use platform::{CursorPlatform, CursorSync, PlatformError}; +pub use actions::{ + EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, +}; +pub use execute::execute_action; +pub use text_helpers::{ + ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start, + find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, + is_zero_width_char, +}; diff --git a/crates/weaver-editor-core/src/text_helpers.rs b/crates/weaver-editor-core/src/text_helpers.rs new file mode 100644 index 0000000..baf321f --- /dev/null +++ b/crates/weaver-editor-core/src/text_helpers.rs @@ -0,0 +1,286 @@ +//! Text navigation and analysis helpers. +//! +//! These functions work with the `EditorDocument` trait to provide +//! common text operations like finding line boundaries and word boundaries. + +use crate::document::EditorDocument; + +/// Find start of line containing offset. +pub fn find_line_start(doc: &D, offset: usize) -> usize { + if offset == 0 { + return 0; + } + + let mut pos = offset; + while pos > 0 { + if let Some('\n') = doc.char_at(pos - 1) { + return pos; + } + pos -= 1; + } + 0 +} + +/// Find end of line containing offset (position of newline or end of doc). +pub fn find_line_end(doc: &D, offset: usize) -> usize { + let len = doc.len_chars(); + if offset >= len { + return len; + } + + let mut pos = offset; + while pos < len { + if let Some('\n') = doc.char_at(pos) { + return pos; + } + pos += 1; + } + len +} + +/// Find word boundary backward from cursor. +pub fn find_word_boundary_backward(doc: &D, cursor: usize) -> usize { + if cursor == 0 { + return 0; + } + + let mut pos = cursor; + + // Skip any whitespace/punctuation immediately before cursor. + while pos > 0 { + match doc.char_at(pos - 1) { + Some(c) if c.is_alphanumeric() || c == '_' => break, + Some(_) => pos -= 1, + None => break, + } + } + + // Skip the word characters. + while pos > 0 { + match doc.char_at(pos - 1) { + Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1, + _ => break, + } + } + + pos +} + +/// Find word boundary forward from cursor. +pub fn find_word_boundary_forward(doc: &D, cursor: usize) -> usize { + let len = doc.len_chars(); + if cursor >= len { + return len; + } + + let mut pos = cursor; + + // Skip word characters first. + while pos < len { + match doc.char_at(pos) { + Some(c) if c.is_alphanumeric() || c == '_' => pos += 1, + _ => break, + } + } + + // Then skip whitespace/punctuation. + while pos < len { + match doc.char_at(pos) { + Some(c) if c.is_alphanumeric() || c == '_' => break, + Some(_) => pos += 1, + None => break, + } + } + + pos +} + +/// Describes what kind of list item the cursor is in, if any. +#[derive(Debug, Clone)] +pub enum ListContext { + /// Unordered list with the given marker char ('-' or '*') and indentation. + Unordered { indent: String, marker: char }, + /// Ordered list with the current number and indentation. + Ordered { indent: String, number: usize }, +} + +/// Detect if cursor is in a list item and return context for continuation. +pub fn detect_list_context(doc: &D, cursor_offset: usize) -> Option { + let line_start = find_line_start(doc, cursor_offset); + let line_end = find_line_end(doc, cursor_offset); + + if line_start >= line_end { + return None; + } + + let line = doc.slice(line_start..line_end)?; + + // Parse indentation. + let indent: String = line + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let trimmed = &line[indent.len()..]; + + // Check for unordered list marker: "- " or "* ". + if trimmed.starts_with("- ") { + return Some(ListContext::Unordered { + indent, + marker: '-', + }); + } + if trimmed.starts_with("* ") { + return Some(ListContext::Unordered { + indent, + marker: '*', + }); + } + + // Check for ordered list marker: "1. ", "2. ", etc. + if let Some(dot_pos) = trimmed.find(". ") { + let num_part = &trimmed[..dot_pos]; + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { + if let Ok(number) = num_part.parse::() { + return Some(ListContext::Ordered { indent, number }); + } + } + } + + None +} + +/// Check if the current list item is empty (just the marker, no content). +pub fn is_list_item_empty( + doc: &D, + cursor_offset: usize, + ctx: &ListContext, +) -> bool { + let line_start = find_line_start(doc, cursor_offset); + let line_end = find_line_end(doc, cursor_offset); + + let line = match doc.slice(line_start..line_end) { + Some(s) => s, + None => return false, + }; + + // Calculate expected marker length. + let marker_len = match ctx { + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " + ListContext::Ordered { indent, number } => { + indent.len() + number.to_string().len() + 2 // "1. " + } + }; + + line.len() <= marker_len +} + +/// Count leading zero-width characters before offset. +pub fn count_leading_zero_width(doc: &D, offset: usize) -> usize { + let mut count = 0; + let mut pos = offset; + + while pos > 0 { + match doc.char_at(pos - 1) { + Some('\u{200C}') | Some('\u{200B}') => { + count += 1; + pos -= 1; + } + _ => break, + } + } + + count +} + +/// Check if character at offset is a zero-width character. +pub fn is_zero_width_char(doc: &D, offset: usize) -> bool { + matches!(doc.char_at(offset), Some('\u{200C}') | Some('\u{200B}')) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EditorRope, PlainEditor, UndoableBuffer}; + + type TestEditor = PlainEditor>; + + fn make_editor(content: &str) -> TestEditor { + let rope = EditorRope::from_str(content); + let buf = UndoableBuffer::new(rope, 100); + PlainEditor::new(buf) + } + + #[test] + fn test_find_line_start() { + let editor = make_editor("hello\nworld\ntest"); + + assert_eq!(find_line_start(&editor, 0), 0); + assert_eq!(find_line_start(&editor, 3), 0); + assert_eq!(find_line_start(&editor, 5), 0); // at newline + assert_eq!(find_line_start(&editor, 6), 6); // start of "world" + assert_eq!(find_line_start(&editor, 8), 6); + assert_eq!(find_line_start(&editor, 12), 12); // start of "test" + } + + #[test] + fn test_find_line_end() { + let editor = make_editor("hello\nworld\ntest"); + + assert_eq!(find_line_end(&editor, 0), 5); + assert_eq!(find_line_end(&editor, 3), 5); + assert_eq!(find_line_end(&editor, 6), 11); + assert_eq!(find_line_end(&editor, 12), 16); + } + + #[test] + fn test_find_word_boundary_backward() { + let editor = make_editor("hello world test"); + + assert_eq!(find_word_boundary_backward(&editor, 16), 12); // from end + assert_eq!(find_word_boundary_backward(&editor, 12), 6); // from "test" + assert_eq!(find_word_boundary_backward(&editor, 11), 6); // from space before "test" + assert_eq!(find_word_boundary_backward(&editor, 5), 0); // from end of "hello" + } + + #[test] + fn test_find_word_boundary_forward() { + let editor = make_editor("hello world test"); + + assert_eq!(find_word_boundary_forward(&editor, 0), 6); // from start + assert_eq!(find_word_boundary_forward(&editor, 6), 12); // from space + assert_eq!(find_word_boundary_forward(&editor, 12), 16); // from "test" + } + + #[test] + fn test_detect_list_context_unordered() { + let editor = make_editor("- item one\n- item two"); + + let ctx = detect_list_context(&editor, 5); + assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. }))); + + let ctx = detect_list_context(&editor, 15); + assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. }))); + } + + #[test] + fn test_detect_list_context_ordered() { + let editor = make_editor("1. first\n2. second"); + + let ctx = detect_list_context(&editor, 5); + assert!(matches!(ctx, Some(ListContext::Ordered { number: 1, .. }))); + + let ctx = detect_list_context(&editor, 12); + assert!(matches!(ctx, Some(ListContext::Ordered { number: 2, .. }))); + } + + #[test] + fn test_is_list_item_empty() { + let editor = make_editor("- \n- item"); + + let ctx = detect_list_context(&editor, 1).unwrap(); + assert!(is_list_item_empty(&editor, 1, &ctx)); + + let ctx = detect_list_context(&editor, 5).unwrap(); + assert!(!is_list_item_empty(&editor, 5, &ctx)); + } +} diff --git a/docs/graph-data.json b/docs/graph-data.json index 3bb79b4..39d5c92 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1286,6 +1286,72 @@ "created_at": "2026-01-06T11:35:39.555773236-05:00", "updated_at": "2026-01-06T11:35:39.555773236-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 119, + "change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", + "node_type": "action", + "title": "core: actions.rs with Range, EditorAction, InputType, Key, Modifiers, KeyCombo", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:48:31.913440141-05:00", + "updated_at": "2026-01-06T11:48:31.913440141-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 120, + "change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", + "node_type": "action", + "title": "core: text_helpers.rs with find_line_start/end, word boundaries, list detection", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:48:48.907582364-05:00", + "updated_at": "2026-01-06T11:48:48.907582364-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 121, + "change_id": "1206764e-ee77-4dd4-b924-019177b1a495", + "node_type": "outcome", + "title": "weaver-editor-browser and weaver-editor-core compile with extracted actions, text helpers, and events", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:54:09.803705832-05:00", + "updated_at": "2026-01-06T11:54:09.803705832-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 122, + "change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", + "node_type": "goal", + "title": "Extract execute_action and related code to core/browser crates", + "description": null, + "status": "pending", + "created_at": "2026-01-06T11:57:25.012098492-05:00", + "updated_at": "2026-01-06T11:57:25.012098492-05:00", + "metadata_json": "{\"confidence\":85,\"prompt\":\"User asked: extract execute_action in generic fashion and scan editor components for other extractable code (non-crdt, non-dioxus)\"}" + }, + { + "id": 123, + "change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", + "node_type": "action", + "title": "Created execute.rs in core with generic execute_action", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:01:21.232311329-05:00", + "updated_at": "2026-01-06T12:01:21.232311329-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 124, + "change_id": "51a84f63-8720-4a90-97af-50c9212d14d6", + "node_type": "outcome", + "title": "Core execute.rs complete with 75 tests passing", + "description": null, + "status": "pending", + "created_at": "2026-01-06T12:01:52.561290209-05:00", + "updated_at": "2026-01-06T12:01:52.561290209-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -2718,6 +2784,72 @@ "weight": 1.0, "rationale": "API refinement", "created_at": "2026-01-06T11:35:39.734624305-05:00" + }, + { + "id": 132, + "from_node_id": 81, + "to_node_id": 119, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "core actions support browser crate", + "created_at": "2026-01-06T11:48:48.924160167-05:00" + }, + { + "id": 133, + "from_node_id": 116, + "to_node_id": 119, + "from_change_id": "cd5cc522-d717-43a7-b116-94f86845db10", + "to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "pattern applied: generic logic in core", + "created_at": "2026-01-06T11:48:48.940647360-05:00" + }, + { + "id": 134, + "from_node_id": 119, + "to_node_id": 120, + "from_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", + "to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "text helpers support action execution", + "created_at": "2026-01-06T11:54:09.820070547-05:00" + }, + { + "id": 135, + "from_node_id": 81, + "to_node_id": 120, + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", + "to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "browser crate extraction progress", + "created_at": "2026-01-06T11:54:09.837098743-05:00" + }, + { + "id": 136, + "from_node_id": 122, + "to_node_id": 123, + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", + "to_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Part of extracting action execution", + "created_at": "2026-01-06T12:01:21.248171774-05:00" + }, + { + "id": 137, + "from_node_id": 123, + "to_node_id": 124, + "from_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", + "to_change_id": "51a84f63-8720-4a90-97af-50c9212d14d6", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Execute action implementation outcome", + "created_at": "2026-01-06T12:01:52.577885198-05:00" } ] } \ No newline at end of file