atproto blogging
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}