at main 415 lines 13 kB view raw
1//! Event handlers exposed to JavaScript. 2//! 3//! These handlers are called by the TypeScript view layer when DOM events fire. 4//! The WASM side processes the events and updates state, returning whether 5//! to preventDefault. 6 7use wasm_bindgen::prelude::*; 8 9use weaver_editor_browser::{ 10 BeforeInputContext, BeforeInputResult, handle_beforeinput, parse_browser_input_type, platform, 11}; 12use weaver_editor_core::{ 13 EditorAction, EditorDocument, KeydownResult, Range, SnapDirection, execute_action, 14 handle_keydown, 15}; 16 17use crate::editor::JsEditor; 18 19/// Result of handling an event. 20#[wasm_bindgen] 21#[derive(Debug, Clone, Copy, PartialEq, Eq)] 22pub enum EventResult { 23 /// Event was handled, call preventDefault. 24 Handled, 25 /// Event should pass through to browser. 26 PassThrough, 27 /// Event was handled but needs async follow-up. 28 HandledAsync, 29} 30 31impl From<BeforeInputResult> for EventResult { 32 fn from(r: BeforeInputResult) -> Self { 33 match r { 34 BeforeInputResult::Handled => EventResult::Handled, 35 BeforeInputResult::PassThrough => EventResult::PassThrough, 36 BeforeInputResult::HandledAsync => EventResult::HandledAsync, 37 BeforeInputResult::DeferredCheck { .. } => EventResult::PassThrough, 38 } 39 } 40} 41 42impl From<KeydownResult> for EventResult { 43 fn from(r: KeydownResult) -> Self { 44 match r { 45 KeydownResult::Handled => EventResult::Handled, 46 KeydownResult::PassThrough | KeydownResult::NotHandled => EventResult::PassThrough, 47 } 48 } 49} 50 51/// Target range for beforeinput event. 52#[wasm_bindgen] 53#[derive(Debug, Clone)] 54pub struct JsTargetRange { 55 pub start: usize, 56 pub end: usize, 57} 58 59#[wasm_bindgen] 60impl JsTargetRange { 61 #[wasm_bindgen(constructor)] 62 pub fn new(start: usize, end: usize) -> Self { 63 Self { start, end } 64 } 65} 66 67#[wasm_bindgen] 68impl JsEditor { 69 // === Event handlers === 70 71 /// Handle beforeinput event. 72 /// 73 /// Returns whether to preventDefault. 74 #[wasm_bindgen(js_name = handleBeforeInput)] 75 pub fn handle_before_input( 76 &mut self, 77 input_type: &str, 78 data: Option<String>, 79 target_start: Option<usize>, 80 target_end: Option<usize>, 81 is_composing: bool, 82 ) -> EventResult { 83 let plat = platform::platform(); 84 let input_type_parsed = parse_browser_input_type(input_type); 85 86 let target_range = match (target_start, target_end) { 87 (Some(start), Some(end)) => Some(Range::new(start, end)), 88 _ => None, 89 }; 90 91 let ctx = BeforeInputContext { 92 input_type: input_type_parsed, 93 data, 94 target_range, 95 is_composing, 96 platform: &plat, 97 }; 98 99 let current_range = self.get_current_range(); 100 let result = handle_beforeinput(&mut self.doc, &ctx, current_range); 101 102 // Handle deferred check (Android workaround) - for JS we just pass through 103 // and let JS handle the timeout 104 let event_result = EventResult::from(result); 105 106 if event_result == EventResult::Handled { 107 self.render_and_update_dom(); 108 self.notify_change(); 109 } 110 111 event_result 112 } 113 114 /// Handle keydown event. 115 /// 116 /// Returns whether to preventDefault. 117 #[wasm_bindgen(js_name = handleKeydown)] 118 pub fn handle_keydown( 119 &mut self, 120 key: &str, 121 ctrl: bool, 122 alt: bool, 123 shift: bool, 124 meta: bool, 125 ) -> EventResult { 126 // During IME composition, only handle Escape and modifier shortcuts 127 if self.doc.composition().is_some() { 128 if key == "Escape" { 129 self.doc.set_composition(None); 130 return EventResult::Handled; 131 } 132 if !ctrl && !alt && !meta { 133 return EventResult::PassThrough; 134 } 135 } 136 137 let combo = weaver_editor_core::KeyCombo { 138 key: parse_key(key), 139 modifiers: weaver_editor_core::Modifiers { 140 ctrl, 141 alt, 142 shift, 143 meta, 144 hyper: false, 145 super_: false, 146 }, 147 }; 148 149 let cursor_offset = self.doc.cursor_offset(); 150 let selection = self.doc.selection(); 151 let range = selection 152 .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 153 .unwrap_or_else(|| Range::caret(cursor_offset)); 154 155 let keybindings = weaver_editor_core::KeybindingConfig::default(); 156 let result = handle_keydown(&mut self.doc, &keybindings, combo, range); 157 158 let event_result = EventResult::from(result); 159 160 if event_result == EventResult::Handled { 161 self.render_and_update_dom(); 162 self.notify_change(); 163 } 164 165 event_result 166 } 167 168 /// Handle keyup event for navigation keys. 169 /// 170 /// Syncs cursor from DOM after browser handles navigation. 171 #[wasm_bindgen(js_name = handleKeyup)] 172 pub fn handle_keyup(&mut self, key: &str) { 173 let direction_hint = match key { 174 "ArrowLeft" | "ArrowUp" => Some(SnapDirection::Backward), 175 "ArrowRight" | "ArrowDown" => Some(SnapDirection::Forward), 176 _ => None, 177 }; 178 179 let is_navigation = matches!( 180 key, 181 "ArrowLeft" 182 | "ArrowRight" 183 | "ArrowUp" 184 | "ArrowDown" 185 | "Home" 186 | "End" 187 | "PageUp" 188 | "PageDown" 189 ); 190 191 if is_navigation { 192 self.sync_cursor_with_hint(direction_hint); 193 } 194 } 195 196 /// Sync cursor from DOM selection. 197 /// 198 /// Call this after click, select, or other events that change selection. 199 #[wasm_bindgen(js_name = syncCursor)] 200 pub fn sync_cursor(&mut self) { 201 self.sync_cursor_with_hint(None); 202 } 203 204 /// Handle paste event. 205 /// 206 /// The text parameter is plain text from clipboard. 207 #[wasm_bindgen(js_name = handlePaste)] 208 pub fn handle_paste(&mut self, text: &str) { 209 let cursor_offset = self.doc.cursor_offset(); 210 let selection = self.doc.selection(); 211 let range = selection 212 .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 213 .unwrap_or_else(|| Range::caret(cursor_offset)); 214 215 let action = EditorAction::Insert { 216 text: text.to_string(), 217 range, 218 }; 219 execute_action(&mut self.doc, &action); 220 221 self.render_and_update_dom(); 222 self.notify_change(); 223 } 224 225 /// Handle cut event. 226 /// 227 /// Returns the text that was cut (for clipboard). 228 #[wasm_bindgen(js_name = handleCut)] 229 pub fn handle_cut(&mut self) -> Option<String> { 230 let selection = self.doc.selection()?; 231 let start = selection.anchor.min(selection.head); 232 let end = selection.anchor.max(selection.head); 233 234 if start == end { 235 return None; 236 } 237 238 let text = self.doc.slice(start..end).map(|s| s.to_string())?; 239 240 let action = EditorAction::Insert { 241 text: String::new(), 242 range: Range::new(start, end), 243 }; 244 execute_action(&mut self.doc, &action); 245 246 self.render_and_update_dom(); 247 self.notify_change(); 248 249 Some(text) 250 } 251 252 /// Handle copy event. 253 /// 254 /// Returns the text to copy (from selection). 255 #[wasm_bindgen(js_name = handleCopy)] 256 pub fn handle_copy(&self) -> Option<String> { 257 let selection = self.doc.selection()?; 258 let start = selection.anchor.min(selection.head); 259 let end = selection.anchor.max(selection.head); 260 261 if start == end { 262 return None; 263 } 264 265 self.doc.slice(start..end).map(|s| s.to_string()) 266 } 267 268 /// Handle blur event. 269 /// 270 /// Clears any in-progress IME composition. 271 #[wasm_bindgen(js_name = handleBlur)] 272 pub fn handle_blur(&mut self) { 273 self.doc.set_composition(None); 274 } 275 276 /// Handle compositionstart event. 277 #[wasm_bindgen(js_name = handleCompositionStart)] 278 pub fn handle_composition_start(&mut self, data: Option<String>) { 279 use weaver_editor_core::CompositionState; 280 281 let cursor = self.doc.cursor_offset(); 282 self.doc.set_composition(Some(CompositionState { 283 start_offset: cursor, 284 text: data.unwrap_or_default(), 285 })); 286 } 287 288 /// Handle compositionupdate event. 289 #[wasm_bindgen(js_name = handleCompositionUpdate)] 290 pub fn handle_composition_update(&mut self, data: Option<String>) { 291 if let Some(mut comp) = self.doc.composition() { 292 comp.text = data.unwrap_or_default(); 293 self.doc.set_composition(Some(comp)); 294 } 295 } 296 297 /// Handle compositionend event. 298 #[wasm_bindgen(js_name = handleCompositionEnd)] 299 pub fn handle_composition_end(&mut self, data: Option<String>) { 300 // Get composition state before clearing 301 let composition = self.doc.composition(); 302 self.doc.set_composition(None); 303 304 // Insert the final composed text 305 if let Some(comp) = composition { 306 if let Some(text) = data { 307 if !text.is_empty() { 308 let range = Range::new( 309 comp.start_offset, 310 comp.start_offset + comp.text.chars().count(), 311 ); 312 let action = EditorAction::Insert { text, range }; 313 execute_action(&mut self.doc, &action); 314 315 self.render_and_update_dom(); 316 self.notify_change(); 317 } 318 } 319 } 320 } 321 322 /// Handle Android Enter key (workaround for keypress). 323 #[wasm_bindgen(js_name = handleAndroidEnter)] 324 pub fn handle_android_enter(&mut self) { 325 let cursor_offset = self.doc.cursor_offset(); 326 let selection = self.doc.selection(); 327 let range = selection 328 .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 329 .unwrap_or_else(|| Range::caret(cursor_offset)); 330 331 let action = EditorAction::InsertParagraph { range }; 332 execute_action(&mut self.doc, &action); 333 334 self.render_and_update_dom(); 335 self.notify_change(); 336 } 337} 338 339// Internal helpers 340impl JsEditor { 341 fn get_current_range(&self) -> Range { 342 let cursor_offset = self.doc.cursor_offset(); 343 let selection = self.doc.selection(); 344 selection 345 .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 346 .unwrap_or_else(|| Range::caret(cursor_offset)) 347 } 348 349 fn sync_cursor_with_hint(&mut self, direction_hint: Option<SnapDirection>) { 350 use weaver_editor_browser::{CursorSyncResult, sync_cursor_from_dom_impl}; 351 352 let Some(ref editor_id) = self.editor_id else { 353 return; 354 }; 355 356 // Get sync result without closures to avoid borrow issues 357 if let Some(result) = sync_cursor_from_dom_impl(editor_id, &self.paragraphs, direction_hint) 358 { 359 match result { 360 CursorSyncResult::Cursor(offset) => { 361 self.doc.set_cursor_offset(offset); 362 self.doc.set_selection(None); 363 } 364 CursorSyncResult::Selection { anchor, head } => { 365 if anchor == head { 366 self.doc.set_cursor_offset(anchor); 367 self.doc.set_selection(None); 368 } else { 369 self.doc 370 .set_selection(Some(weaver_editor_core::Selection { anchor, head })); 371 } 372 } 373 CursorSyncResult::None => {} 374 } 375 } 376 377 // Update syntax visibility after cursor sync 378 let cursor_offset = self.doc.cursor_offset(); 379 let selection = self.doc.selection(); 380 let syntax_spans: Vec<_> = self 381 .paragraphs 382 .iter() 383 .flat_map(|p| p.syntax_spans.iter().cloned()) 384 .collect(); 385 weaver_editor_browser::update_syntax_visibility( 386 cursor_offset, 387 selection.as_ref(), 388 &syntax_spans, 389 &self.paragraphs, 390 ); 391 } 392} 393 394/// Parse a key string to the editor's Key enum. 395fn parse_key(key: &str) -> weaver_editor_core::Key { 396 use weaver_editor_core::Key; 397 398 match key { 399 "Enter" => Key::Enter, 400 "Backspace" => Key::Backspace, 401 "Delete" => Key::Delete, 402 "Tab" => Key::Tab, 403 "Escape" => Key::Escape, 404 "ArrowLeft" => Key::ArrowLeft, 405 "ArrowRight" => Key::ArrowRight, 406 "ArrowUp" => Key::ArrowUp, 407 "ArrowDown" => Key::ArrowDown, 408 "Home" => Key::Home, 409 "End" => Key::End, 410 "PageUp" => Key::PageUp, 411 "PageDown" => Key::PageDown, 412 s if s.len() == 1 => Key::character(s), 413 _ => Key::Unidentified, 414 } 415}