at main 671 lines 24 kB view raw
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. 5 6use wasm_bindgen::prelude::*; 7use weaver_editor_core::{InputType, ParagraphRender, Range}; 8 9use crate::dom_sync::dom_position_to_text_offset; 10use crate::platform::Platform; 11 12// === StaticRange binding === 13// 14// Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it. 15// StaticRange is returned by InputEvent.getTargetRanges() and represents 16// a fixed range that doesn't update when the DOM changes. 17 18#[wasm_bindgen] 19extern "C" { 20 /// The StaticRange interface represents a static range of text in the DOM. 21 pub type StaticRange; 22 23 #[wasm_bindgen(method, getter, structural)] 24 pub fn startContainer(this: &StaticRange) -> web_sys::Node; 25 26 #[wasm_bindgen(method, getter, structural)] 27 pub fn startOffset(this: &StaticRange) -> u32; 28 29 #[wasm_bindgen(method, getter, structural)] 30 pub fn endContainer(this: &StaticRange) -> web_sys::Node; 31 32 #[wasm_bindgen(method, getter, structural)] 33 pub fn endOffset(this: &StaticRange) -> u32; 34 35 #[wasm_bindgen(method, getter, structural)] 36 pub fn collapsed(this: &StaticRange) -> bool; 37} 38 39// === InputType browser parsing === 40 41/// Parse a browser inputType string to an InputType enum. 42/// 43/// This handles the W3C Input Events inputType values as returned by 44/// `InputEvent.inputType` in browsers. 45pub fn parse_browser_input_type(s: &str) -> InputType { 46 match s { 47 // Insertion 48 "insertText" => InputType::InsertText, 49 "insertCompositionText" => InputType::InsertCompositionText, 50 "insertLineBreak" => InputType::InsertLineBreak, 51 "insertParagraph" => InputType::InsertParagraph, 52 "insertFromPaste" => InputType::InsertFromPaste, 53 "insertFromDrop" => InputType::InsertFromDrop, 54 "insertReplacementText" => InputType::InsertReplacementText, 55 "insertFromYank" => InputType::InsertFromYank, 56 "insertHorizontalRule" => InputType::InsertHorizontalRule, 57 "insertOrderedList" => InputType::InsertOrderedList, 58 "insertUnorderedList" => InputType::InsertUnorderedList, 59 "insertLink" => InputType::InsertLink, 60 61 // Deletion 62 "deleteContentBackward" => InputType::DeleteContentBackward, 63 "deleteContentForward" => InputType::DeleteContentForward, 64 "deleteWordBackward" => InputType::DeleteWordBackward, 65 "deleteWordForward" => InputType::DeleteWordForward, 66 "deleteSoftLineBackward" => InputType::DeleteSoftLineBackward, 67 "deleteSoftLineForward" => InputType::DeleteSoftLineForward, 68 "deleteHardLineBackward" => InputType::DeleteHardLineBackward, 69 "deleteHardLineForward" => InputType::DeleteHardLineForward, 70 "deleteByCut" => InputType::DeleteByCut, 71 "deleteByDrag" => InputType::DeleteByDrag, 72 "deleteContent" => InputType::DeleteContent, 73 "deleteEntireSoftLine" => InputType::DeleteSoftLineBackward, 74 "deleteEntireWordBackward" => InputType::DeleteEntireWordBackward, 75 "deleteEntireWordForward" => InputType::DeleteEntireWordForward, 76 77 // History 78 "historyUndo" => InputType::HistoryUndo, 79 "historyRedo" => InputType::HistoryRedo, 80 81 // Formatting 82 "formatBold" => InputType::FormatBold, 83 "formatItalic" => InputType::FormatItalic, 84 "formatUnderline" => InputType::FormatUnderline, 85 "formatStrikethrough" => InputType::FormatStrikethrough, 86 "formatSuperscript" => InputType::FormatSuperscript, 87 "formatSubscript" => InputType::FormatSubscript, 88 89 // Unknown 90 other => InputType::Unknown(other.to_string()), 91 } 92} 93 94// === BeforeInput event handling === 95 96/// Result of handling a beforeinput event. 97#[derive(Debug, Clone)] 98pub enum BeforeInputResult { 99 /// Event was handled, prevent default browser behavior. 100 Handled, 101 /// Event should be handled by browser (e.g., during composition). 102 PassThrough, 103 /// Event was handled but requires async follow-up (e.g., paste). 104 HandledAsync, 105 /// Android backspace workaround: defer and check if browser handled it. 106 DeferredCheck { 107 /// The action to execute if browser didn't handle it. 108 fallback_action: weaver_editor_core::EditorAction, 109 }, 110} 111 112/// Context for beforeinput handling. 113pub struct BeforeInputContext<'a> { 114 /// The input type. 115 pub input_type: InputType, 116 /// The data (text to insert, if any). 117 pub data: Option<String>, 118 /// Target range from getTargetRanges(), if available. 119 /// This is the range the browser wants to modify. 120 pub target_range: Option<Range>, 121 /// Whether the event is part of an IME composition. 122 pub is_composing: bool, 123 /// Platform info for quirks handling. 124 pub platform: &'a Platform, 125} 126 127/// Extract target range from a beforeinput event. 128/// 129/// Uses getTargetRanges() to get the browser's intended range for this operation. 130pub fn get_target_range_from_event( 131 event: &web_sys::InputEvent, 132 editor_id: &str, 133 paragraphs: &[ParagraphRender], 134) -> Option<Range> { 135 use wasm_bindgen::JsCast; 136 137 let ranges = event.get_target_ranges(); 138 if ranges.length() == 0 { 139 return None; 140 } 141 142 let static_range: StaticRange = ranges.get(0).unchecked_into(); 143 144 let window = web_sys::window()?; 145 let dom_document = window.document()?; 146 let editor_element = dom_document.get_element_by_id(editor_id)?; 147 148 let start_container = static_range.startContainer(); 149 let start_offset = static_range.startOffset() as usize; 150 let end_container = static_range.endContainer(); 151 let end_offset = static_range.endOffset() as usize; 152 153 let start = dom_position_to_text_offset( 154 &dom_document, 155 &editor_element, 156 &start_container, 157 start_offset, 158 paragraphs, 159 None, 160 )?; 161 162 let end = dom_position_to_text_offset( 163 &dom_document, 164 &editor_element, 165 &end_container, 166 end_offset, 167 paragraphs, 168 None, 169 )?; 170 171 Some(Range::new(start, end)) 172} 173 174/// Get data from a beforeinput event, handling different sources. 175pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> { 176 // First try the data property. 177 if let Some(data) = event.data() { 178 if !data.is_empty() { 179 return Some(data); 180 } 181 } 182 183 // For paste/drop, try dataTransfer. 184 if let Some(data_transfer) = event.data_transfer() { 185 if let Ok(text) = data_transfer.get_data("text/plain") { 186 if !text.is_empty() { 187 return Some(text); 188 } 189 } 190 } 191 192 None 193} 194 195/// Get input type from a beforeinput event. 196pub fn get_input_type_from_event(event: &web_sys::InputEvent) -> InputType { 197 parse_browser_input_type(&event.input_type()) 198} 199 200/// Check if the beforeinput event is during IME composition. 201pub fn is_composing(event: &web_sys::InputEvent) -> bool { 202 event.is_composing() 203} 204 205// === Clipboard helpers === 206 207/// Write text to clipboard with both text/plain and custom MIME type. 208pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), JsValue> { 209 use js_sys::{Array, Object, Reflect}; 210 use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 211 212 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 213 let navigator = window.navigator(); 214 let clipboard = navigator.clipboard(); 215 216 let text_parts = Array::new(); 217 text_parts.push(&JsValue::from_str(text)); 218 219 let text_opts = BlobPropertyBag::new(); 220 text_opts.set_type("text/plain"); 221 let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 222 223 let custom_opts = BlobPropertyBag::new(); 224 custom_opts.set_type("text/markdown"); 225 let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 226 227 let item_data = Object::new(); 228 Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 229 Reflect::set( 230 &item_data, 231 &JsValue::from_str("text/markdown"), 232 &custom_blob, 233 )?; 234 235 let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 236 let items = Array::new(); 237 items.push(&clipboard_item); 238 239 let promise = clipboard.write(&items); 240 wasm_bindgen_futures::JsFuture::from(promise).await?; 241 242 Ok(()) 243} 244 245/// Read text from clipboard. 246pub async fn read_clipboard_text() -> Result<Option<String>, JsValue> { 247 let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 248 let navigator = window.navigator(); 249 let clipboard = navigator.clipboard(); 250 251 let promise = clipboard.read_text(); 252 let result: JsValue = wasm_bindgen_futures::JsFuture::from(promise).await?; 253 254 Ok(result.as_string()) 255} 256 257// === BeforeInput handler === 258 259use crate::FORCE_INNERHTML_UPDATE; 260use weaver_editor_core::{EditorAction, EditorDocument, Selection, execute_action}; 261 262/// Get the current range (cursor or selection) from an EditorDocument. 263/// 264/// This is a convenience helper for building `BeforeInputContext`. 265pub fn get_current_range<D: EditorDocument>(doc: &D) -> Range { 266 if let Some(sel) = doc.selection() { 267 Range::new(sel.start(), sel.end()) 268 } else { 269 Range::caret(doc.cursor_offset()) 270 } 271} 272 273/// Check if a character requires special delete handling. 274/// 275/// Returns true for newlines and zero-width chars which need semantic handling 276/// rather than simple char deletion. 277fn needs_special_delete_handling(ch: Option<char>) -> bool { 278 matches!(ch, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) 279} 280 281/// Handle a beforeinput event, dispatching to the appropriate action. 282/// 283/// This is the main entry point for beforeinput-based input handling. 284/// The `current_range` parameter should be the current cursor/selection range 285/// from the document when `ctx.target_range` is None. 286/// 287/// Returns the handling result indicating whether default should be prevented. 288/// 289/// # DOM Update Strategy 290/// 291/// When [`FORCE_INNERHTML_UPDATE`] is `true`, this always returns `Handled` 292/// and the caller should preventDefault. The DOM will be updated via innerHTML. 293/// 294/// When `false`, simple operations (plain text insert, single char delete) 295/// return `PassThrough` to let the browser update the DOM while we track 296/// changes in the model. Complex operations still return `Handled`. 297pub fn handle_beforeinput<D: EditorDocument>( 298 doc: &mut D, 299 ctx: &BeforeInputContext<'_>, 300 current_range: Range, 301) -> BeforeInputResult { 302 // During composition, let the browser handle most things. 303 if ctx.is_composing { 304 match ctx.input_type { 305 InputType::HistoryUndo | InputType::HistoryRedo => { 306 // Handle undo/redo even during composition. 307 } 308 InputType::InsertCompositionText => { 309 return BeforeInputResult::PassThrough; 310 } 311 _ => { 312 return BeforeInputResult::PassThrough; 313 } 314 } 315 } 316 317 // Use target range from event, or fall back to current range. 318 let range = ctx.target_range.unwrap_or(current_range); 319 320 match ctx.input_type { 321 // === Insertion === 322 InputType::InsertText => { 323 if let Some(ref text) = ctx.data { 324 let action = EditorAction::Insert { 325 text: text.clone(), 326 range, 327 }; 328 execute_action(doc, &action); 329 330 // When FORCE_INNERHTML_UPDATE is false, we can let browser handle 331 // DOM updates for simple text insertions while we just track in model. 332 if FORCE_INNERHTML_UPDATE { 333 BeforeInputResult::Handled 334 } else { 335 BeforeInputResult::PassThrough 336 } 337 } else { 338 BeforeInputResult::PassThrough 339 } 340 } 341 342 InputType::InsertLineBreak => { 343 let action = EditorAction::InsertLineBreak { range }; 344 execute_action(doc, &action); 345 BeforeInputResult::Handled 346 } 347 348 InputType::InsertParagraph => { 349 let action = EditorAction::InsertParagraph { range }; 350 execute_action(doc, &action); 351 BeforeInputResult::Handled 352 } 353 354 InputType::InsertFromPaste | InputType::InsertReplacementText => { 355 if let Some(ref text) = ctx.data { 356 let action = EditorAction::Insert { 357 text: text.clone(), 358 range, 359 }; 360 execute_action(doc, &action); 361 BeforeInputResult::Handled 362 } else { 363 BeforeInputResult::PassThrough 364 } 365 } 366 367 InputType::InsertFromDrop => BeforeInputResult::PassThrough, 368 369 InputType::InsertCompositionText => BeforeInputResult::PassThrough, 370 371 // === Deletion === 372 InputType::DeleteContentBackward => { 373 // Android Chrome workaround: backspace sometimes doesn't work properly. 374 if ctx.platform.android && ctx.platform.chrome && range.is_caret() { 375 let action = EditorAction::DeleteBackward { range }; 376 return BeforeInputResult::DeferredCheck { 377 fallback_action: action, 378 }; 379 } 380 381 // Check if this delete requires special handling. 382 let needs_special = if !range.is_caret() { 383 // Selection delete - always handle for consistency. 384 true 385 } else if range.start == 0 { 386 // At start - nothing to delete. 387 false 388 } else { 389 // Check what char we're deleting. 390 needs_special_delete_handling(doc.char_at(range.start - 1)) 391 }; 392 393 if needs_special || FORCE_INNERHTML_UPDATE { 394 // Complex delete or forced mode - use full action handler. 395 let action = EditorAction::DeleteBackward { range }; 396 execute_action(doc, &action); 397 BeforeInputResult::Handled 398 } else { 399 // Simple single-char delete - track in model, let browser handle DOM. 400 if range.start > 0 { 401 doc.delete(range.start - 1..range.start); 402 } 403 BeforeInputResult::PassThrough 404 } 405 } 406 407 InputType::DeleteContentForward => { 408 // Check if this delete requires special handling. 409 let needs_special = if !range.is_caret() { 410 true 411 } else if range.start >= doc.len_chars() { 412 false 413 } else { 414 needs_special_delete_handling(doc.char_at(range.start)) 415 }; 416 417 if needs_special || FORCE_INNERHTML_UPDATE { 418 let action = EditorAction::DeleteForward { range }; 419 execute_action(doc, &action); 420 BeforeInputResult::Handled 421 } else { 422 // Simple delete forward. 423 if range.start < doc.len_chars() { 424 doc.delete(range.start..range.start + 1); 425 } 426 BeforeInputResult::PassThrough 427 } 428 } 429 430 InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { 431 let action = EditorAction::DeleteWordBackward { range }; 432 execute_action(doc, &action); 433 BeforeInputResult::Handled 434 } 435 436 InputType::DeleteWordForward | InputType::DeleteEntireWordForward => { 437 let action = EditorAction::DeleteWordForward { range }; 438 execute_action(doc, &action); 439 BeforeInputResult::Handled 440 } 441 442 InputType::DeleteSoftLineBackward => { 443 let action = EditorAction::DeleteSoftLineBackward { range }; 444 execute_action(doc, &action); 445 BeforeInputResult::Handled 446 } 447 448 InputType::DeleteSoftLineForward => { 449 let action = EditorAction::DeleteSoftLineForward { range }; 450 execute_action(doc, &action); 451 BeforeInputResult::Handled 452 } 453 454 InputType::DeleteHardLineBackward => { 455 let action = EditorAction::DeleteToLineStart { range }; 456 execute_action(doc, &action); 457 BeforeInputResult::Handled 458 } 459 460 InputType::DeleteHardLineForward => { 461 let action = EditorAction::DeleteToLineEnd { range }; 462 execute_action(doc, &action); 463 BeforeInputResult::Handled 464 } 465 466 InputType::DeleteByCut => { 467 if !range.is_caret() { 468 let action = EditorAction::DeleteBackward { range }; 469 execute_action(doc, &action); 470 } 471 BeforeInputResult::Handled 472 } 473 474 InputType::DeleteByDrag | InputType::DeleteContent => { 475 if !range.is_caret() { 476 let action = EditorAction::DeleteBackward { range }; 477 execute_action(doc, &action); 478 } 479 BeforeInputResult::Handled 480 } 481 482 // === History === 483 InputType::HistoryUndo => { 484 execute_action(doc, &EditorAction::Undo); 485 BeforeInputResult::Handled 486 } 487 488 InputType::HistoryRedo => { 489 execute_action(doc, &EditorAction::Redo); 490 BeforeInputResult::Handled 491 } 492 493 // === Formatting === 494 InputType::FormatBold => { 495 execute_action(doc, &EditorAction::ToggleBold); 496 BeforeInputResult::Handled 497 } 498 499 InputType::FormatItalic => { 500 execute_action(doc, &EditorAction::ToggleItalic); 501 BeforeInputResult::Handled 502 } 503 504 InputType::FormatStrikethrough => { 505 execute_action(doc, &EditorAction::ToggleStrikethrough); 506 BeforeInputResult::Handled 507 } 508 509 // === Not handled === 510 InputType::InsertFromYank 511 | InputType::InsertHorizontalRule 512 | InputType::InsertOrderedList 513 | InputType::InsertUnorderedList 514 | InputType::InsertLink 515 | InputType::FormatUnderline 516 | InputType::FormatSuperscript 517 | InputType::FormatSubscript 518 | InputType::Unknown(_) => BeforeInputResult::PassThrough, 519 } 520} 521 522// === Math click handling === 523 524use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; 525 526/// Check if a click target is a math-clickable element. 527/// 528/// Returns the target character offset if the click was on a `.math-clickable` 529/// element with a valid `data-char-target` attribute, None otherwise. 530pub fn get_math_click_offset(target: &web_sys::EventTarget) -> Option<usize> { 531 use wasm_bindgen::JsCast; 532 533 let element = target.dyn_ref::<web_sys::Element>()?; 534 let math_el = element.closest(".math-clickable").ok()??; 535 let char_target = math_el.get_attribute("data-char-target")?; 536 char_target.parse().ok() 537} 538 539/// Handle a click that might be on a math element. 540/// 541/// If the click target is a math-clickable element, this updates the cursor, 542/// clears selection, updates visibility, and restores the DOM cursor position. 543/// 544/// Returns true if the click was handled (was on a math element), false otherwise. 545/// When this returns false, the caller should handle the click normally. 546pub fn handle_math_click<D: EditorDocument>( 547 target: &web_sys::EventTarget, 548 doc: &mut D, 549 syntax_spans: &[SyntaxSpanInfo], 550 paragraphs: &[ParagraphRender], 551 offset_map: &[OffsetMapping], 552) -> bool { 553 if let Some(offset) = get_math_click_offset(target) { 554 tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 555 doc.set_cursor_offset(offset); 556 doc.set_selection(None); 557 crate::update_syntax_visibility(offset, None, syntax_spans, paragraphs); 558 let _ = crate::restore_cursor_position(offset, offset_map, None); 559 true 560 } else { 561 false 562 } 563} 564 565// === Composition (IME) event handlers === 566 567/// Handle composition start event. 568/// 569/// Clears any existing selection (composition replaces it) and sets up 570/// composition state tracking. 571#[cfg(feature = "dioxus")] 572pub fn handle_compositionstart<D: EditorDocument>( 573 evt: dioxus_core::Event<dioxus_html::CompositionData>, 574 doc: &mut D, 575) { 576 let data = evt.data().data(); 577 tracing::trace!(data = %data, "compositionstart"); 578 579 // Delete selection if present (composition replaces it). 580 if let Some(sel) = doc.selection() { 581 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 582 tracing::trace!(start, end, "compositionstart: deleting selection"); 583 doc.delete(start..end); 584 doc.set_cursor_offset(start); 585 doc.set_selection(None); 586 } 587 588 let cursor_offset = doc.cursor_offset(); 589 tracing::trace!(cursor = cursor_offset, "compositionstart: setting composition state"); 590 doc.set_composition(Some(weaver_editor_core::CompositionState { 591 start_offset: cursor_offset, 592 text: data, 593 })); 594} 595 596/// Handle composition update event. 597/// 598/// Updates the composition text as the user types or selects IME suggestions. 599#[cfg(feature = "dioxus")] 600pub fn handle_compositionupdate<D: EditorDocument>( 601 evt: dioxus_core::Event<dioxus_html::CompositionData>, 602 doc: &mut D, 603) { 604 let data = evt.data().data(); 605 tracing::trace!(data = %data, "compositionupdate"); 606 607 if let Some(mut comp) = doc.composition() { 608 comp.text = data; 609 doc.set_composition(Some(comp)); 610 } else { 611 tracing::debug!("compositionupdate without active composition state"); 612 } 613} 614 615/// Handle composition end event. 616/// 617/// Finalizes the composition by inserting the final text into the document. 618/// Also handles zero-width character cleanup that some IMEs leave behind. 619#[cfg(feature = "dioxus")] 620pub fn handle_compositionend<D: EditorDocument>( 621 evt: dioxus_core::Event<dioxus_html::CompositionData>, 622 doc: &mut D, 623) { 624 let final_text = evt.data().data(); 625 tracing::trace!(data = %final_text, "compositionend"); 626 627 // Record when composition ended for Safari timing workaround. 628 doc.set_composition_ended_now(); 629 630 let comp = doc.composition(); 631 doc.set_composition(None); 632 633 if let Some(comp) = comp { 634 tracing::debug!( 635 start_offset = comp.start_offset, 636 final_text = %final_text, 637 chars = final_text.chars().count(), 638 "compositionend: inserting text" 639 ); 640 641 if !final_text.is_empty() { 642 // Clean up zero-width characters that IMEs sometimes leave behind. 643 let mut delete_start = comp.start_offset; 644 while delete_start > 0 { 645 match doc.char_at(delete_start - 1) { 646 Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 647 _ => break, 648 } 649 } 650 651 let cursor_offset = doc.cursor_offset(); 652 let zw_count = cursor_offset - delete_start; 653 654 if zw_count > 0 { 655 // Splice: delete zero-width chars and insert new char in one op. 656 doc.replace(delete_start..delete_start + zw_count, &final_text); 657 doc.set_cursor_offset(delete_start + final_text.chars().count()); 658 } else if cursor_offset == doc.len_chars() { 659 // Fast path: append at end. 660 doc.push(&final_text); 661 doc.set_cursor_offset(comp.start_offset + final_text.chars().count()); 662 } else { 663 // Insert at cursor position. 664 doc.insert(cursor_offset, &final_text); 665 doc.set_cursor_offset(comp.start_offset + final_text.chars().count()); 666 } 667 } 668 } else { 669 tracing::debug!("compositionend without active composition state"); 670 } 671}