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