···1-//! BeforeInput event handling for the editor.
2-//!
3-//! This module provides the primary input handling via the `beforeinput` event,
4-//! which gives us semantic information about what the browser wants to do
5-//! (insert text, delete backward, etc.) rather than raw key codes.
6-//!
7-//! The core logic is in `weaver_editor_browser::handle_beforeinput`. This module
8-//! adds app-specific concerns like `pending_snap` for cursor snapping direction.
9-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
10-use super::document::SignalEditorDocument;
11-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
12-use dioxus::prelude::*;
13-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
14-use weaver_editor_core::SnapDirection;
15-16-// Re-export types from extracted crates.
17-pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult};
18-pub use weaver_editor_core::InputType;
19-20-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
21-pub use weaver_editor_browser::StaticRange;
22-23-/// Determine the cursor snap direction hint for an input type.
24-///
25-/// This is used to hint `dom_sync` which direction to snap the cursor if it
26-/// lands on invisible content after an edit.
27-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28-fn snap_direction_for_input_type(input_type: &InputType) -> Option<SnapDirection> {
29- match input_type {
30- // Forward: cursor should snap toward new/remaining content after the edit.
31- InputType::InsertLineBreak
32- | InputType::InsertParagraph
33- | InputType::DeleteContentForward
34- | InputType::DeleteWordForward
35- | InputType::DeleteEntireWordForward
36- | InputType::DeleteSoftLineForward
37- | InputType::DeleteHardLineForward => Some(SnapDirection::Forward),
38-39- // Backward: cursor should snap toward content before the deleted range.
40- InputType::DeleteContentBackward
41- | InputType::DeleteWordBackward
42- | InputType::DeleteEntireWordBackward
43- | InputType::DeleteSoftLineBackward
44- | InputType::DeleteHardLineBackward => Some(SnapDirection::Backward),
45-46- // No snap hint for other operations.
47- _ => None,
48- }
49-}
50-51-/// Handle a beforeinput event.
52-///
53-/// This is the main entry point for beforeinput-based input handling.
54-/// Sets `pending_snap` for cursor snapping, then delegates to the browser crate.
55-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
56-pub fn handle_beforeinput(
57- doc: &mut SignalEditorDocument,
58- ctx: BeforeInputContext<'_>,
59-) -> BeforeInputResult {
60- // Set pending_snap hint before executing the action.
61- if let Some(snap) = snap_direction_for_input_type(&ctx.input_type) {
62- doc.pending_snap.set(Some(snap));
63- }
64-65- // Get current range for the browser handler.
66- let current_range = weaver_editor_browser::get_current_range(doc);
67-68- // Delegate to browser crate's generic handler.
69- weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range)
70-}
···1131 fn set_composition(&mut self, composition: Option<CompositionState>) {
1132 self.composition.set(composition);
1133 }
1134+1135+ fn undo(&mut self) -> bool {
1136+ // Sync Loro cursor to current position BEFORE undo
1137+ // so it tracks through the undo operation.
1138+ self.sync_loro_cursor();
1139+1140+ let result = self.buffer.undo();
1141+ if result {
1142+ // After undo, query Loro cursor for new position.
1143+ self.sync_cursor_from_loro();
1144+ // Signal content change for re-render.
1145+ self.content_changed.set(());
1146+ }
1147+ result
1148+ }
1149+1150+ fn redo(&mut self) -> bool {
1151+ // Sync Loro cursor to current position BEFORE redo.
1152+ self.sync_loro_cursor();
1153+1154+ let result = self.buffer.redo();
1155+ if result {
1156+ // After redo, query Loro cursor for new position.
1157+ self.sync_cursor_from_loro();
1158+ // Signal content change for re-render.
1159+ self.content_changed.set(());
1160+ }
1161+ result
1162+ }
1163+1164+ fn pending_snap(&self) -> Option<weaver_editor_core::SnapDirection> {
1165+ *self.pending_snap.read()
1166+ }
1167+1168+ fn set_pending_snap(&mut self, snap: Option<weaver_editor_core::SnapDirection>) {
1169+ self.pending_snap.set(snap);
1170+ }
1171}
-601
crates/weaver-app/src/components/editor/input.rs
···1-//! Input handling for the markdown editor.
2-//!
3-//! Keyboard events, clipboard operations, and text manipulation.
4-5-use dioxus::prelude::*;
6-7-use super::document::SignalEditorDocument;
8-use weaver_editor_core::{FormatAction, SnapDirection, apply_formatting};
9-10-// Re-export ListContext from core - the logic is duplicated below for Loro-specific usage,
11-// but the type itself comes from core.
12-pub use weaver_editor_core::ListContext;
13-14-// Re-export clipboard helpers from browser crate.
15-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
16-pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type};
17-18-/// Check if we need to intercept this key event.
19-/// Returns true for content-modifying operations, false for navigation.
20-#[allow(unused)]
21-pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool {
22- use dioxus::prelude::keyboard_types::Key;
23-24- let key = evt.key();
25- let mods = evt.modifiers();
26-27- // Handle Ctrl/Cmd shortcuts
28- if mods.ctrl() || mods.meta() {
29- if let Key::Character(ch) = &key {
30- // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e)
31- match ch.as_str() {
32- "b" | "i" | "z" | "y" => return true,
33- "e" => return true, // Ctrl+E for HTML export/copy
34- _ => {}
35- }
36- }
37- // Intercept Cmd+Backspace (delete to start of line) and Cmd+Delete (delete to end)
38- if matches!(key, Key::Backspace | Key::Delete) {
39- return true;
40- }
41- // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
42- return false;
43- }
44-45- // Intercept content modifications
46- matches!(
47- key,
48- Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab
49- )
50-}
51-52-/// Handle keyboard events and update document state.
53-#[allow(unused)]
54-pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut SignalEditorDocument) {
55- use dioxus::prelude::keyboard_types::Key;
56-57- let key = evt.key();
58- let mods = evt.modifiers();
59-60- match key {
61- Key::Character(ch) => {
62- // Keyboard shortcuts first
63- if mods.ctrl() {
64- match ch.as_str() {
65- "b" => {
66- apply_formatting(doc, FormatAction::Bold);
67- return;
68- }
69- "i" => {
70- apply_formatting(doc, FormatAction::Italic);
71- return;
72- }
73- "z" => {
74- if mods.shift() {
75- // Ctrl+Shift+Z = redo
76- if let Ok(true) = doc.redo() {
77- let max = doc.len_chars();
78- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
79- }
80- } else {
81- // Ctrl+Z = undo
82- if let Ok(true) = doc.undo() {
83- let max = doc.len_chars();
84- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
85- }
86- }
87- doc.selection.set(None);
88- return;
89- }
90- "y" => {
91- // Ctrl+Y = redo (alternative)
92- if let Ok(true) = doc.redo() {
93- let max = doc.len_chars();
94- doc.cursor.with_mut(|c| c.offset = c.offset.min(max));
95- }
96- doc.selection.set(None);
97- return;
98- }
99- "e" => {
100- // Ctrl+E = copy as HTML (export)
101- if let Some(sel) = *doc.selection.read() {
102- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
103- if start != end {
104- if let Some(markdown) = doc.slice(start, end) {
105- let clean_md =
106- markdown.replace('\u{200C}', "").replace('\u{200B}', "");
107- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
108- wasm_bindgen_futures::spawn_local(async move {
109- if let Err(e) = copy_as_html(&clean_md).await {
110- tracing::warn!("[COPY HTML] Failed: {:?}", e);
111- }
112- });
113- }
114- }
115- }
116- return;
117- }
118- _ => {}
119- }
120- }
121-122- // Insert character at cursor (replacing selection if any)
123- let sel = doc.selection.write().take();
124- if let Some(sel) = sel {
125- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
126- let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch);
127- doc.cursor.write().offset = start + ch.chars().count();
128- } else {
129- // Clean up any preceding zero-width chars (gap scaffolding)
130- let cursor_offset = doc.cursor.read().offset;
131- let mut delete_start = cursor_offset;
132- while delete_start > 0 {
133- match get_char_at(doc.loro_text(), delete_start - 1) {
134- Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1,
135- _ => break,
136- }
137- }
138-139- let zw_count = cursor_offset - delete_start;
140- if zw_count > 0 {
141- // Splice: delete zero-width chars and insert new char in one op
142- let _ = doc.replace_tracked(delete_start, zw_count, &ch);
143- doc.cursor.write().offset = delete_start + ch.chars().count();
144- } else if cursor_offset == doc.len_chars() {
145- // Fast path: append at end
146- let _ = doc.push_tracked(&ch);
147- doc.cursor.write().offset = cursor_offset + ch.chars().count();
148- } else {
149- let _ = doc.insert_tracked(cursor_offset, &ch);
150- doc.cursor.write().offset = cursor_offset + ch.chars().count();
151- }
152- }
153- }
154-155- Key::Backspace => {
156- // Snap backward after backspace (toward deleted content)
157- doc.pending_snap.set(Some(SnapDirection::Backward));
158-159- let sel = doc.selection.write().take();
160- if let Some(sel) = sel {
161- // Delete selection
162- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
163- let _ = doc.remove_tracked(start, end.saturating_sub(start));
164- doc.cursor.write().offset = start;
165- } else if doc.cursor.read().offset > 0 {
166- let cursor_offset = doc.cursor.read().offset;
167-168- // Cmd+Backspace: delete to start of line
169- if mods.meta() || mods.ctrl() {
170- let line_start = find_line_start(doc.loro_text(), cursor_offset);
171- if line_start < cursor_offset {
172- let _ = doc.remove_tracked(line_start, cursor_offset - line_start);
173- doc.cursor.write().offset = line_start;
174- }
175- return;
176- }
177-178- // Check if we're about to delete a newline
179- let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1);
180-181- if prev_char == Some('\n') {
182- let newline_pos = cursor_offset - 1;
183- let mut delete_start = newline_pos;
184- let mut delete_end = cursor_offset;
185-186- // Check if there's another newline before this one (empty paragraph)
187- // If so, delete both newlines to merge paragraphs
188- if newline_pos > 0 {
189- let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1);
190- if prev_prev_char == Some('\n') {
191- // Empty paragraph case: delete both newlines
192- delete_start = newline_pos - 1;
193- }
194- }
195-196- // Also check if there's a zero-width char after cursor (inserted by Shift+Enter)
197- if let Some(ch) = get_char_at(doc.loro_text(), delete_end) {
198- if ch == '\u{200C}' || ch == '\u{200B}' {
199- delete_end += 1;
200- }
201- }
202-203- // Scan backwards through whitespace before the newline(s)
204- while delete_start > 0 {
205- let ch = get_char_at(doc.loro_text(), delete_start - 1);
206- match ch {
207- Some('\u{200C}') | Some('\u{200B}') => {
208- delete_start -= 1;
209- }
210- Some('\n') => break, // stop at another newline
211- _ => break, // stop at actual content
212- }
213- }
214-215- // Delete from where we stopped to end (including any trailing zero-width)
216- let _ =
217- doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start));
218- doc.cursor.write().offset = delete_start;
219- } else {
220- // Normal backspace - delete one char
221- let prev = cursor_offset - 1;
222- let _ = doc.remove_tracked(prev, 1);
223- doc.cursor.write().offset = prev;
224- }
225- }
226- }
227-228- Key::Delete => {
229- // Snap forward after delete (toward remaining content)
230- doc.pending_snap.set(Some(SnapDirection::Forward));
231-232- let sel = doc.selection.write().take();
233- if let Some(sel) = sel {
234- // Delete selection
235- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
236- let _ = doc.remove_tracked(start, end.saturating_sub(start));
237- doc.cursor.write().offset = start;
238- } else {
239- let cursor_offset = doc.cursor.read().offset;
240- let doc_len = doc.len_chars();
241-242- // Cmd+Delete: delete to end of line
243- if mods.meta() || mods.ctrl() {
244- let line_end = find_line_end(doc.loro_text(), cursor_offset);
245- if cursor_offset < line_end {
246- let _ = doc.remove_tracked(cursor_offset, line_end - cursor_offset);
247- }
248- return;
249- }
250-251- if cursor_offset < doc_len {
252- // Delete next char
253- let _ = doc.remove_tracked(cursor_offset, 1);
254- }
255- }
256- }
257-258- // Arrow keys handled by browser, synced in onkeyup
259- Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => {
260- // Browser handles these naturally
261- }
262-263- Key::Enter => {
264- // Snap forward after enter (into new paragraph/line)
265- doc.pending_snap.set(Some(SnapDirection::Forward));
266-267- let sel = doc.selection.write().take();
268- if let Some(sel) = sel {
269- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
270- let _ = doc.remove_tracked(start, end.saturating_sub(start));
271- doc.cursor.write().offset = start;
272- }
273-274- let cursor_offset = doc.cursor.read().offset;
275- if mods.shift() {
276- // Shift+Enter: hard line break (soft break)
277- let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}");
278- doc.cursor.write().offset = cursor_offset + 3;
279- } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) {
280- // We're in a list item
281- if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) {
282- // Empty item - exit list by removing marker and inserting paragraph break
283- let line_start = find_line_start(doc.loro_text(), cursor_offset);
284- let line_end = find_line_end(doc.loro_text(), cursor_offset);
285-286- // Delete the empty list item line INCLUDING its trailing newline
287- // line_end points to the newline, so +1 to include it
288- let delete_end = (line_end + 1).min(doc.len_chars());
289-290- // Use replace_tracked to atomically delete line and insert paragraph break
291- let _ = doc.replace_tracked(
292- line_start,
293- delete_end.saturating_sub(line_start),
294- "\n\n\u{200C}\n",
295- );
296- doc.cursor.write().offset = line_start + 2;
297- } else {
298- // Non-empty item - continue list
299- let continuation = match ctx {
300- ListContext::Unordered { indent, marker } => {
301- format!("\n{}{} ", indent, marker)
302- }
303- ListContext::Ordered { indent, number } => {
304- format!("\n{}{}. ", indent, number + 1)
305- }
306- };
307- let len = continuation.chars().count();
308- let _ = doc.insert_tracked(cursor_offset, &continuation);
309- doc.cursor.write().offset = cursor_offset + len;
310- }
311- } else {
312- // Not in a list - normal paragraph break
313- let _ = doc.insert_tracked(cursor_offset, "\n\n");
314- doc.cursor.write().offset = cursor_offset + 2;
315- }
316- }
317-318- // Home/End handled by browser, synced in onkeyup
319- Key::Home | Key::End => {
320- // Browser handles these naturally
321- }
322-323- _ => {}
324- }
325-326- // Sync Loro cursor when edits affect paragraph boundaries
327- // This ensures cursor position is tracked correctly through structural changes
328- if doc.last_edit().is_some_and(|e| e.contains_newline) {
329- doc.sync_loro_cursor();
330- }
331-}
332-333-/// Handle paste events and insert text at cursor.
334-pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
335- evt.prevent_default();
336- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
337- let _ = doc;
338-339- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
340- {
341- use dioxus::web::WebEventExt;
342- use wasm_bindgen::JsCast;
343-344- let base_evt = evt.as_web_event();
345- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
346- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
347- // Try our custom type first (internal paste), fall back to text/plain
348- let text = data_transfer
349- .get_data("text/x-weaver-md")
350- .ok()
351- .filter(|s| !s.is_empty())
352- .or_else(|| data_transfer.get_data("text/plain").ok());
353-354- if let Some(text) = text {
355- // Delete selection if present
356- let sel = doc.selection.write().take();
357- if let Some(sel) = sel {
358- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
359- let _ = doc.remove_tracked(start, end.saturating_sub(start));
360- doc.cursor.write().offset = start;
361- }
362-363- // Insert pasted text
364- let cursor_offset = doc.cursor.read().offset;
365- let _ = doc.insert_tracked(cursor_offset, &text);
366- doc.cursor.write().offset = cursor_offset + text.chars().count();
367- }
368- }
369- } else {
370- tracing::warn!("[PASTE] Failed to cast to ClipboardEvent");
371- }
372- }
373-}
374-375-/// Handle cut events - extract text, write to clipboard, then delete.
376-pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
377- evt.prevent_default();
378- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
379- let _ = doc;
380-381- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
382- {
383- use dioxus::web::WebEventExt;
384- use wasm_bindgen::JsCast;
385-386- let base_evt = evt.as_web_event();
387- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
388- let cut_text = {
389- let sel = doc.selection.write().take();
390- if let Some(sel) = sel {
391- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
392- if start != end {
393- // Extract text and strip zero-width chars
394- let selected_text = doc.slice(start, end).unwrap_or_default();
395- let clean_text = selected_text
396- .replace('\u{200C}', "")
397- .replace('\u{200B}', "");
398-399- // Write to clipboard BEFORE deleting (sync fallback)
400- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
401- if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
402- tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e);
403- }
404- }
405-406- // Now delete
407- let _ = doc.remove_tracked(start, end.saturating_sub(start));
408- doc.cursor.write().offset = start;
409-410- Some(clean_text)
411- } else {
412- None
413- }
414- } else {
415- None
416- }
417- };
418-419- // Async: also write custom MIME type for internal paste detection
420- if let Some(text) = cut_text {
421- wasm_bindgen_futures::spawn_local(async move {
422- if let Err(e) = write_clipboard_with_custom_type(&text).await {
423- tracing::debug!("[CUT] Async clipboard write failed: {:?}", e);
424- }
425- });
426- }
427- }
428- }
429-430- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
431- {
432- let _ = evt; // suppress unused warning
433- }
434-}
435-436-/// Handle copy events - extract text, clean it up, write to clipboard.
437-pub fn handle_copy(evt: Event<ClipboardData>, doc: &SignalEditorDocument) {
438- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
439- {
440- use dioxus::web::WebEventExt;
441- use wasm_bindgen::JsCast;
442-443- let base_evt = evt.as_web_event();
444- if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
445- let sel = *doc.selection.read();
446- if let Some(sel) = sel {
447- let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
448- if start != end {
449- // Extract text
450- let selected_text = doc.slice(start, end).unwrap_or_default();
451-452- // Strip zero-width chars used for gap handling
453- let clean_text = selected_text
454- .replace('\u{200C}', "")
455- .replace('\u{200B}', "");
456-457- // Sync fallback: write text/plain via DataTransfer
458- if let Some(data_transfer) = clipboard_evt.clipboard_data() {
459- if let Err(e) = data_transfer.set_data("text/plain", &clean_text) {
460- tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e);
461- }
462- }
463-464- // Async: also write custom MIME type for internal paste detection
465- let text_for_async = clean_text.clone();
466- wasm_bindgen_futures::spawn_local(async move {
467- if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await {
468- tracing::debug!("[COPY] Async clipboard write failed: {:?}", e);
469- }
470- });
471-472- // Prevent browser's default copy (which would copy rendered HTML)
473- evt.prevent_default();
474- }
475- }
476- }
477- }
478-479- #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
480- {
481- let _ = (evt, doc); // suppress unused warnings
482- }
483-}
484-485-/// Detect if cursor is in a list item and return context for continuation.
486-///
487-/// Scans backwards to find start of current line, then checks for list marker.
488-pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> {
489- // Find start of current line
490- let line_start = find_line_start(text, cursor_offset);
491-492- // Get the line content from start to cursor
493- let line_end = find_line_end(text, cursor_offset);
494- if line_start >= line_end {
495- return None;
496- }
497-498- // Extract line text
499- let line = text.slice(line_start, line_end).ok()?;
500-501- // Parse indentation
502- let indent: String = line
503- .chars()
504- .take_while(|c| *c == ' ' || *c == '\t')
505- .collect();
506- let trimmed = &line[indent.len()..];
507-508- // Check for unordered list marker: "- " or "* "
509- if trimmed.starts_with("- ") {
510- return Some(ListContext::Unordered {
511- indent,
512- marker: '-',
513- });
514- }
515- if trimmed.starts_with("* ") {
516- return Some(ListContext::Unordered {
517- indent,
518- marker: '*',
519- });
520- }
521-522- // Check for ordered list marker: "1. ", "2. ", "123. ", etc.
523- if let Some(dot_pos) = trimmed.find(". ") {
524- let num_part = &trimmed[..dot_pos];
525- if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) {
526- if let Ok(number) = num_part.parse::<usize>() {
527- return Some(ListContext::Ordered { indent, number });
528- }
529- }
530- }
531-532- None
533-}
534-535-/// Check if the current list item is empty (just the marker, no content after cursor).
536-///
537-/// Used to determine whether Enter should continue the list or exit it.
538-pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool {
539- let line_start = find_line_start(text, cursor_offset);
540- let line_end = find_line_end(text, cursor_offset);
541-542- // Get line content
543- let line = match text.slice(line_start, line_end) {
544- Ok(s) => s,
545- Err(_) => return false,
546- };
547-548- // Calculate expected marker length
549- let marker_len = match ctx {
550- ListContext::Unordered { indent, .. } => indent.len() + 2, // "- "
551- ListContext::Ordered { indent, number } => {
552- indent.len() + number.to_string().len() + 2 // "1. "
553- }
554- };
555-556- // Item is empty if line length equals marker length (nothing after marker)
557- line.len() <= marker_len
558-}
559-560-/// Get character at the given offset in LoroText.
561-pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> {
562- text.char_at(offset).ok()
563-}
564-565-/// Find start of line containing offset.
566-pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize {
567- if offset == 0 {
568- return 0;
569- }
570- // Only slice the portion before cursor
571- let prefix = match text.slice(0, offset) {
572- Ok(s) => s,
573- Err(_) => return 0,
574- };
575- prefix
576- .chars()
577- .enumerate()
578- .filter(|(_, c)| *c == '\n')
579- .last()
580- .map(|(pos, _)| pos + 1)
581- .unwrap_or(0)
582-}
583-584-/// Find end of line containing offset.
585-pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize {
586- let char_len = text.len_unicode();
587- if offset >= char_len {
588- return char_len;
589- }
590- // Only slice from cursor to end
591- let suffix = match text.slice(offset, char_len) {
592- Ok(s) => s,
593- Err(_) => return char_len,
594- };
595- suffix
596- .chars()
597- .enumerate()
598- .find(|(_, c)| *c == '\n')
599- .map(|(i, _)| offset + i)
600- .unwrap_or(char_len)
601-}
···1+//! Browser clipboard implementation.
2+//!
3+//! Implements `ClipboardPlatform` for browser environments using the
4+//! ClipboardEvent's DataTransfer API for sync access and the async
5+//! Clipboard API for custom MIME types.
6+7+use weaver_editor_core::ClipboardPlatform;
8+9+/// Browser clipboard context wrapping a ClipboardEvent's DataTransfer.
10+///
11+/// Created from a clipboard event (copy, cut, paste) to provide sync
12+/// clipboard access. Also spawns async tasks for custom MIME types.
13+pub struct BrowserClipboard {
14+ data_transfer: Option<web_sys::DataTransfer>,
15+}
16+17+impl BrowserClipboard {
18+ /// Create from a ClipboardEvent.
19+ ///
20+ /// Call this in your copy/cut/paste event handler.
21+ pub fn from_event(evt: &web_sys::ClipboardEvent) -> Self {
22+ Self {
23+ data_transfer: evt.clipboard_data(),
24+ }
25+ }
26+27+ /// Create an empty clipboard context (for testing or non-event contexts).
28+ pub fn empty() -> Self {
29+ Self {
30+ data_transfer: None,
31+ }
32+ }
33+}
34+35+impl ClipboardPlatform for BrowserClipboard {
36+ fn write_text(&self, text: &str) {
37+ // Sync write via DataTransfer (immediate fallback).
38+ if let Some(dt) = &self.data_transfer {
39+ if let Err(e) = dt.set_data("text/plain", text) {
40+ tracing::warn!("Clipboard sync write failed: {:?}", e);
41+ }
42+ }
43+44+ // Async write for custom MIME type (enables internal paste detection).
45+ let text = text.to_string();
46+ wasm_bindgen_futures::spawn_local(async move {
47+ if let Err(e) = crate::events::write_clipboard_with_custom_type(&text).await {
48+ tracing::debug!("Clipboard async write failed: {:?}", e);
49+ }
50+ });
51+ }
52+53+ fn write_html(&self, html: &str, plain_text: &str) {
54+ // Sync write of plain text fallback.
55+ if let Some(dt) = &self.data_transfer {
56+ if let Err(e) = dt.set_data("text/plain", plain_text) {
57+ tracing::warn!("Clipboard sync write (plain) failed: {:?}", e);
58+ }
59+ }
60+61+ // Async write for HTML.
62+ let html = html.to_string();
63+ let plain = plain_text.to_string();
64+ wasm_bindgen_futures::spawn_local(async move {
65+ if let Err(e) = write_html_to_clipboard(&html, &plain).await {
66+ tracing::warn!("Clipboard HTML write failed: {:?}", e);
67+ }
68+ });
69+ }
70+71+ fn read_text(&self) -> Option<String> {
72+ let dt = self.data_transfer.as_ref()?;
73+74+ // Try our custom MIME type first (internal paste).
75+ if let Ok(text) = dt.get_data("text/x-weaver-md") {
76+ if !text.is_empty() {
77+ return Some(text);
78+ }
79+ }
80+81+ // Fall back to plain text.
82+ dt.get_data("text/plain").ok().filter(|s| !s.is_empty())
83+ }
84+}
85+86+/// Write HTML and plain text to clipboard using the async Clipboard API.
87+///
88+/// This uses the navigator.clipboard API and doesn't require a clipboard event.
89+/// Suitable for keyboard-triggered copy operations like CopyAsHtml.
90+pub async fn write_html_to_clipboard(
91+ html: &str,
92+ plain_text: &str,
93+) -> Result<(), wasm_bindgen::JsValue> {
94+ use js_sys::{Array, Object, Reflect};
95+ use wasm_bindgen::JsValue;
96+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
97+98+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
99+ let clipboard = window.navigator().clipboard();
100+101+ // Create HTML blob.
102+ let html_parts = Array::new();
103+ html_parts.push(&JsValue::from_str(html));
104+ let html_opts = BlobPropertyBag::new();
105+ html_opts.set_type("text/html");
106+ let html_blob = Blob::new_with_str_sequence_and_options(&html_parts, &html_opts)?;
107+108+ // Create plain text blob.
109+ let text_parts = Array::new();
110+ text_parts.push(&JsValue::from_str(plain_text));
111+ let text_opts = BlobPropertyBag::new();
112+ text_opts.set_type("text/plain");
113+ let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
114+115+ // Create ClipboardItem with both types.
116+ let item_data = Object::new();
117+ Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
118+ Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
119+120+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
121+ let items = Array::new();
122+ items.push(&clipboard_item);
123+124+ wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
125+ tracing::debug!("Wrote {} bytes of HTML to clipboard", html.len());
126+ Ok(())
127+}
128+129+// === Dioxus event handlers ===
130+131+/// Handle a Dioxus paste event.
132+///
133+/// Extracts text from the clipboard event and inserts at cursor.
134+#[cfg(feature = "dioxus")]
135+pub fn handle_paste<D: weaver_editor_core::EditorDocument>(
136+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
137+ doc: &mut D,
138+) {
139+ use dioxus_web::WebEventExt;
140+ use wasm_bindgen::JsCast;
141+142+ evt.prevent_default();
143+144+ let base_evt = evt.as_web_event();
145+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
146+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
147+ weaver_editor_core::clipboard_paste(doc, &clipboard);
148+ } else {
149+ tracing::warn!("[PASTE] Failed to cast to ClipboardEvent");
150+ }
151+}
152+153+/// Handle a Dioxus cut event.
154+///
155+/// Copies selection to clipboard, then deletes it.
156+#[cfg(feature = "dioxus")]
157+pub fn handle_cut<D: weaver_editor_core::EditorDocument>(
158+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
159+ doc: &mut D,
160+) {
161+ use dioxus_web::WebEventExt;
162+ use wasm_bindgen::JsCast;
163+164+ evt.prevent_default();
165+166+ let base_evt = evt.as_web_event();
167+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
168+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
169+ weaver_editor_core::clipboard_cut(doc, &clipboard);
170+ }
171+}
172+173+/// Handle a Dioxus copy event.
174+///
175+/// Copies selection to clipboard. Only prevents default if there was a selection.
176+#[cfg(feature = "dioxus")]
177+pub fn handle_copy<D: weaver_editor_core::EditorDocument>(
178+ evt: dioxus_core::Event<dioxus_html::ClipboardData>,
179+ doc: &D,
180+) {
181+ use dioxus_web::WebEventExt;
182+ use wasm_bindgen::JsCast;
183+184+ let base_evt = evt.as_web_event();
185+ if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
186+ let clipboard = BrowserClipboard::from_event(clipboard_evt);
187+ if weaver_editor_core::clipboard_copy(doc, &clipboard) {
188+ evt.prevent_default();
189+ }
190+ }
191+}
+43-46
crates/weaver-editor-browser/src/events.rs
···254 Ok(result.as_string())
255}
256257-/// Copy markdown as rendered HTML to clipboard.
258-///
259-/// Renders the markdown to HTML and writes both text/html and text/plain
260-/// representations to the clipboard.
261-pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> {
262- use js_sys::{Array, Object, Reflect};
263- use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
264-265- // Render markdown to HTML using ClientWriter.
266- let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter();
267- let mut html = String::new();
268- weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(
269- parser, &mut html, markdown,
270- )
271- .run()
272- .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?;
273-274- let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
275- let clipboard = window.navigator().clipboard();
276-277- // Create blobs for both HTML and plain text.
278- let parts = Array::new();
279- parts.push(&JsValue::from_str(&html));
280-281- let html_opts = BlobPropertyBag::new();
282- html_opts.set_type("text/html");
283- let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?;
284-285- let text_opts = BlobPropertyBag::new();
286- text_opts.set_type("text/plain");
287- let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?;
288-289- // Create ClipboardItem with both types.
290- let item_data = Object::new();
291- Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
292- Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
293-294- let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
295- let items = Array::new();
296- items.push(&clipboard_item);
297-298- wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
299- tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len());
300- Ok(())
301-}
302-303// === BeforeInput handler ===
304305use crate::FORCE_INNERHTML_UPDATE;
···564 | InputType::Unknown(_) => BeforeInputResult::PassThrough,
565 }
566}
0000000000000000000000000000000000000000000
···254 Ok(result.as_string())
255}
2560000000000000000000000000000000000000000000000257// === BeforeInput handler ===
258259use crate::FORCE_INNERHTML_UPDATE;
···518 | InputType::Unknown(_) => BeforeInputResult::PassThrough,
519 }
520}
521+522+// === Math click handling ===
523+524+use 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.
530+pub 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.
546+pub 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+}
+10-4
crates/weaver-editor-browser/src/lib.rs
···47/// Set to `true` for maximum control, `false` for smoother typing experience.
48pub const FORCE_INNERHTML_UPDATE: bool = true;
49050pub mod color;
51pub mod cursor;
52pub mod dom_sync;
···6869// Event handling
70pub use events::{
71- BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range,
72- get_data_from_event, get_input_type_from_event, get_target_range_from_event,
73- handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text,
74- write_clipboard_with_custom_type,
75};
7677// Platform detection
···8283// Color utilities
84pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha};
00000
···47/// Set to `true` for maximum control, `false` for smoother typing experience.
48pub const FORCE_INNERHTML_UPDATE: bool = true;
4950+pub mod clipboard;
51pub mod color;
52pub mod cursor;
53pub mod dom_sync;
···6970// Event handling
71pub use events::{
72+ BeforeInputContext, BeforeInputResult, StaticRange, get_current_range, get_data_from_event,
73+ get_input_type_from_event, get_math_click_offset, get_target_range_from_event,
74+ handle_beforeinput, handle_math_click, is_composing, parse_browser_input_type,
75+ read_clipboard_text, write_clipboard_with_custom_type,
76};
7778// Platform detection
···8384// Color utilities
85pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha};
86+87+// Clipboard
88+pub use clipboard::{BrowserClipboard, write_html_to_clipboard};
89+#[cfg(feature = "dioxus")]
90+pub use clipboard::{handle_copy, handle_cut, handle_paste};
+7-2
crates/weaver-editor-browser/src/visibility.rs
···19//! }
20//! ```
2122-use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState};
2324/// Update syntax span visibility in the DOM based on cursor position.
25///
···43) {
44 use wasm_bindgen::JsCast;
4546- let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs);
000004748 let Some(window) = web_sys::window() else {
49 return;
···19//! }
20//! ```
2122+use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo};
2324/// Update syntax span visibility in the DOM based on cursor position.
25///
···43) {
44 use wasm_bindgen::JsCast;
4546+ let visibility = weaver_editor_core::VisibilityState::calculate(
47+ cursor_offset,
48+ selection,
49+ syntax_spans,
50+ paragraphs,
51+ );
5253 let Some(window) = web_sys::window() else {
54 return;
···103 F: FnOnce(usize),
104 G: FnOnce(usize, usize);
105}
106+107+/// Platform-specific clipboard operations.
108+///
109+/// Implementations handle the low-level clipboard access (sync and async paths
110+/// as appropriate for the platform). Document-level operations (selection
111+/// extraction, cursor updates) are handled by the `clipboard_*` functions
112+/// in this module.
113+pub trait ClipboardPlatform {
114+ /// Write plain text to clipboard.
115+ ///
116+ /// For browsers, implementations should use both the sync DataTransfer API
117+ /// (for immediate fallback) and the async Clipboard API (for custom MIME types).
118+ fn write_text(&self, text: &str);
119+120+ /// Write markdown rendered as HTML to clipboard.
121+ ///
122+ /// The `plain_text` is the original markdown, `html` is the rendered output.
123+ /// Both should be written to clipboard with appropriate MIME types.
124+ fn write_html(&self, html: &str, plain_text: &str);
125+126+ /// Read text from clipboard.
127+ ///
128+ /// For browsers, this reads from the paste event's DataTransfer.
129+ /// Returns None if no text is available.
130+ fn read_text(&self) -> Option<String>;
131+}
132+133+/// Strip zero-width characters used for formatting gaps.
134+///
135+/// The editor uses ZWNJ (U+200C) and ZWSP (U+200B) to create cursor positions
136+/// within invisible formatting syntax. These should be stripped when copying
137+/// text to the clipboard.
138+pub fn strip_zero_width(text: &str) -> String {
139+ text.replace('\u{200C}', "").replace('\u{200B}', "")
140+}
141+142+/// Copy selected text from document to clipboard.
143+///
144+/// Returns true if text was copied, false if no selection.
145+pub fn clipboard_copy<D: crate::EditorDocument, P: ClipboardPlatform>(
146+ doc: &D,
147+ platform: &P,
148+) -> bool {
149+ let Some(sel) = doc.selection() else {
150+ return false;
151+ };
152+153+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
154+ if start == end {
155+ return false;
156+ }
157+158+ let Some(text) = doc.slice(start..end) else {
159+ return false;
160+ };
161+162+ let clean_text = strip_zero_width(&text);
163+ platform.write_text(&clean_text);
164+ true
165+}
166+167+/// Cut selected text from document to clipboard.
168+///
169+/// Copies the selection to clipboard, then deletes it from the document.
170+/// Returns true if text was cut, false if no selection.
171+pub fn clipboard_cut<D: crate::EditorDocument, P: ClipboardPlatform>(
172+ doc: &mut D,
173+ platform: &P,
174+) -> bool {
175+ let Some(sel) = doc.selection() else {
176+ return false;
177+ };
178+179+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
180+ if start == end {
181+ return false;
182+ }
183+184+ let Some(text) = doc.slice(start..end) else {
185+ return false;
186+ };
187+188+ let clean_text = strip_zero_width(&text);
189+ platform.write_text(&clean_text);
190+191+ // Delete selection.
192+ doc.delete(start..end);
193+ doc.set_selection(None);
194+195+ true
196+}
197+198+/// Paste text from clipboard into document.
199+///
200+/// Replaces any selection with the pasted text, or inserts at cursor.
201+/// Returns true if text was pasted, false if clipboard was empty.
202+pub fn clipboard_paste<D: crate::EditorDocument, P: ClipboardPlatform>(
203+ doc: &mut D,
204+ platform: &P,
205+) -> bool {
206+ let Some(text) = platform.read_text() else {
207+ return false;
208+ };
209+210+ if text.is_empty() {
211+ return false;
212+ }
213+214+ // Delete selection if present.
215+ if let Some(sel) = doc.selection() {
216+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
217+ if start != end {
218+ doc.delete(start..end);
219+ doc.set_cursor_offset(start);
220+ }
221+ }
222+ doc.set_selection(None);
223+224+ // Insert at cursor.
225+ let cursor = doc.cursor_offset();
226+ doc.insert(cursor, &text);
227+228+ true
229+}
230+231+/// Render markdown to HTML using the ClientWriter.
232+///
233+/// Uses a minimal context with no embed resolution, suitable for clipboard operations.
234+pub fn render_markdown_to_html(markdown: &str) -> Option<String> {
235+ use crate::markdown_weaver::Parser;
236+ use crate::weaver_renderer::atproto::ClientWriter;
237+238+ let parser = Parser::new(markdown).into_offset_iter();
239+ let mut html = String::new();
240+ ClientWriter::<_, _, ()>::new(parser, &mut html, markdown)
241+ .run()
242+ .ok()?;
243+ Some(html)
244+}
245+246+/// Copy selected text as rendered HTML to clipboard.
247+///
248+/// Renders the selected markdown to HTML and writes both representations
249+/// to the clipboard. Returns true if text was copied, false if no selection.
250+pub fn clipboard_copy_as_html<D: crate::EditorDocument, P: ClipboardPlatform>(
251+ doc: &D,
252+ platform: &P,
253+) -> bool {
254+ let Some(sel) = doc.selection() else {
255+ return false;
256+ };
257+258+ let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end()));
259+ if start == end {
260+ return false;
261+ }
262+263+ let Some(text) = doc.slice(start..end) else {
264+ return false;
265+ };
266+267+ let clean_text = strip_zero_width(&text);
268+269+ let Some(html) = render_markdown_to_html(&clean_text) else {
270+ return false;
271+ };
272+273+ platform.write_html(&html, &clean_text);
274+ true
275+}