···1//! Browser event handling for the editor.
2//!
3-//! Handles beforeinput, keydown, paste, and other DOM events.
045-// TODO: Migrate from weaver-app (beforeinput.rs, input.rs)
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1//! Browser event handling for the editor.
2//!
3+//! Provides browser-specific event extraction and input type parsing for
4+//! the `beforeinput` event and other DOM events.
56+use wasm_bindgen::prelude::*;
7+use weaver_editor_core::{InputType, OffsetMapping, Range};
8+9+use crate::dom_sync::dom_position_to_text_offset;
10+11+// === StaticRange binding ===
12+//
13+// Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it.
14+// StaticRange is returned by InputEvent.getTargetRanges() and represents
15+// a fixed range that doesn't update when the DOM changes.
16+17+#[wasm_bindgen]
18+extern "C" {
19+ /// The StaticRange interface represents a static range of text in the DOM.
20+ pub type StaticRange;
21+22+ #[wasm_bindgen(method, getter, structural)]
23+ pub fn startContainer(this: &StaticRange) -> web_sys::Node;
24+25+ #[wasm_bindgen(method, getter, structural)]
26+ pub fn startOffset(this: &StaticRange) -> u32;
27+28+ #[wasm_bindgen(method, getter, structural)]
29+ pub fn endContainer(this: &StaticRange) -> web_sys::Node;
30+31+ #[wasm_bindgen(method, getter, structural)]
32+ pub fn endOffset(this: &StaticRange) -> u32;
33+34+ #[wasm_bindgen(method, getter, structural)]
35+ pub fn collapsed(this: &StaticRange) -> bool;
36+}
37+38+// === InputType browser parsing ===
39+40+/// Parse a browser inputType string to an InputType enum.
41+///
42+/// This handles the W3C Input Events inputType values as returned by
43+/// `InputEvent.inputType` in browsers.
44+pub fn parse_browser_input_type(s: &str) -> InputType {
45+ match s {
46+ // Insertion
47+ "insertText" => InputType::InsertText,
48+ "insertCompositionText" => InputType::InsertCompositionText,
49+ "insertLineBreak" => InputType::InsertLineBreak,
50+ "insertParagraph" => InputType::InsertParagraph,
51+ "insertFromPaste" => InputType::InsertFromPaste,
52+ "insertFromDrop" => InputType::InsertFromDrop,
53+ "insertReplacementText" => InputType::InsertReplacementText,
54+ "insertFromYank" => InputType::InsertFromYank,
55+ "insertHorizontalRule" => InputType::InsertHorizontalRule,
56+ "insertOrderedList" => InputType::InsertOrderedList,
57+ "insertUnorderedList" => InputType::InsertUnorderedList,
58+ "insertLink" => InputType::InsertLink,
59+60+ // Deletion
61+ "deleteContentBackward" => InputType::DeleteContentBackward,
62+ "deleteContentForward" => InputType::DeleteContentForward,
63+ "deleteWordBackward" => InputType::DeleteWordBackward,
64+ "deleteWordForward" => InputType::DeleteWordForward,
65+ "deleteSoftLineBackward" => InputType::DeleteSoftLineBackward,
66+ "deleteSoftLineForward" => InputType::DeleteSoftLineForward,
67+ "deleteHardLineBackward" => InputType::DeleteHardLineBackward,
68+ "deleteHardLineForward" => InputType::DeleteHardLineForward,
69+ "deleteByCut" => InputType::DeleteByCut,
70+ "deleteByDrag" => InputType::DeleteByDrag,
71+ "deleteContent" => InputType::DeleteContent,
72+ "deleteEntireSoftLine" => InputType::DeleteSoftLineBackward,
73+ "deleteEntireWordBackward" => InputType::DeleteEntireWordBackward,
74+ "deleteEntireWordForward" => InputType::DeleteEntireWordForward,
75+76+ // History
77+ "historyUndo" => InputType::HistoryUndo,
78+ "historyRedo" => InputType::HistoryRedo,
79+80+ // Formatting
81+ "formatBold" => InputType::FormatBold,
82+ "formatItalic" => InputType::FormatItalic,
83+ "formatUnderline" => InputType::FormatUnderline,
84+ "formatStrikethrough" => InputType::FormatStrikethrough,
85+ "formatSuperscript" => InputType::FormatSuperscript,
86+ "formatSubscript" => InputType::FormatSubscript,
87+88+ // Unknown
89+ other => InputType::Unknown(other.to_string()),
90+ }
91+}
92+93+// === BeforeInput event handling ===
94+95+/// Result of handling a beforeinput event.
96+#[derive(Debug, Clone)]
97+pub enum BeforeInputResult {
98+ /// Event was handled, prevent default browser behavior.
99+ Handled,
100+ /// Event should be handled by browser (e.g., during composition).
101+ PassThrough,
102+ /// Event was handled but requires async follow-up (e.g., paste).
103+ HandledAsync,
104+ /// Android backspace workaround: defer and check if browser handled it.
105+ DeferredCheck {
106+ /// The action to execute if browser didn't handle it.
107+ fallback_action: weaver_editor_core::EditorAction,
108+ },
109+}
110+111+/// Context for beforeinput handling.
112+pub struct BeforeInputContext<'a> {
113+ /// The input type.
114+ pub input_type: InputType,
115+ /// The data (text to insert, if any).
116+ pub data: Option<String>,
117+ /// Target range from getTargetRanges(), if available.
118+ pub target_range: Option<Range>,
119+ /// Whether the event is part of an IME composition.
120+ pub is_composing: bool,
121+ /// Whether we're on Android.
122+ pub is_android: bool,
123+ /// Whether we're on Chrome.
124+ pub is_chrome: bool,
125+ /// Offset mappings for the document.
126+ pub offset_map: &'a [OffsetMapping],
127+}
128+129+/// Extract target range from a beforeinput event.
130+///
131+/// Uses getTargetRanges() to get the browser's intended range for this operation.
132+pub fn get_target_range_from_event(
133+ event: &web_sys::InputEvent,
134+ editor_id: &str,
135+ offset_map: &[OffsetMapping],
136+) -> Option<Range> {
137+ use wasm_bindgen::JsCast;
138+139+ let ranges = event.get_target_ranges();
140+ if ranges.length() == 0 {
141+ return None;
142+ }
143+144+ let static_range: StaticRange = ranges.get(0).unchecked_into();
145+146+ let window = web_sys::window()?;
147+ let dom_document = window.document()?;
148+ let editor_element = dom_document.get_element_by_id(editor_id)?;
149+150+ let start_container = static_range.startContainer();
151+ let start_offset = static_range.startOffset() as usize;
152+ let end_container = static_range.endContainer();
153+ let end_offset = static_range.endOffset() as usize;
154+155+ let start = dom_position_to_text_offset(
156+ &dom_document,
157+ &editor_element,
158+ &start_container,
159+ start_offset,
160+ offset_map,
161+ None,
162+ )?;
163+164+ let end = dom_position_to_text_offset(
165+ &dom_document,
166+ &editor_element,
167+ &end_container,
168+ end_offset,
169+ offset_map,
170+ None,
171+ )?;
172+173+ Some(Range::new(start, end))
174+}
175+176+/// Get data from a beforeinput event, handling different sources.
177+pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> {
178+ // First try the data property.
179+ if let Some(data) = event.data() {
180+ if !data.is_empty() {
181+ return Some(data);
182+ }
183+ }
184+185+ // For paste/drop, try dataTransfer.
186+ if let Some(data_transfer) = event.data_transfer() {
187+ if let Ok(text) = data_transfer.get_data("text/plain") {
188+ if !text.is_empty() {
189+ return Some(text);
190+ }
191+ }
192+ }
193+194+ None
195+}
196+197+/// Get input type from a beforeinput event.
198+pub fn get_input_type_from_event(event: &web_sys::InputEvent) -> InputType {
199+ parse_browser_input_type(&event.input_type())
200+}
201+202+/// Check if the beforeinput event is during IME composition.
203+pub fn is_composing(event: &web_sys::InputEvent) -> bool {
204+ event.is_composing()
205+}
206+207+// === Clipboard helpers ===
208+209+/// Write text to clipboard with both text/plain and custom MIME type.
210+pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), JsValue> {
211+ use js_sys::{Array, Object, Reflect};
212+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
213+214+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
215+ let navigator = window.navigator();
216+ let clipboard = navigator.clipboard();
217+218+ let text_parts = Array::new();
219+ text_parts.push(&JsValue::from_str(text));
220+221+ let text_opts = BlobPropertyBag::new();
222+ text_opts.set_type("text/plain");
223+ let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
224+225+ let custom_opts = BlobPropertyBag::new();
226+ custom_opts.set_type("text/markdown");
227+ let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?;
228+229+ let item_data = Object::new();
230+ Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
231+ Reflect::set(
232+ &item_data,
233+ &JsValue::from_str("text/markdown"),
234+ &custom_blob,
235+ )?;
236+237+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
238+ let items = Array::new();
239+ items.push(&clipboard_item);
240+241+ let promise = clipboard.write(&items);
242+ wasm_bindgen_futures::JsFuture::from(promise).await?;
243+244+ Ok(())
245+}
246+247+/// Read text from clipboard.
248+pub async fn read_clipboard_text() -> Result<Option<String>, JsValue> {
249+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
250+ let navigator = window.navigator();
251+ let clipboard = navigator.clipboard();
252+253+ let promise = clipboard.read_text();
254+ let result: JsValue = wasm_bindgen_futures::JsFuture::from(promise).await?;
255+256+ Ok(result.as_string())
257+}
+11-5
crates/weaver-editor-browser/src/lib.rs
···8//!
9//! - `cursor`: Selection API handling and cursor restoration
10//! - `dom_sync`: DOM ↔ document state synchronization
11-//! - `events`: beforeinput, keydown, paste event handlers
12-//! - `contenteditable`: Editor element setup and management
13//! - `platform`: Browser/OS detection for platform-specific behavior
14//!
15//! # Re-exports
···29// Browser cursor implementation
30pub use cursor::BrowserCursor;
31000000000032// Platform detection
33pub use platform::{Platform, platform};
34-35-// TODO: contenteditable module
36-// TODO: embed worker module
···8//!
9//! - `cursor`: Selection API handling and cursor restoration
10//! - `dom_sync`: DOM ↔ document state synchronization
11+//! - `events`: beforeinput event handling and clipboard helpers
012//! - `platform`: Browser/OS detection for platform-specific behavior
13//!
14//! # Re-exports
···28// Browser cursor implementation
29pub use cursor::BrowserCursor;
3031+// DOM sync types
32+pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData};
33+34+// Event handling
35+pub use events::{
36+ BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event,
37+ get_input_type_from_event, get_target_range_from_event, is_composing,
38+ parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type,
39+};
40+41// Platform detection
42pub use platform::{Platform, platform};
000
···1+//! Action execution for editor documents.
2+//!
3+//! This module provides the `execute_action` function that applies `EditorAction`
4+//! operations to any type implementing `EditorDocument`. The logic is generic
5+//! and platform-agnostic.
6+7+use crate::actions::{EditorAction, Range};
8+use crate::document::EditorDocument;
9+use crate::text_helpers::{
10+ ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward,
11+ find_word_boundary_forward, is_list_item_empty,
12+};
13+use crate::types::Selection;
14+15+/// Execute an editor action on a document.
16+///
17+/// This is the central dispatch point for all editor operations.
18+/// Returns true if the action was handled and the document was modified.
19+pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool {
20+ match action {
21+ EditorAction::Insert { text, range } => execute_insert(doc, text, *range),
22+ EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range),
23+ EditorAction::InsertParagraph { range } => execute_insert_paragraph(doc, *range),
24+ EditorAction::DeleteBackward { range } => execute_delete_backward(doc, *range),
25+ EditorAction::DeleteForward { range } => execute_delete_forward(doc, *range),
26+ EditorAction::DeleteWordBackward { range } => execute_delete_word_backward(doc, *range),
27+ EditorAction::DeleteWordForward { range } => execute_delete_word_forward(doc, *range),
28+ EditorAction::DeleteToLineStart { range } => execute_delete_to_line_start(doc, *range),
29+ EditorAction::DeleteToLineEnd { range } => execute_delete_to_line_end(doc, *range),
30+ EditorAction::DeleteSoftLineBackward { range } => {
31+ execute_action(doc, &EditorAction::DeleteToLineStart { range: *range })
32+ }
33+ EditorAction::DeleteSoftLineForward { range } => {
34+ execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range })
35+ }
36+ EditorAction::Undo => execute_undo(doc),
37+ EditorAction::Redo => execute_redo(doc),
38+ EditorAction::ToggleBold => execute_toggle_format(doc, "**"),
39+ EditorAction::ToggleItalic => execute_toggle_format(doc, "*"),
40+ EditorAction::ToggleCode => execute_toggle_format(doc, "`"),
41+ EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"),
42+ EditorAction::InsertLink => execute_insert_link(doc),
43+ EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => {
44+ // Clipboard operations are handled by platform layer.
45+ false
46+ }
47+ EditorAction::Paste { range: _ } => {
48+ // Paste is handled by platform layer with clipboard access.
49+ false
50+ }
51+ EditorAction::SelectAll => execute_select_all(doc),
52+ EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset),
53+ EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset),
54+ }
55+}
56+57+fn execute_insert<D: EditorDocument>(doc: &mut D, text: &str, range: Range) -> bool {
58+ let range = range.normalize();
59+60+ // Clean up any preceding zero-width chars.
61+ let mut delete_start = range.start;
62+ while delete_start > 0 {
63+ match doc.char_at(delete_start - 1) {
64+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
65+ _ => break,
66+ }
67+ }
68+69+ let zw_count = range.start - delete_start;
70+71+ if range.is_caret() {
72+ if zw_count > 0 {
73+ doc.replace(delete_start..range.start, text);
74+ } else if range.start == doc.len_chars() {
75+ doc.insert(range.start, text);
76+ } else {
77+ doc.insert(range.start, text);
78+ }
79+ } else {
80+ // Replace selection.
81+ if zw_count > 0 {
82+ // Delete zero-width chars before selection start too.
83+ doc.replace(delete_start..range.end, text);
84+ } else {
85+ doc.replace(range.start..range.end, text);
86+ }
87+ }
88+89+ doc.set_selection(None);
90+ true
91+}
92+93+fn execute_insert_line_break<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
94+ let range = range.normalize();
95+ let offset = range.start;
96+97+ // Delete selection if any.
98+ if !range.is_caret() {
99+ doc.delete(offset..range.end);
100+ }
101+102+ // Check if we're right after a soft break (newline + zero-width char).
103+ let is_double_enter = if offset >= 2 {
104+ let prev_char = doc.char_at(offset - 1);
105+ let prev_prev_char = doc.char_at(offset - 2);
106+ prev_char == Some('\u{200C}') && prev_prev_char == Some('\n')
107+ } else {
108+ false
109+ };
110+111+ if !is_double_enter {
112+ // Check for list context.
113+ if let Some(ctx) = detect_list_context(doc, offset) {
114+ if is_list_item_empty(doc, offset, &ctx) {
115+ // Empty item - exit list.
116+ let line_start = find_line_start(doc, offset);
117+ let line_end = find_line_end(doc, offset);
118+ let delete_end = (line_end + 1).min(doc.len_chars());
119+ doc.replace(line_start..delete_end, "\n\n\u{200C}\n");
120+ doc.set_cursor_offset(line_start + 2);
121+ } else {
122+ // Continue list.
123+ let continuation = list_continuation(&ctx);
124+ let len = continuation.chars().count();
125+ doc.insert(offset, &continuation);
126+ doc.set_cursor_offset(offset + len);
127+ }
128+ } else {
129+ // Normal soft break: insert newline + zero-width char.
130+ doc.insert(offset, "\n\u{200C}");
131+ doc.set_cursor_offset(offset + 2);
132+ }
133+ } else {
134+ // Replace zero-width char with newline.
135+ doc.replace(offset - 1..offset, "\n");
136+ }
137+138+ doc.set_selection(None);
139+ true
140+}
141+142+fn execute_insert_paragraph<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
143+ let range = range.normalize();
144+ let cursor_offset = range.start;
145+146+ // Delete selection if any.
147+ if !range.is_caret() {
148+ doc.delete(cursor_offset..range.end);
149+ }
150+151+ // Check for list context.
152+ if let Some(ctx) = detect_list_context(doc, cursor_offset) {
153+ if is_list_item_empty(doc, cursor_offset, &ctx) {
154+ // Empty item - exit list.
155+ let line_start = find_line_start(doc, cursor_offset);
156+ let line_end = find_line_end(doc, cursor_offset);
157+ let delete_end = (line_end + 1).min(doc.len_chars());
158+ doc.replace(line_start..delete_end, "\n\n\u{200C}\n");
159+ doc.set_cursor_offset(line_start + 2);
160+ } else {
161+ // Continue list.
162+ let continuation = list_continuation(&ctx);
163+ let len = continuation.chars().count();
164+ doc.insert(cursor_offset, &continuation);
165+ doc.set_cursor_offset(cursor_offset + len);
166+ }
167+ } else {
168+ // Normal paragraph break.
169+ doc.insert(cursor_offset, "\n\n");
170+ doc.set_cursor_offset(cursor_offset + 2);
171+ }
172+173+ doc.set_selection(None);
174+ true
175+}
176+177+fn execute_delete_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
178+ let range = range.normalize();
179+180+ if !range.is_caret() {
181+ // Delete selection.
182+ doc.delete(range.start..range.end);
183+ return true;
184+ }
185+186+ if range.start == 0 {
187+ return false;
188+ }
189+190+ let cursor_offset = range.start;
191+ let prev_char = doc.char_at(cursor_offset - 1);
192+193+ if prev_char == Some('\n') {
194+ // Deleting a newline - handle paragraph merging.
195+ let newline_pos = cursor_offset - 1;
196+ let mut delete_start = newline_pos;
197+ let mut delete_end = cursor_offset;
198+199+ // Check for empty paragraph (double newline).
200+ if newline_pos > 0 && doc.char_at(newline_pos - 1) == Some('\n') {
201+ delete_start = newline_pos - 1;
202+ }
203+204+ // Check for trailing zero-width char.
205+ if let Some(ch) = doc.char_at(delete_end) {
206+ if ch == '\u{200C}' || ch == '\u{200B}' {
207+ delete_end += 1;
208+ }
209+ }
210+211+ // Scan backwards through zero-width chars.
212+ while delete_start > 0 {
213+ match doc.char_at(delete_start - 1) {
214+ Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
215+ Some('\n') | _ => break,
216+ }
217+ }
218+219+ doc.delete(delete_start..delete_end);
220+ } else {
221+ // Normal single char delete.
222+ doc.delete(cursor_offset - 1..cursor_offset);
223+ }
224+225+ doc.set_selection(None);
226+ true
227+}
228+229+fn execute_delete_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
230+ let range = range.normalize();
231+232+ if !range.is_caret() {
233+ doc.delete(range.start..range.end);
234+ return true;
235+ }
236+237+ if range.start >= doc.len_chars() {
238+ return false;
239+ }
240+241+ doc.delete(range.start..range.start + 1);
242+ doc.set_selection(None);
243+ true
244+}
245+246+fn execute_delete_word_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
247+ let range = range.normalize();
248+249+ if !range.is_caret() {
250+ doc.delete(range.start..range.end);
251+ return true;
252+ }
253+254+ let cursor = range.start;
255+ let word_start = find_word_boundary_backward(doc, cursor);
256+ if word_start < cursor {
257+ doc.delete(word_start..cursor);
258+ }
259+260+ doc.set_selection(None);
261+ true
262+}
263+264+fn execute_delete_word_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
265+ let range = range.normalize();
266+267+ if !range.is_caret() {
268+ doc.delete(range.start..range.end);
269+ return true;
270+ }
271+272+ let cursor = range.start;
273+ let word_end = find_word_boundary_forward(doc, cursor);
274+ if word_end > cursor {
275+ doc.delete(cursor..word_end);
276+ }
277+278+ doc.set_selection(None);
279+ true
280+}
281+282+fn execute_delete_to_line_start<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
283+ let range = range.normalize();
284+ let cursor = range.start;
285+ let line_start = find_line_start(doc, cursor);
286+287+ if line_start < cursor {
288+ doc.delete(line_start..cursor);
289+ }
290+291+ doc.set_selection(None);
292+ true
293+}
294+295+fn execute_delete_to_line_end<D: EditorDocument>(doc: &mut D, range: Range) -> bool {
296+ let range = range.normalize();
297+ let cursor = if range.is_caret() {
298+ range.start
299+ } else {
300+ range.end
301+ };
302+ let line_end = find_line_end(doc, cursor);
303+304+ if cursor < line_end {
305+ doc.delete(cursor..line_end);
306+ }
307+308+ doc.set_selection(None);
309+ true
310+}
311+312+fn execute_undo<D: EditorDocument>(doc: &mut D) -> bool {
313+ if doc.undo() {
314+ let max = doc.len_chars();
315+ let cursor = doc.cursor();
316+ if cursor.offset > max {
317+ doc.set_cursor_offset(max);
318+ }
319+ doc.set_selection(None);
320+ true
321+ } else {
322+ false
323+ }
324+}
325+326+fn execute_redo<D: EditorDocument>(doc: &mut D) -> bool {
327+ if doc.redo() {
328+ let max = doc.len_chars();
329+ let cursor = doc.cursor();
330+ if cursor.offset > max {
331+ doc.set_cursor_offset(max);
332+ }
333+ doc.set_selection(None);
334+ true
335+ } else {
336+ false
337+ }
338+}
339+340+fn execute_toggle_format<D: EditorDocument>(doc: &mut D, marker: &str) -> bool {
341+ let cursor_offset = doc.cursor_offset();
342+ let (start, end) = if let Some(sel) = doc.selection() {
343+ (sel.start(), sel.end())
344+ } else {
345+ find_word_boundaries(doc, cursor_offset)
346+ };
347+348+ // Insert end marker first so start position stays valid.
349+ doc.insert(end, marker);
350+ doc.insert(start, marker);
351+ doc.set_cursor_offset(end + marker.len() * 2);
352+ doc.set_selection(None);
353+ true
354+}
355+356+fn execute_insert_link<D: EditorDocument>(doc: &mut D) -> bool {
357+ let cursor_offset = doc.cursor_offset();
358+ let (start, end) = if let Some(sel) = doc.selection() {
359+ (sel.start(), sel.end())
360+ } else {
361+ find_word_boundaries(doc, cursor_offset)
362+ };
363+364+ // Insert [selected text](url)
365+ doc.insert(end, "](url)");
366+ doc.insert(start, "[");
367+ doc.set_cursor_offset(end + 8);
368+ doc.set_selection(None);
369+ true
370+}
371+372+fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool {
373+ let len = doc.len_chars();
374+ doc.set_selection(Some(Selection::new(0, len)));
375+ doc.set_cursor_offset(len);
376+ true
377+}
378+379+fn execute_move_cursor<D: EditorDocument>(doc: &mut D, offset: usize) -> bool {
380+ let offset = offset.min(doc.len_chars());
381+ doc.set_cursor_offset(offset);
382+ doc.set_selection(None);
383+ true
384+}
385+386+fn execute_extend_selection<D: EditorDocument>(doc: &mut D, offset: usize) -> bool {
387+ let offset = offset.min(doc.len_chars());
388+ let anchor = doc
389+ .selection()
390+ .map(|s| s.anchor)
391+ .unwrap_or_else(|| doc.cursor_offset());
392+ doc.set_selection(Some(Selection::new(anchor, offset)));
393+ doc.set_cursor_offset(offset);
394+ true
395+}
396+397+/// Find word boundaries around cursor position.
398+fn find_word_boundaries<D: EditorDocument>(doc: &D, offset: usize) -> (usize, usize) {
399+ let len = doc.len_chars();
400+401+ // Find start by scanning backwards.
402+ let mut start = 0;
403+ for i in (0..offset).rev() {
404+ match doc.char_at(i) {
405+ Some(c) if c.is_whitespace() => {
406+ start = i + 1;
407+ break;
408+ }
409+ Some(_) => continue,
410+ None => break,
411+ }
412+ }
413+414+ // Find end by scanning forwards.
415+ let mut end = len;
416+ for i in offset..len {
417+ match doc.char_at(i) {
418+ Some(c) if c.is_whitespace() => {
419+ end = i;
420+ break;
421+ }
422+ Some(_) => continue,
423+ None => break,
424+ }
425+ }
426+427+ (start, end)
428+}
429+430+/// Generate list continuation text.
431+fn list_continuation(ctx: &ListContext) -> String {
432+ match ctx {
433+ ListContext::Unordered { indent, marker } => {
434+ format!("\n{}{} ", indent, marker)
435+ }
436+ ListContext::Ordered { indent, number } => {
437+ format!("\n{}{}. ", indent, number + 1)
438+ }
439+ }
440+}
441+442+#[cfg(test)]
443+mod tests {
444+ use super::*;
445+ use crate::{EditorRope, PlainEditor, UndoableBuffer};
446+447+ type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>;
448+449+ fn make_editor(content: &str) -> TestEditor {
450+ let rope = EditorRope::from_str(content);
451+ let buf = UndoableBuffer::new(rope, 100);
452+ PlainEditor::new(buf)
453+ }
454+455+ #[test]
456+ fn test_insert() {
457+ let mut editor = make_editor("hello");
458+ let action = EditorAction::Insert {
459+ text: " world".to_string(),
460+ range: Range::caret(5),
461+ };
462+ assert!(execute_action(&mut editor, &action));
463+ assert_eq!(editor.content_string(), "hello world");
464+ }
465+466+ #[test]
467+ fn test_delete_backward() {
468+ let mut editor = make_editor("hello");
469+ editor.set_cursor_offset(5);
470+ let action = EditorAction::DeleteBackward {
471+ range: Range::caret(5),
472+ };
473+ assert!(execute_action(&mut editor, &action));
474+ assert_eq!(editor.content_string(), "hell");
475+ }
476+477+ #[test]
478+ fn test_delete_selection() {
479+ let mut editor = make_editor("hello world");
480+ editor.set_selection(Some(Selection::new(5, 11)));
481+ let action = EditorAction::DeleteBackward {
482+ range: Range::new(5, 11),
483+ };
484+ assert!(execute_action(&mut editor, &action));
485+ assert_eq!(editor.content_string(), "hello");
486+ }
487+488+ #[test]
489+ fn test_undo_redo() {
490+ let mut editor = make_editor("hello");
491+492+ let action = EditorAction::Insert {
493+ text: " world".to_string(),
494+ range: Range::caret(5),
495+ };
496+ execute_action(&mut editor, &action);
497+ assert_eq!(editor.content_string(), "hello world");
498+499+ assert!(execute_action(&mut editor, &EditorAction::Undo));
500+ assert_eq!(editor.content_string(), "hello");
501+502+ assert!(execute_action(&mut editor, &EditorAction::Redo));
503+ assert_eq!(editor.content_string(), "hello world");
504+ }
505+506+ #[test]
507+ fn test_select_all() {
508+ let mut editor = make_editor("hello world");
509+ assert!(execute_action(&mut editor, &EditorAction::SelectAll));
510+ let sel = editor.selection().unwrap();
511+ assert_eq!(sel.start(), 0);
512+ assert_eq!(sel.end(), 11);
513+ }
514+515+ #[test]
516+ fn test_toggle_bold() {
517+ let mut editor = make_editor("hello");
518+ editor.set_selection(Some(Selection::new(0, 5)));
519+ assert!(execute_action(&mut editor, &EditorAction::ToggleBold));
520+ assert_eq!(editor.content_string(), "**hello**");
521+ }
522+}
+13
crates/weaver-editor-core/src/lib.rs
···6//! - `UndoableBuffer<T>` - TextBuffer wrapper with undo/redo
7//! - `EditorDocument` trait - interface for editor implementations
8//! - `PlainEditor<T>` - simple field-based EditorDocument impl
09//! - Rendering types and offset mapping utilities
10011pub mod document;
012pub mod offset_map;
13pub mod paragraph;
14pub mod platform;
15pub mod render;
16pub mod syntax;
17pub mod text;
018pub mod types;
19pub mod undo;
20pub mod visibility;
···38pub use visibility::VisibilityState;
39pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
40pub use platform::{CursorPlatform, CursorSync, PlatformError};
000000000
···6//! - `UndoableBuffer<T>` - TextBuffer wrapper with undo/redo
7//! - `EditorDocument` trait - interface for editor implementations
8//! - `PlainEditor<T>` - simple field-based EditorDocument impl
9+//! - `EditorAction`, `InputType`, `Key` - platform-agnostic input/action types
10//! - Rendering types and offset mapping utilities
1112+pub mod actions;
13pub mod document;
14+pub mod execute;
15pub mod offset_map;
16pub mod paragraph;
17pub mod platform;
18pub mod render;
19pub mod syntax;
20pub mod text;
21+pub mod text_helpers;
22pub mod types;
23pub mod undo;
24pub mod visibility;
···42pub use visibility::VisibilityState;
43pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult};
44pub use platform::{CursorPlatform, CursorSync, PlatformError};
45+pub use actions::{
46+ EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range,
47+};
48+pub use execute::execute_action;
49+pub use text_helpers::{
50+ ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start,
51+ find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
52+ is_zero_width_char,
53+};