basic IME support, few more bug fixes

Orual 542142f9 81b460a4

+345 -43
+1 -1
crates/weaver-app/Cargo.toml
··· 65 65 chrono = { version = "0.4", features = ["wasmbind"] } 66 66 wasm-bindgen = "0.2" 67 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"] } 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 69 js-sys = "0.3" 70 70 gloo-storage = "0.3" 71 71 gloo-timers = "0.3"
+17
crates/weaver-app/src/components/editor/document.rs
··· 201 201 result 202 202 } 203 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 + 204 221 /// Remove text range and record edit info for incremental rendering. 205 222 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 206 223 let content = self.text.to_string();
+321 -40
crates/weaver-app/src/components/editor/mod.rs
··· 42 42 /// - LocalStorage auto-save with debouncing 43 43 /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 44 44 /// 45 - /// # Phase 1 Limitations 45 + /// # Phase 1 Limitations (mostly resolved) 46 46 /// - Cursor jumps to end after each keystroke (acceptable for MVP) 47 - /// - All formatting characters visible (no hiding based on cursor position) 47 + /// - All formatting characters visible (no hiding based on cursor position) - RESOLVED 48 48 /// - No proper grapheme cluster handling 49 - /// - No IME composition support 50 - /// - No undo/redo 49 + /// - No undo/redo - RESOLVED (Loro UndoManager) 51 50 /// - No selection with Shift+Arrow 52 - /// - No mouse selection 51 + /// - No mouse selection - RESOLVED 53 52 #[component] 54 53 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 55 54 // Try to restore from localStorage (includes CRDT state for undo history) ··· 101 100 // Update DOM when paragraphs change (incremental rendering) 102 101 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 103 102 use_effect(move || { 103 + tracing::info!("DOM update effect triggered"); 104 + 104 105 // Read document once to avoid multiple borrows 105 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 + 106 120 let cursor_offset = doc.cursor.offset; 107 121 let selection = doc.selection; 108 122 drop(doc); // Release borrow before other operations ··· 124 138 // Use requestAnimationFrame to wait for browser paint 125 139 if let Some(window) = web_sys::window() { 126 140 let closure = Closure::once(move || { 127 - if let Err(e) = 128 - cursor::restore_cursor_position(cursor_offset, &map, editor_id) 141 + if let Err(e) = cursor::restore_cursor_position(cursor_offset, &map, editor_id) 129 142 { 130 143 tracing::warn!("Cursor restoration failed: {:?}", e); 131 144 } ··· 140 153 cached_paragraphs.set(new_paras.clone()); 141 154 142 155 // Update syntax visibility after DOM changes 143 - update_syntax_visibility( 144 - cursor_offset, 145 - selection.as_ref(), 146 - &spans, 147 - &new_paras, 148 - ); 156 + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); 149 157 }); 150 158 151 159 // Track last saved frontiers to detect changes (peek-only, no subscriptions) ··· 170 178 171 179 if needs_save { 172 180 // 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 - }); 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 + }); 182 191 183 192 use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set 184 193 let snapshot_b64 = if snapshot_bytes.is_empty() { ··· 220 229 // DOM populated via web-sys in use_effect for incremental updates 221 230 222 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 + 223 250 // Only prevent default for operations that modify content 224 251 // Let browser handle arrow keys, Home/End naturally 225 252 if should_intercept_key(&evt) { ··· 279 306 oncopy: move |evt| { 280 307 handle_copy(evt, &document); 281 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 + }, 282 392 } 283 393 284 394 ··· 306 416 // Handle Ctrl/Cmd shortcuts 307 417 if mods.ctrl() || mods.meta() { 308 418 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"); 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 + } 311 425 } 312 426 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 313 427 return false; ··· 536 650 537 651 /// Handle paste events and insert text at cursor 538 652 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 653 + evt.prevent_default(); 654 + 539 655 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 540 656 { 541 657 use dioxus::web::WebEventExt; ··· 544 660 let base_evt = evt.as_web_event(); 545 661 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 546 662 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 547 - if let Ok(text) = data_transfer.get_data("text/plain") { 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 { 548 671 document.with_mut(|doc| { 549 672 // Delete selection if present 550 673 if let Some(sel) = doc.selection { ··· 568 691 569 692 /// Handle cut events - extract text, write to clipboard, then delete 570 693 fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 694 + evt.prevent_default(); 695 + 571 696 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 572 697 { 573 698 use dioxus::web::WebEventExt; ··· 575 700 576 701 let base_evt = evt.as_web_event(); 577 702 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 578 - document.with_mut(|doc| { 703 + let cut_text = document.with_mut(|doc| { 579 704 if let Some(sel) = doc.selection { 580 705 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 581 706 if start != end { 582 - // Extract text 707 + // Extract text and strip zero-width chars 583 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}', ""); 584 712 585 - // Write to clipboard BEFORE deleting 713 + // Write to clipboard BEFORE deleting (sync fallback) 586 714 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 587 - if let Err(e) = data_transfer.set_data("text/plain", &selected_text) { 715 + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 588 716 tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 589 717 } 590 718 } ··· 593 721 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 594 722 doc.cursor.offset = start; 595 723 doc.selection = None; 724 + 725 + return Some(clean_text); 596 726 } 597 727 } 728 + None 598 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 + } 599 739 } 600 740 } 601 741 ··· 626 766 .replace('\u{200C}', "") 627 767 .replace('\u{200B}', ""); 628 768 629 - // Write to clipboard 769 + // Sync fallback: write text/plain via DataTransfer 630 770 if let Some(data_transfer) = clipboard_evt.clipboard_data() { 631 771 if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 632 772 tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 633 773 } 634 774 } 635 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 + 636 784 // Prevent browser's default copy (which would copy rendered HTML) 637 785 evt.prevent_default(); 638 786 } ··· 646 794 } 647 795 } 648 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 + 649 885 /// Extract a slice of text from a string by char indices 650 886 fn extract_text_slice(text: &str, start: usize, end: usize) -> String { 651 - text.chars().skip(start).take(end.saturating_sub(start)).collect() 887 + text.chars() 888 + .skip(start) 889 + .take(end.saturating_sub(start)) 890 + .collect() 652 891 } 653 892 654 893 /// Handle keyboard events and update document state ··· 697 936 doc.selection = None; 698 937 return; 699 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 + } 700 960 _ => {} 701 961 } 702 962 } ··· 707 967 let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 708 968 doc.cursor.offset = start + ch.chars().count(); 709 969 } else { 710 - let _ = doc.insert_tracked(doc.cursor.offset, &ch); 711 - doc.cursor.offset += ch.chars().count(); 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 + } 712 992 } 713 993 } 714 994 ··· 758 1038 } 759 1039 760 1040 // 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)); 1041 + let _ = doc 1042 + .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 762 1043 doc.cursor.offset = delete_start; 763 1044 } else { 764 1045 // Normal backspace - delete one char ··· 809 1090 let delete_end = (line_end + 1).min(doc.len_chars()); 810 1091 811 1092 // 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"); 1093 + let _ = doc.replace_tracked( 1094 + line_start, 1095 + delete_end.saturating_sub(line_start), 1096 + "\n\n\u{200C}\n", 1097 + ); 813 1098 doc.cursor.offset = line_start + 2; 814 1099 } else { 815 1100 // Non-empty item - continue list ··· 910 1195 /// Check if the current list item is empty (just the marker, no content after cursor). 911 1196 /// 912 1197 /// 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 { 1198 + fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { 918 1199 let line_start = find_line_start(text, cursor_offset); 919 1200 let line_end = find_line_end(text, cursor_offset); 920 1201
+5
crates/weaver-app/src/components/editor/render.rs
··· 87 87 for span in &mut adjusted_syntax { 88 88 span.char_range.start = apply_delta(span.char_range.start, char_delta); 89 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 + } 90 95 } 91 96 92 97 ParagraphRender {
-1
crates/weaver-app/src/main.rs
··· 168 168 169 169 #[component] 170 170 fn App() -> Element { 171 - tracing::debug!("App component rendering"); 172 171 #[allow(unused)] 173 172 let fetcher = use_context_provider(|| { 174 173 fetch::Fetcher::new(OAuthClient::new(
+1 -1
crates/weaver-app/src/views/navbar.rs
··· 17 17 #[component] 18 18 pub fn Navbar() -> Element { 19 19 let route = use_route::<Route>(); 20 - tracing::debug!("Route: {:?}", route); 20 + tracing::trace!("Route: {:?}", route); 21 21 22 22 let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 23 23 let (route_handle_res, route_handle) = use_load_handle(match &route {