basic IME support, few more bug fixes

Orual 542142f9 81b460a4

+345 -43
+1 -1
crates/weaver-app/Cargo.toml
··· 65 chrono = { version = "0.4", features = ["wasmbind"] } 66 wasm-bindgen = "0.2" 67 wasm-bindgen-futures = "0.4" 68 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList"] } 69 js-sys = "0.3" 70 gloo-storage = "0.3" 71 gloo-timers = "0.3"
··· 65 chrono = { version = "0.4", features = ["wasmbind"] } 66 wasm-bindgen = "0.2" 67 wasm-bindgen-futures = "0.4" 68 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter", "DomTokenList", "Clipboard", "ClipboardItem", "Blob", "BlobPropertyBag"] } 69 js-sys = "0.3" 70 gloo-storage = "0.3" 71 gloo-timers = "0.3"
+17
crates/weaver-app/src/components/editor/document.rs
··· 201 result 202 } 203 204 /// Remove text range and record edit info for incremental rendering. 205 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 206 let content = self.text.to_string();
··· 201 result 202 } 203 204 + /// Push text to end of document. Faster than insert for appending. 205 + pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { 206 + let pos = self.text.len_unicode(); 207 + let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 208 + let result = self.text.push_str(text); 209 + let len_after = self.text.len_unicode(); 210 + self.last_edit = Some(EditInfo { 211 + edit_char_pos: pos, 212 + inserted_len: text.chars().count(), 213 + deleted_len: 0, 214 + contains_newline: text.contains('\n'), 215 + in_block_syntax_zone, 216 + doc_len_after: len_after, 217 + }); 218 + result 219 + } 220 + 221 /// Remove text range and record edit info for incremental rendering. 222 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 223 let content = self.text.to_string();
+321 -40
crates/weaver-app/src/components/editor/mod.rs
··· 42 /// - LocalStorage auto-save with debouncing 43 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 44 /// 45 - /// # Phase 1 Limitations 46 /// - Cursor jumps to end after each keystroke (acceptable for MVP) 47 - /// - All formatting characters visible (no hiding based on cursor position) 48 /// - No proper grapheme cluster handling 49 - /// - No IME composition support 50 - /// - No undo/redo 51 /// - No selection with Shift+Arrow 52 - /// - No mouse selection 53 #[component] 54 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 55 // Try to restore from localStorage (includes CRDT state for undo history) ··· 101 // Update DOM when paragraphs change (incremental rendering) 102 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 103 use_effect(move || { 104 // Read document once to avoid multiple borrows 105 let doc = document(); 106 let cursor_offset = doc.cursor.offset; 107 let selection = doc.selection; 108 drop(doc); // Release borrow before other operations ··· 124 // Use requestAnimationFrame to wait for browser paint 125 if let Some(window) = web_sys::window() { 126 let closure = Closure::once(move || { 127 - if let Err(e) = 128 - cursor::restore_cursor_position(cursor_offset, &map, editor_id) 129 { 130 tracing::warn!("Cursor restoration failed: {:?}", e); 131 } ··· 140 cached_paragraphs.set(new_paras.clone()); 141 142 // Update syntax visibility after DOM changes 143 - update_syntax_visibility( 144 - cursor_offset, 145 - selection.as_ref(), 146 - &spans, 147 - &new_paras, 148 - ); 149 }); 150 151 // Track last saved frontiers to detect changes (peek-only, no subscriptions) ··· 170 171 if needs_save { 172 // Sync cursor and extract data for save 173 - let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| { 174 - doc.sync_loro_cursor(); 175 - ( 176 - doc.to_string(), 177 - doc.cursor.offset, 178 - doc.loro_cursor().cloned(), 179 - doc.export_snapshot(), 180 - ) 181 - }); 182 183 use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set 184 let snapshot_b64 = if snapshot_bytes.is_empty() { ··· 220 // DOM populated via web-sys in use_effect for incremental updates 221 222 onkeydown: move |evt| { 223 // Only prevent default for operations that modify content 224 // Let browser handle arrow keys, Home/End naturally 225 if should_intercept_key(&evt) { ··· 279 oncopy: move |evt| { 280 handle_copy(evt, &document); 281 }, 282 } 283 284 ··· 306 // Handle Ctrl/Cmd shortcuts 307 if mods.ctrl() || mods.meta() { 308 if let Key::Character(ch) = &key { 309 - // Intercept our shortcuts: formatting (b/i), undo/redo (z/y) 310 - return matches!(ch.as_str(), "b" | "i" | "z" | "y"); 311 } 312 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 313 return false; ··· 536 537 /// Handle paste events and insert text at cursor 538 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 539 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 540 { 541 use dioxus::web::WebEventExt; ··· 544 let base_evt = evt.as_web_event(); 545 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 546 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 547 - if let Ok(text) = data_transfer.get_data("text/plain") { 548 document.with_mut(|doc| { 549 // Delete selection if present 550 if let Some(sel) = doc.selection { ··· 568 569 /// Handle cut events - extract text, write to clipboard, then delete 570 fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 571 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 572 { 573 use dioxus::web::WebEventExt; ··· 575 576 let base_evt = evt.as_web_event(); 577 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 578 - document.with_mut(|doc| { 579 if let Some(sel) = doc.selection { 580 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 581 if start != end { 582 - // Extract text 583 let selected_text = doc.slice(start, end).unwrap_or_default(); 584 585 - // Write to clipboard BEFORE deleting 586 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 587 - if let Err(e) = data_transfer.set_data("text/plain", &selected_text) { 588 tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 589 } 590 } ··· 593 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 594 doc.cursor.offset = start; 595 doc.selection = None; 596 } 597 } 598 }); 599 } 600 } 601 ··· 626 .replace('\u{200C}', "") 627 .replace('\u{200B}', ""); 628 629 - // Write to clipboard 630 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 631 if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 632 tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 633 } 634 } 635 636 // Prevent browser's default copy (which would copy rendered HTML) 637 evt.prevent_default(); 638 } ··· 646 } 647 } 648 649 /// Extract a slice of text from a string by char indices 650 fn extract_text_slice(text: &str, start: usize, end: usize) -> String { 651 - text.chars().skip(start).take(end.saturating_sub(start)).collect() 652 } 653 654 /// Handle keyboard events and update document state ··· 697 doc.selection = None; 698 return; 699 } 700 _ => {} 701 } 702 } ··· 707 let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 708 doc.cursor.offset = start + ch.chars().count(); 709 } else { 710 - let _ = doc.insert_tracked(doc.cursor.offset, &ch); 711 - doc.cursor.offset += ch.chars().count(); 712 } 713 } 714 ··· 758 } 759 760 // Delete from where we stopped to end (including any trailing zero-width) 761 - let _ = doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 762 doc.cursor.offset = delete_start; 763 } else { 764 // Normal backspace - delete one char ··· 809 let delete_end = (line_end + 1).min(doc.len_chars()); 810 811 // Use replace_tracked to atomically delete line and insert paragraph break 812 - let _ = doc.replace_tracked(line_start, delete_end.saturating_sub(line_start), "\n\n\u{200C}\n"); 813 doc.cursor.offset = line_start + 2; 814 } else { 815 // Non-empty item - continue list ··· 910 /// Check if the current list item is empty (just the marker, no content after cursor). 911 /// 912 /// Used to determine whether Enter should continue the list or exit it. 913 - fn is_list_item_empty( 914 - text: &loro::LoroText, 915 - cursor_offset: usize, 916 - ctx: &ListContext, 917 - ) -> bool { 918 let line_start = find_line_start(text, cursor_offset); 919 let line_end = find_line_end(text, cursor_offset); 920
··· 42 /// - LocalStorage auto-save with debouncing 43 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 44 /// 45 + /// # Phase 1 Limitations (mostly resolved) 46 /// - Cursor jumps to end after each keystroke (acceptable for MVP) 47 + /// - All formatting characters visible (no hiding based on cursor position) - RESOLVED 48 /// - No proper grapheme cluster handling 49 + /// - No undo/redo - RESOLVED (Loro UndoManager) 50 /// - No selection with Shift+Arrow 51 + /// - No mouse selection - RESOLVED 52 #[component] 53 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 54 // Try to restore from localStorage (includes CRDT state for undo history) ··· 100 // Update DOM when paragraphs change (incremental rendering) 101 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 102 use_effect(move || { 103 + tracing::info!("DOM update effect triggered"); 104 + 105 // Read document once to avoid multiple borrows 106 let doc = document(); 107 + 108 + tracing::info!( 109 + composition_active = doc.composition.is_some(), 110 + cursor = doc.cursor.offset, 111 + "DOM update: checking state" 112 + ); 113 + 114 + // Skip DOM updates during IME composition - browser controls the preview 115 + if doc.composition.is_some() { 116 + tracing::info!("skipping DOM update during composition"); 117 + return; 118 + } 119 + 120 let cursor_offset = doc.cursor.offset; 121 let selection = doc.selection; 122 drop(doc); // Release borrow before other operations ··· 138 // Use requestAnimationFrame to wait for browser paint 139 if let Some(window) = web_sys::window() { 140 let closure = Closure::once(move || { 141 + if let Err(e) = cursor::restore_cursor_position(cursor_offset, &map, editor_id) 142 { 143 tracing::warn!("Cursor restoration failed: {:?}", e); 144 } ··· 153 cached_paragraphs.set(new_paras.clone()); 154 155 // Update syntax visibility after DOM changes 156 + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); 157 }); 158 159 // Track last saved frontiers to detect changes (peek-only, no subscriptions) ··· 178 179 if needs_save { 180 // Sync cursor and extract data for save 181 + let (content, cursor_offset, loro_cursor, snapshot_bytes) = 182 + document.with_mut(|doc| { 183 + doc.sync_loro_cursor(); 184 + ( 185 + doc.to_string(), 186 + doc.cursor.offset, 187 + doc.loro_cursor().cloned(), 188 + doc.export_snapshot(), 189 + ) 190 + }); 191 192 use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set 193 let snapshot_b64 = if snapshot_bytes.is_empty() { ··· 229 // DOM populated via web-sys in use_effect for incremental updates 230 231 onkeydown: move |evt| { 232 + use dioxus::prelude::keyboard_types::Key; 233 + 234 + // During IME composition, let browser handle everything 235 + // Exception: Escape cancels composition 236 + if document.peek().composition.is_some() { 237 + tracing::info!( 238 + key = ?evt.key(), 239 + "keydown during composition - delegating to browser" 240 + ); 241 + if evt.key() == Key::Escape { 242 + tracing::info!("Escape pressed - cancelling composition"); 243 + document.with_mut(|doc| { 244 + doc.composition = None; 245 + }); 246 + } 247 + return; 248 + } 249 + 250 // Only prevent default for operations that modify content 251 // Let browser handle arrow keys, Home/End naturally 252 if should_intercept_key(&evt) { ··· 306 oncopy: move |evt| { 307 handle_copy(evt, &document); 308 }, 309 + 310 + onblur: move |_| { 311 + // Cancel any in-progress IME composition on focus loss 312 + let had_composition = document.peek().composition.is_some(); 313 + if had_composition { 314 + tracing::info!("onblur: clearing active composition"); 315 + } 316 + document.with_mut(|doc| { 317 + doc.composition = None; 318 + }); 319 + }, 320 + 321 + oncompositionstart: move |evt: CompositionEvent| { 322 + let data = evt.data().data(); 323 + tracing::info!( 324 + data = %data, 325 + "compositionstart" 326 + ); 327 + document.with_mut(|doc| { 328 + // Delete selection if present (composition replaces it) 329 + if let Some(sel) = doc.selection.take() { 330 + let (start, end) = 331 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 332 + tracing::info!( 333 + start, 334 + end, 335 + "compositionstart: deleting selection" 336 + ); 337 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 338 + doc.cursor.offset = start; 339 + } 340 + 341 + tracing::info!( 342 + cursor = doc.cursor.offset, 343 + "compositionstart: setting composition state" 344 + ); 345 + doc.composition = Some(CompositionState { 346 + start_offset: doc.cursor.offset, 347 + text: data, 348 + }); 349 + }); 350 + }, 351 + 352 + oncompositionupdate: move |evt: CompositionEvent| { 353 + let data = evt.data().data(); 354 + tracing::info!( 355 + data = %data, 356 + "compositionupdate" 357 + ); 358 + document.with_mut(|doc| { 359 + if let Some(ref mut comp) = doc.composition { 360 + comp.text = data; 361 + } else { 362 + tracing::info!("compositionupdate without active composition state"); 363 + } 364 + }); 365 + }, 366 + 367 + oncompositionend: move |evt: CompositionEvent| { 368 + let final_text = evt.data().data(); 369 + tracing::info!( 370 + data = %final_text, 371 + "compositionend" 372 + ); 373 + document.with_mut(|doc| { 374 + if let Some(comp) = doc.composition.take() { 375 + tracing::info!( 376 + start_offset = comp.start_offset, 377 + final_text = %final_text, 378 + chars = final_text.chars().count(), 379 + "compositionend: inserting text" 380 + ); 381 + 382 + if !final_text.is_empty() { 383 + let _ = doc.insert_tracked(comp.start_offset, &final_text); 384 + doc.cursor.offset = 385 + comp.start_offset + final_text.chars().count(); 386 + } 387 + } else { 388 + tracing::info!("compositionend without active composition state"); 389 + } 390 + }); 391 + }, 392 } 393 394 ··· 416 // Handle Ctrl/Cmd shortcuts 417 if mods.ctrl() || mods.meta() { 418 if let Key::Character(ch) = &key { 419 + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) 420 + match ch.as_str() { 421 + "b" | "i" | "z" | "y" => return true, 422 + "e" => return true, // Ctrl+E for HTML export/copy 423 + _ => {} 424 + } 425 } 426 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 427 return false; ··· 650 651 /// Handle paste events and insert text at cursor 652 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 653 + evt.prevent_default(); 654 + 655 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 656 { 657 use dioxus::web::WebEventExt; ··· 660 let base_evt = evt.as_web_event(); 661 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 662 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 663 + // Try our custom type first (internal paste), fall back to text/plain 664 + let text = data_transfer 665 + .get_data("text/x-weaver-md") 666 + .ok() 667 + .filter(|s| !s.is_empty()) 668 + .or_else(|| data_transfer.get_data("text/plain").ok()); 669 + 670 + if let Some(text) = text { 671 document.with_mut(|doc| { 672 // Delete selection if present 673 if let Some(sel) = doc.selection { ··· 691 692 /// Handle cut events - extract text, write to clipboard, then delete 693 fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 694 + evt.prevent_default(); 695 + 696 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 697 { 698 use dioxus::web::WebEventExt; ··· 700 701 let base_evt = evt.as_web_event(); 702 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 703 + let cut_text = document.with_mut(|doc| { 704 if let Some(sel) = doc.selection { 705 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 706 if start != end { 707 + // Extract text and strip zero-width chars 708 let selected_text = doc.slice(start, end).unwrap_or_default(); 709 + let clean_text = selected_text 710 + .replace('\u{200C}', "") 711 + .replace('\u{200B}', ""); 712 713 + // Write to clipboard BEFORE deleting (sync fallback) 714 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 715 + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 716 tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 717 } 718 } ··· 721 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 722 doc.cursor.offset = start; 723 doc.selection = None; 724 + 725 + return Some(clean_text); 726 } 727 } 728 + None 729 }); 730 + 731 + // Async: also write custom MIME type for internal paste detection 732 + if let Some(text) = cut_text { 733 + wasm_bindgen_futures::spawn_local(async move { 734 + if let Err(e) = write_clipboard_with_custom_type(&text).await { 735 + tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); 736 + } 737 + }); 738 + } 739 } 740 } 741 ··· 766 .replace('\u{200C}', "") 767 .replace('\u{200B}', ""); 768 769 + // Sync fallback: write text/plain via DataTransfer 770 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 771 if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 772 tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 773 } 774 } 775 776 + // Async: also write custom MIME type for internal paste detection 777 + let text_for_async = clean_text.clone(); 778 + wasm_bindgen_futures::spawn_local(async move { 779 + if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { 780 + tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); 781 + } 782 + }); 783 + 784 // Prevent browser's default copy (which would copy rendered HTML) 785 evt.prevent_default(); 786 } ··· 794 } 795 } 796 797 + /// Copy markdown as rendered HTML to clipboard 798 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 799 + async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { 800 + use js_sys::Array; 801 + use wasm_bindgen::JsValue; 802 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 803 + 804 + // Render markdown to HTML using ClientWriter 805 + let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); 806 + let mut html = String::new(); 807 + weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( 808 + parser.map(|(evt, _range)| evt), 809 + &mut html, 810 + ) 811 + .run() 812 + .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 813 + 814 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 815 + let clipboard = window.navigator().clipboard(); 816 + 817 + // Create blobs for both HTML and plain text (raw HTML for inspection) 818 + let parts = Array::new(); 819 + parts.push(&JsValue::from_str(&html)); 820 + 821 + let mut html_opts = BlobPropertyBag::new(); 822 + html_opts.type_("text/html"); 823 + let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 824 + 825 + let mut text_opts = BlobPropertyBag::new(); 826 + text_opts.type_("text/plain"); 827 + let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 828 + 829 + // Create ClipboardItem with both types 830 + let item_data = js_sys::Object::new(); 831 + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 832 + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 833 + 834 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 835 + let items = Array::new(); 836 + items.push(&clipboard_item); 837 + 838 + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 839 + tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 840 + Ok(()) 841 + } 842 + 843 + /// Write text to clipboard with both text/plain and custom MIME type 844 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 845 + async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { 846 + use js_sys::{Array, Object, Reflect}; 847 + use wasm_bindgen::JsValue; 848 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 849 + 850 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 851 + let navigator = window.navigator(); 852 + let clipboard = navigator.clipboard(); 853 + 854 + // Create blobs for each MIME type 855 + let text_parts = Array::new(); 856 + text_parts.push(&JsValue::from_str(text)); 857 + 858 + let mut text_opts = BlobPropertyBag::new(); 859 + text_opts.type_("text/plain"); 860 + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 861 + 862 + let mut custom_opts = BlobPropertyBag::new(); 863 + custom_opts.type_("text/x-weaver-md"); 864 + let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 865 + 866 + // Create ClipboardItem with both types 867 + let item_data = Object::new(); 868 + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 869 + Reflect::set( 870 + &item_data, 871 + &JsValue::from_str("text/x-weaver-md"), 872 + &custom_blob, 873 + )?; 874 + 875 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 876 + let items = Array::new(); 877 + items.push(&clipboard_item); 878 + 879 + let promise = clipboard.write(&items); 880 + wasm_bindgen_futures::JsFuture::from(promise).await?; 881 + 882 + Ok(()) 883 + } 884 + 885 /// Extract a slice of text from a string by char indices 886 fn extract_text_slice(text: &str, start: usize, end: usize) -> String { 887 + text.chars() 888 + .skip(start) 889 + .take(end.saturating_sub(start)) 890 + .collect() 891 } 892 893 /// Handle keyboard events and update document state ··· 936 doc.selection = None; 937 return; 938 } 939 + "e" => { 940 + // Ctrl+E = copy as HTML (export) 941 + if let Some(sel) = doc.selection { 942 + let (start, end) = 943 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 944 + if start != end { 945 + if let Some(markdown) = doc.slice(start, end) { 946 + let clean_md = markdown 947 + .replace('\u{200C}', "") 948 + .replace('\u{200B}', ""); 949 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 950 + wasm_bindgen_futures::spawn_local(async move { 951 + if let Err(e) = copy_as_html(&clean_md).await { 952 + tracing::warn!("[COPY HTML] Failed: {:?}", e); 953 + } 954 + }); 955 + } 956 + } 957 + } 958 + return; 959 + } 960 _ => {} 961 } 962 } ··· 967 let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 968 doc.cursor.offset = start + ch.chars().count(); 969 } else { 970 + // Clean up any preceding zero-width chars (gap scaffolding) 971 + let mut delete_start = doc.cursor.offset; 972 + while delete_start > 0 { 973 + match get_char_at(doc.loro_text(), delete_start - 1) { 974 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 975 + _ => break, 976 + } 977 + } 978 + 979 + let zw_count = doc.cursor.offset - delete_start; 980 + if zw_count > 0 { 981 + // Splice: delete zero-width chars and insert new char in one op 982 + let _ = doc.replace_tracked(delete_start, zw_count, &ch); 983 + doc.cursor.offset = delete_start + ch.chars().count(); 984 + } else if doc.cursor.offset == doc.len_chars() { 985 + // Fast path: append at end 986 + let _ = doc.push_tracked(&ch); 987 + doc.cursor.offset += ch.chars().count(); 988 + } else { 989 + let _ = doc.insert_tracked(doc.cursor.offset, &ch); 990 + doc.cursor.offset += ch.chars().count(); 991 + } 992 } 993 } 994 ··· 1038 } 1039 1040 // Delete from where we stopped to end (including any trailing zero-width) 1041 + let _ = doc 1042 + .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 1043 doc.cursor.offset = delete_start; 1044 } else { 1045 // Normal backspace - delete one char ··· 1090 let delete_end = (line_end + 1).min(doc.len_chars()); 1091 1092 // Use replace_tracked to atomically delete line and insert paragraph break 1093 + let _ = doc.replace_tracked( 1094 + line_start, 1095 + delete_end.saturating_sub(line_start), 1096 + "\n\n\u{200C}\n", 1097 + ); 1098 doc.cursor.offset = line_start + 2; 1099 } else { 1100 // Non-empty item - continue list ··· 1195 /// Check if the current list item is empty (just the marker, no content after cursor). 1196 /// 1197 /// Used to determine whether Enter should continue the list or exit it. 1198 + fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { 1199 let line_start = find_line_start(text, cursor_offset); 1200 let line_end = find_line_end(text, cursor_offset); 1201
+5
crates/weaver-app/src/components/editor/render.rs
··· 87 for span in &mut adjusted_syntax { 88 span.char_range.start = apply_delta(span.char_range.start, char_delta); 89 span.char_range.end = apply_delta(span.char_range.end, char_delta); 90 } 91 92 ParagraphRender {
··· 87 for span in &mut adjusted_syntax { 88 span.char_range.start = apply_delta(span.char_range.start, char_delta); 89 span.char_range.end = apply_delta(span.char_range.end, char_delta); 90 + // Also adjust formatted_range if present (used for inline visibility) 91 + if let Some(ref mut fr) = span.formatted_range { 92 + fr.start = apply_delta(fr.start, char_delta); 93 + fr.end = apply_delta(fr.end, char_delta); 94 + } 95 } 96 97 ParagraphRender {
-1
crates/weaver-app/src/main.rs
··· 168 169 #[component] 170 fn App() -> Element { 171 - tracing::debug!("App component rendering"); 172 #[allow(unused)] 173 let fetcher = use_context_provider(|| { 174 fetch::Fetcher::new(OAuthClient::new(
··· 168 169 #[component] 170 fn App() -> Element { 171 #[allow(unused)] 172 let fetcher = use_context_provider(|| { 173 fetch::Fetcher::new(OAuthClient::new(
+1 -1
crates/weaver-app/src/views/navbar.rs
··· 17 #[component] 18 pub fn Navbar() -> Element { 19 let route = use_route::<Route>(); 20 - tracing::debug!("Route: {:?}", route); 21 22 let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 23 let (route_handle_res, route_handle) = use_load_handle(match &route {
··· 17 #[component] 18 pub fn Navbar() -> Element { 19 let route = use_route::<Route>(); 20 + tracing::trace!("Route: {:?}", route); 21 22 let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 23 let (route_handle_res, route_handle) = use_load_handle(match &route {