perf tuning/refactor

Orual 157cb538 468ca19d

+1504 -786
+2 -1
crates/weaver-app/assets/styling/editor.css
··· 227 228 /* Hidden syntax spans - collapsed when cursor is not near */ 229 .md-syntax-inline.hidden, 230 - .md-syntax-block.hidden { 231 display: none; 232 } 233
··· 227 228 /* Hidden syntax spans - collapsed when cursor is not near */ 229 .md-syntax-inline.hidden, 230 + .md-syntax-block.hidden, 231 + .image-alt.hidden { 232 display: none; 233 } 234
+402 -328
crates/weaver-app/src/components/editor/component.rs
··· 11 use crate::fetch::Fetcher; 12 13 use super::document::{CompositionState, EditorDocument}; 14 - use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; 15 use super::formatting; 16 use super::input::{ 17 get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, ··· 45 // Try to restore from localStorage (includes CRDT state for undo history) 46 // Use "current" as the default draft key for now 47 let draft_key = "current"; 48 - let mut document = use_signal(move || { 49 storage::load_from_storage(draft_key) 50 .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 51 }); ··· 58 let mut image_resolver = use_signal(EditorImageResolver::default); 59 60 // Render paragraphs with incremental caching 61 let paragraphs = use_memo(move || { 62 - let doc = document(); 63 let cache = render_cache.peek(); 64 - let edit = doc.last_edit.as_ref(); 65 let resolver = image_resolver(); 66 67 - let (paras, new_cache) = 68 - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver)); 69 70 // Update cache for next render (write-only via spawn to avoid reactive loop) 71 dioxus::prelude::spawn(async move { ··· 96 97 // Update DOM when paragraphs change (incremental rendering) 98 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 99 use_effect(move || { 100 tracing::debug!("DOM update effect triggered"); 101 102 - // Read document once to avoid multiple borrows 103 - let doc = document(); 104 - 105 tracing::debug!( 106 - composition_active = doc.composition.is_some(), 107 - cursor = doc.cursor.offset, 108 "DOM update: checking state" 109 ); 110 111 // Skip DOM updates during IME composition - browser controls the preview 112 - if doc.composition.is_some() { 113 tracing::debug!("skipping DOM update during composition"); 114 return; 115 } 116 117 tracing::debug!( 118 - cursor = doc.cursor.offset, 119 - len = doc.len_chars(), 120 "DOM update proceeding (not in composition)" 121 ); 122 123 - let cursor_offset = doc.cursor.offset; 124 - let selection = doc.selection; 125 - drop(doc); // Release borrow before other operations 126 127 let new_paras = paragraphs(); 128 let map = offset_map(); ··· 138 use wasm_bindgen::JsCast; 139 use wasm_bindgen::prelude::*; 140 141 // Use requestAnimationFrame to wait for browser paint 142 if let Some(window) = web_sys::window() { 143 let closure = Closure::once(move || { 144 if let Err(e) = 145 - super::cursor::restore_cursor_position(cursor_offset, &map, editor_id) 146 { 147 tracing::warn!("Cursor restoration failed: {:?}", e); 148 } ··· 164 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 165 166 // Auto-save with periodic check (no reactive dependency to avoid loops) 167 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 168 use_effect(move || { 169 // Check every 500ms if there are unsaved changes 170 let interval = gloo_timers::callback::Interval::new(500, move || { 171 - // Peek both signals without creating reactive dependencies 172 - let current_frontiers = document.peek().state_frontiers(); 173 174 // Only save if frontiers changed (document was edited) 175 let needs_save = { ··· 181 }; // drop last_frontiers borrow here 182 183 if needs_save { 184 - document.with_mut(|doc| { 185 - doc.sync_loro_cursor(); 186 - let _ = storage::save_to_storage(doc, draft_key); 187 - }); 188 189 // Update last saved frontiers 190 last_saved_frontiers.set(Some(current_frontiers)); ··· 194 }); 195 196 // Set up beforeinput listener for iOS/Android virtual keyboard quirks 197 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 198 use_effect(move || { 199 use wasm_bindgen::JsCast; ··· 219 None => return, 220 }; 221 222 - let mut document_signal = document; 223 let cached_paras = cached_paragraphs; 224 225 let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { ··· 235 evt.prevent_default(); 236 237 // Handle as Enter key 238 - document_signal.with_mut(|doc| { 239 - if let Some(sel) = doc.selection.take() { 240 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 241 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 242 - doc.cursor.offset = start; 243 - } 244 245 - if input_type == "insertLineBreak" { 246 - // Soft break (like Shift+Enter) 247 - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 248 - doc.cursor.offset += 3; 249 - } else { 250 - // Paragraph break 251 - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 252 - doc.cursor.offset += 2; 253 - } 254 - }); 255 } 256 257 // Android workaround: When swipe keyboard picks a suggestion, ··· 264 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 265 // Defer cursor sync by 20ms to let selection settle 266 let paras = cached_paras; 267 - let doc_sig = document_signal; 268 let window = web_sys::window(); 269 if let Some(window) = window { 270 let closure = Closure::once(move || { 271 let paras = paras(); 272 - sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, &paras); 273 }); 274 let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 275 closure.as_ref().unchecked_ref(), ··· 299 r#type: "text", 300 class: "title-input", 301 placeholder: "Entry title...", 302 - value: "{document().title()}", 303 - oninput: move |e| { 304 - document.with_mut(|doc| doc.set_title(&e.value())); 305 }, 306 } 307 } ··· 314 r#type: "text", 315 class: "path-input", 316 placeholder: "url-slug", 317 - value: "{document().path()}", 318 - oninput: move |e| { 319 - document.with_mut(|doc| doc.set_path(&e.value())); 320 }, 321 } 322 } ··· 324 div { class: "meta-tags", 325 label { "Tags" } 326 div { class: "tags-container", 327 - for tag in document().tags() { 328 span { 329 class: "tag-chip", 330 "{tag}" 331 button { 332 class: "tag-remove", 333 onclick: { 334 let tag_to_remove = tag.clone(); 335 move |_| { 336 - document.with_mut(|doc| doc.remove_tag(&tag_to_remove)); 337 } 338 }, 339 "×" ··· 346 placeholder: "Add tag...", 347 value: "{new_tag}", 348 oninput: move |e| new_tag.set(e.value()), 349 - onkeydown: move |e| { 350 - use dioxus::prelude::keyboard_types::Key; 351 - if e.key() == Key::Enter && !new_tag().trim().is_empty() { 352 - e.prevent_default(); 353 - let tag = new_tag().trim().to_string(); 354 - document.with_mut(|doc| doc.add_tag(&tag)); 355 - new_tag.set(String::new()); 356 } 357 }, 358 } ··· 360 } 361 362 PublishButton { 363 - document: document, 364 draft_key: draft_key.to_string(), 365 } 366 } ··· 372 class: "editor-content", 373 contenteditable: "true", 374 375 - onkeydown: move |evt| { 376 - use dioxus::prelude::keyboard_types::Key; 377 - use std::time::Duration; 378 379 - let plat = platform::platform(); 380 - let mods = evt.modifiers(); 381 - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 382 383 - // During IME composition: 384 - // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 385 - // - Allow Escape to cancel composition 386 - // - Block text input (let browser handle composition preview) 387 - if document.peek().composition.is_some() { 388 - if evt.key() == Key::Escape { 389 - tracing::debug!("Escape pressed - cancelling composition"); 390 - document.with_mut(|doc| { 391 - doc.composition = None; 392 - }); 393 - return; 394 - } 395 396 - // Allow modifier shortcuts through during composition 397 - if !has_modifier { 398 - tracing::debug!( 399 - key = ?evt.key(), 400 - "keydown during composition - delegating to browser" 401 - ); 402 - return; 403 - } 404 - // Fall through to handle the shortcut 405 - } 406 - 407 - // Safari workaround: After Japanese IME composition ends, both 408 - // compositionend and keydown fire for Enter. Ignore keydown 409 - // within 500ms of composition end to prevent double-newline. 410 - if plat.safari && evt.key() == Key::Enter { 411 - if let Some(ended_at) = document.peek().composition_ended_at { 412 - if ended_at.elapsed() < Duration::from_millis(500) { 413 tracing::debug!( 414 - "Safari: ignoring Enter within 500ms of compositionend" 415 ); 416 return; 417 } 418 } 419 - } 420 421 - // Android workaround: Chrome Android gets confused by Enter during/after 422 - // composition. Defer Enter handling to onkeypress instead. 423 - if plat.android && evt.key() == Key::Enter { 424 - tracing::debug!("Android: deferring Enter to keypress"); 425 - return; 426 - } 427 428 - // Only prevent default for operations that modify content 429 - // Let browser handle arrow keys, Home/End naturally 430 - if should_intercept_key(&evt) { 431 - evt.prevent_default(); 432 - handle_keydown(evt, &mut document); 433 } 434 }, 435 436 - onkeyup: move |evt| { 437 - use dioxus::prelude::keyboard_types::Key; 438 439 - // Navigation keys (with or without Shift for selection) 440 - let navigation = matches!( 441 - evt.key(), 442 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 443 - Key::Home | Key::End | Key::PageUp | Key::PageDown 444 - ); 445 446 - // Cmd/Ctrl+A for select all 447 - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 448 - && matches!(evt.key(), Key::Character(ref c) if c == "a"); 449 450 - if navigation || select_all { 451 let paras = cached_paragraphs(); 452 - sync_cursor_from_dom(&mut document, editor_id, &paras); 453 - let doc = document(); 454 let spans = syntax_spans(); 455 update_syntax_visibility( 456 - doc.cursor.offset, 457 - doc.selection.as_ref(), 458 &spans, 459 &paras, 460 ); 461 } 462 }, 463 464 - onselect: move |_evt| { 465 - tracing::debug!("onselect fired"); 466 - let paras = cached_paragraphs(); 467 - sync_cursor_from_dom(&mut document, editor_id, &paras); 468 - let doc = document(); 469 - let spans = syntax_spans(); 470 - update_syntax_visibility( 471 - doc.cursor.offset, 472 - doc.selection.as_ref(), 473 - &spans, 474 - &paras, 475 - ); 476 }, 477 478 - onselectstart: move |_evt| { 479 - tracing::debug!("onselectstart fired"); 480 - let paras = cached_paragraphs(); 481 - sync_cursor_from_dom(&mut document, editor_id, &paras); 482 - let doc = document(); 483 - let spans = syntax_spans(); 484 - update_syntax_visibility( 485 - doc.cursor.offset, 486 - doc.selection.as_ref(), 487 - &spans, 488 - &paras, 489 - ); 490 - }, 491 - 492 - onselectionchange: move |_evt| { 493 - tracing::debug!("onselectionchange fired"); 494 - let paras = cached_paragraphs(); 495 - sync_cursor_from_dom(&mut document, editor_id, &paras); 496 - let doc = document(); 497 - let spans = syntax_spans(); 498 - update_syntax_visibility( 499 - doc.cursor.offset, 500 - doc.selection.as_ref(), 501 - &spans, 502 - &paras, 503 - ); 504 }, 505 506 - onclick: move |_evt| { 507 - tracing::debug!("onclick fired"); 508 - let paras = cached_paragraphs(); 509 - sync_cursor_from_dom(&mut document, editor_id, &paras); 510 - let doc = document(); 511 - let spans = syntax_spans(); 512 - update_syntax_visibility( 513 - doc.cursor.offset, 514 - doc.selection.as_ref(), 515 - &spans, 516 - &paras, 517 - ); 518 }, 519 520 // Android workaround: Handle Enter in keypress instead of keydown. 521 // Chrome Android fires confused composition events on Enter in keydown, 522 // but keypress fires after composition state settles. 523 - onkeypress: move |evt| { 524 - use dioxus::prelude::keyboard_types::Key; 525 526 - let plat = platform::platform(); 527 - if plat.android && evt.key() == Key::Enter { 528 - tracing::debug!("Android: handling Enter in keypress"); 529 - evt.prevent_default(); 530 - handle_keydown(evt, &mut document); 531 } 532 }, 533 534 - onpaste: move |evt| { 535 - handle_paste(evt, &mut document); 536 }, 537 538 - oncut: move |evt| { 539 - handle_cut(evt, &mut document); 540 }, 541 542 - oncopy: move |evt| { 543 - handle_copy(evt, &document); 544 }, 545 546 - onblur: move |_| { 547 - // Cancel any in-progress IME composition on focus loss 548 - let had_composition = document.peek().composition.is_some(); 549 - if had_composition { 550 - tracing::debug!("onblur: clearing active composition"); 551 } 552 - document.with_mut(|doc| { 553 - doc.composition = None; 554 - }); 555 }, 556 557 - oncompositionstart: move |evt: CompositionEvent| { 558 - let data = evt.data().data(); 559 - tracing::debug!( 560 - data = %data, 561 - "compositionstart" 562 - ); 563 - document.with_mut(|doc| { 564 // Delete selection if present (composition replaces it) 565 - if let Some(sel) = doc.selection.take() { 566 let (start, end) = 567 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 568 tracing::debug!( ··· 571 "compositionstart: deleting selection" 572 ); 573 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 574 - doc.cursor.offset = start; 575 } 576 577 tracing::debug!( 578 - cursor = doc.cursor.offset, 579 "compositionstart: setting composition state" 580 ); 581 - doc.composition = Some(CompositionState { 582 - start_offset: doc.cursor.offset, 583 text: data, 584 - }); 585 - }); 586 }, 587 588 - oncompositionupdate: move |evt: CompositionEvent| { 589 - let data = evt.data().data(); 590 - tracing::debug!( 591 - data = %data, 592 - "compositionupdate" 593 - ); 594 - document.with_mut(|doc| { 595 - if let Some(ref mut comp) = doc.composition { 596 comp.text = data; 597 } else { 598 tracing::debug!("compositionupdate without active composition state"); 599 } 600 - }); 601 }, 602 603 - oncompositionend: move |evt: CompositionEvent| { 604 - let final_text = evt.data().data(); 605 - tracing::debug!( 606 - data = %final_text, 607 - "compositionend" 608 - ); 609 - document.with_mut(|doc| { 610 // Record when composition ended for Safari timing workaround 611 - doc.composition_ended_at = Some(web_time::Instant::now()); 612 613 - if let Some(comp) = doc.composition.take() { 614 tracing::debug!( 615 start_offset = comp.start_offset, 616 final_text = %final_text, ··· 627 } 628 } 629 630 - let zw_count = doc.cursor.offset - delete_start; 631 if zw_count > 0 { 632 // Splice: delete zero-width chars and insert new char in one op 633 let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 634 - doc.cursor.offset = delete_start + final_text.chars().count(); 635 - } else if doc.cursor.offset == doc.len_chars() { 636 // Fast path: append at end 637 let _ = doc.push_tracked(&final_text); 638 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 639 } else { 640 - let _ = doc.insert_tracked(doc.cursor.offset, &final_text); 641 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 642 } 643 } 644 } else { 645 tracing::debug!("compositionend without active composition state"); 646 } 647 - }); 648 }, 649 } 650 651 // Debug panel snug below editor 652 div { class: "editor-debug", 653 - div { "Cursor: {document().cursor.offset}, Chars: {document().len_chars()}" }, 654 ReportButton { 655 email: "editor-bugs@weaver.sh".to_string(), 656 editor_id: "markdown-editor".to_string(), ··· 660 661 // Toolbar in grid column 2, row 3 662 EditorToolbar { 663 - on_format: move |action| { 664 - document.with_mut(|doc| { 665 - formatting::apply_formatting(doc, action); 666 - }); 667 }, 668 - on_image: move |uploaded: super::image_upload::UploadedImage| { 669 - // Build data URL for immediate preview 670 - use base64::{Engine, engine::general_purpose::STANDARD}; 671 - let data_url = format!( 672 - "data:{};base64,{}", 673 - uploaded.mime_type, 674 - STANDARD.encode(&uploaded.data) 675 - ); 676 677 - // Add to resolver for immediate display 678 - let name = uploaded.name.clone(); 679 - image_resolver.with_mut(|resolver| { 680 - resolver.add_pending(name.clone(), data_url); 681 - }); 682 683 - // Insert markdown image syntax at cursor 684 - let alt_text = if uploaded.alt.is_empty() { 685 - name.clone() 686 - } else { 687 - uploaded.alt.clone() 688 - }; 689 - let markdown = format!("![{}](/image/{})", alt_text, name); 690 691 - document.with_mut(|doc| { 692 - let pos = doc.cursor.offset; 693 let _ = doc.insert_tracked(pos, &markdown); 694 - doc.cursor.offset = pos + markdown.chars().count(); 695 - }); 696 697 - // Upload to PDS in background if authenticated 698 - let is_authenticated = auth_state.read().is_authenticated(); 699 - if is_authenticated { 700 - let fetcher = fetcher.clone(); 701 - let name_for_upload = name.clone(); 702 - let alt_for_upload = alt_text.clone(); 703 - let data = uploaded.data.clone(); 704 705 - spawn(async move { 706 - let client = fetcher.get_client(); 707 708 - // Upload blob and create temporary PublishedBlob record 709 - match client.publish_blob(data, &name_for_upload, None).await { 710 - Ok((strong_ref, published_blob)) => { 711 - // Extract the blob from PublishedBlob 712 - let blob = match published_blob.upload { 713 - BlobRef::Blob(b) => b, 714 - _ => { 715 - tracing::warn!("Unexpected BlobRef variant"); 716 - return; 717 - } 718 - }; 719 720 - // Get format from mime type 721 - let format = blob 722 - .mime_type 723 - .0 724 - .strip_prefix("image/") 725 - .unwrap_or("jpeg") 726 - .to_string(); 727 728 - // Get DID from fetcher 729 - let did = match fetcher.current_did().await { 730 - Some(d) => d.to_string(), 731 - None => { 732 - tracing::warn!("No DID available"); 733 - return; 734 - } 735 - }; 736 737 - let cid = blob.cid().to_string(); 738 739 - // Build Image using the builder API 740 - let name_for_resolver = name_for_upload.clone(); 741 - let image = Image::new() 742 - .alt(alt_for_upload.to_cowstr()) 743 - .image(BlobRef::Blob(blob)) 744 - .name(name_for_upload.to_cowstr()) 745 - .build(); 746 747 - // Add to document 748 - document.with_mut(|doc| { 749 - doc.add_image(&image, Some(&strong_ref.uri)); 750 - }); 751 752 - // Promote from pending to uploaded in resolver 753 - image_resolver.with_mut(|resolver| { 754 - resolver.promote_to_uploaded( 755 - &name_for_resolver, 756 - cid, 757 - did, 758 - format, 759 - ); 760 - }); 761 762 - tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 763 - } 764 - Err(e) => { 765 - tracing::error!(error = %e, "Failed to upload image"); 766 - // Image stays as data URL - will work for preview but not publish 767 } 768 - } 769 - }); 770 - } else { 771 - tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 772 } 773 - } 774 } 775 776 }
··· 11 use crate::fetch::Fetcher; 12 13 use super::document::{CompositionState, EditorDocument}; 14 + use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom}; 15 + use super::offset_map::SnapDirection; 16 use super::formatting; 17 use super::input::{ 18 get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, ··· 46 // Try to restore from localStorage (includes CRDT state for undo history) 47 // Use "current" as the default draft key for now 48 let draft_key = "current"; 49 + // Document is NOT in a signal - its fields are individually reactive 50 + let mut document = use_hook(|| { 51 storage::load_from_storage(draft_key) 52 .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 53 }); ··· 60 let mut image_resolver = use_signal(EditorImageResolver::default); 61 62 // Render paragraphs with incremental caching 63 + // Reads document.last_edit signal - creates dependency on content changes only 64 + let doc_for_memo = document.clone(); 65 let paragraphs = use_memo(move || { 66 + let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency 67 let cache = render_cache.peek(); 68 let resolver = image_resolver(); 69 70 + let (paras, new_cache) = render::render_paragraphs_incremental( 71 + doc_for_memo.loro_text(), 72 + Some(&cache), 73 + edit.as_ref(), 74 + Some(&resolver), 75 + ); 76 77 // Update cache for next render (write-only via spawn to avoid reactive loop) 78 dioxus::prelude::spawn(async move { ··· 103 104 // Update DOM when paragraphs change (incremental rendering) 105 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 106 + let mut doc_for_dom = document.clone(); 107 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 108 use_effect(move || { 109 tracing::debug!("DOM update effect triggered"); 110 111 tracing::debug!( 112 + composition_active = doc_for_dom.composition.read().is_some(), 113 + cursor = doc_for_dom.cursor.read().offset, 114 "DOM update: checking state" 115 ); 116 117 // Skip DOM updates during IME composition - browser controls the preview 118 + if doc_for_dom.composition.read().is_some() { 119 tracing::debug!("skipping DOM update during composition"); 120 return; 121 } 122 123 tracing::debug!( 124 + cursor = doc_for_dom.cursor.read().offset, 125 + len = doc_for_dom.len_chars(), 126 "DOM update proceeding (not in composition)" 127 ); 128 129 + let cursor_offset = doc_for_dom.cursor.read().offset; 130 + let selection = *doc_for_dom.selection.read(); 131 132 let new_paras = paragraphs(); 133 let map = offset_map(); ··· 143 use wasm_bindgen::JsCast; 144 use wasm_bindgen::prelude::*; 145 146 + // Read and consume pending snap direction 147 + let snap_direction = doc_for_dom.pending_snap.write().take(); 148 + 149 // Use requestAnimationFrame to wait for browser paint 150 if let Some(window) = web_sys::window() { 151 let closure = Closure::once(move || { 152 if let Err(e) = 153 + super::cursor::restore_cursor_position(cursor_offset, &map, editor_id, snap_direction) 154 { 155 tracing::warn!("Cursor restoration failed: {:?}", e); 156 } ··· 172 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 173 174 // Auto-save with periodic check (no reactive dependency to avoid loops) 175 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 176 + let doc_for_autosave = document.clone(); 177 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 178 use_effect(move || { 179 // Check every 500ms if there are unsaved changes 180 + let mut doc = doc_for_autosave.clone(); 181 let interval = gloo_timers::callback::Interval::new(500, move || { 182 + let current_frontiers = doc.state_frontiers(); 183 184 // Only save if frontiers changed (document was edited) 185 let needs_save = { ··· 191 }; // drop last_frontiers borrow here 192 193 if needs_save { 194 + doc.sync_loro_cursor(); 195 + let _ = storage::save_to_storage(&doc, draft_key); 196 197 // Update last saved frontiers 198 last_saved_frontiers.set(Some(current_frontiers)); ··· 202 }); 203 204 // Set up beforeinput listener for iOS/Android virtual keyboard quirks 205 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 206 + let doc_for_beforeinput = document.clone(); 207 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 208 use_effect(move || { 209 use wasm_bindgen::JsCast; ··· 229 None => return, 230 }; 231 232 + let mut doc = doc_for_beforeinput.clone(); 233 let cached_paras = cached_paragraphs; 234 235 let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { ··· 245 evt.prevent_default(); 246 247 // Handle as Enter key 248 + let sel = doc.selection.write().take(); 249 + if let Some(sel) = sel { 250 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 251 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 252 + doc.cursor.write().offset = start; 253 + } 254 255 + let cursor_offset = doc.cursor.read().offset; 256 + if input_type == "insertLineBreak" { 257 + // Soft break (like Shift+Enter) 258 + let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); 259 + doc.cursor.write().offset = cursor_offset + 3; 260 + } else { 261 + // Paragraph break 262 + let _ = doc.insert_tracked(cursor_offset, "\n\n"); 263 + doc.cursor.write().offset = cursor_offset + 2; 264 + } 265 } 266 267 // Android workaround: When swipe keyboard picks a suggestion, ··· 274 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 275 // Defer cursor sync by 20ms to let selection settle 276 let paras = cached_paras; 277 + let mut doc_for_timeout = doc.clone(); 278 let window = web_sys::window(); 279 if let Some(window) = window { 280 let closure = Closure::once(move || { 281 let paras = paras(); 282 + sync_cursor_from_dom(&mut doc_for_timeout, editor_id, &paras); 283 }); 284 let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 285 closure.as_ref().unchecked_ref(), ··· 309 r#type: "text", 310 class: "title-input", 311 placeholder: "Entry title...", 312 + value: "{document.title()}", 313 + oninput: { 314 + let doc = document.clone(); 315 + move |e| { 316 + doc.set_title(&e.value()); 317 + } 318 }, 319 } 320 } ··· 327 r#type: "text", 328 class: "path-input", 329 placeholder: "url-slug", 330 + value: "{document.path()}", 331 + oninput: { 332 + let doc = document.clone(); 333 + move |e| { 334 + doc.set_path(&e.value()); 335 + } 336 }, 337 } 338 } ··· 340 div { class: "meta-tags", 341 label { "Tags" } 342 div { class: "tags-container", 343 + for tag in document.tags() { 344 span { 345 class: "tag-chip", 346 "{tag}" 347 button { 348 class: "tag-remove", 349 onclick: { 350 + let doc = document.clone(); 351 let tag_to_remove = tag.clone(); 352 move |_| { 353 + doc.remove_tag(&tag_to_remove); 354 } 355 }, 356 "×" ··· 363 placeholder: "Add tag...", 364 value: "{new_tag}", 365 oninput: move |e| new_tag.set(e.value()), 366 + onkeydown: { 367 + let doc = document.clone(); 368 + move |e| { 369 + use dioxus::prelude::keyboard_types::Key; 370 + if e.key() == Key::Enter && !new_tag().trim().is_empty() { 371 + e.prevent_default(); 372 + let tag = new_tag().trim().to_string(); 373 + doc.add_tag(&tag); 374 + new_tag.set(String::new()); 375 + } 376 } 377 }, 378 } ··· 380 } 381 382 PublishButton { 383 + document: document.clone(), 384 draft_key: draft_key.to_string(), 385 } 386 } ··· 392 class: "editor-content", 393 contenteditable: "true", 394 395 + onkeydown: { 396 + let mut doc = document.clone(); 397 + move |evt| { 398 + use dioxus::prelude::keyboard_types::Key; 399 + use std::time::Duration; 400 401 + let plat = platform::platform(); 402 + let mods = evt.modifiers(); 403 + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 404 405 + // During IME composition: 406 + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 407 + // - Allow Escape to cancel composition 408 + // - Block text input (let browser handle composition preview) 409 + if doc.composition.read().is_some() { 410 + if evt.key() == Key::Escape { 411 + tracing::debug!("Escape pressed - cancelling composition"); 412 + doc.composition.set(None); 413 + return; 414 + } 415 416 + // Allow modifier shortcuts through during composition 417 + if !has_modifier { 418 tracing::debug!( 419 + key = ?evt.key(), 420 + "keydown during composition - delegating to browser" 421 ); 422 return; 423 } 424 + // Fall through to handle the shortcut 425 } 426 427 + // Safari workaround: After Japanese IME composition ends, both 428 + // compositionend and keydown fire for Enter. Ignore keydown 429 + // within 500ms of composition end to prevent double-newline. 430 + if plat.safari && evt.key() == Key::Enter { 431 + if let Some(ended_at) = *doc.composition_ended_at.read() { 432 + if ended_at.elapsed() < Duration::from_millis(500) { 433 + tracing::debug!( 434 + "Safari: ignoring Enter within 500ms of compositionend" 435 + ); 436 + return; 437 + } 438 + } 439 + } 440 441 + // Android workaround: Chrome Android gets confused by Enter during/after 442 + // composition. Defer Enter handling to onkeypress instead. 443 + if plat.android && evt.key() == Key::Enter { 444 + tracing::debug!("Android: deferring Enter to keypress"); 445 + return; 446 + } 447 + 448 + // Only prevent default for operations that modify content 449 + // Let browser handle arrow keys, Home/End naturally 450 + if should_intercept_key(&evt) { 451 + evt.prevent_default(); 452 + handle_keydown(evt, &mut doc); 453 + } 454 } 455 }, 456 457 + onkeyup: { 458 + let mut doc = document.clone(); 459 + move |evt| { 460 + use dioxus::prelude::keyboard_types::Key; 461 + 462 + // Arrow keys with direction hint for snapping 463 + let direction_hint = match evt.key() { 464 + Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), 465 + Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), 466 + _ => None, 467 + }; 468 469 + // Navigation keys (with or without Shift for selection) 470 + let navigation = matches!( 471 + evt.key(), 472 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 473 + Key::Home | Key::End | Key::PageUp | Key::PageDown 474 + ); 475 476 + // Cmd/Ctrl+A for select all 477 + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 478 + && matches!(evt.key(), Key::Character(ref c) if c == "a"); 479 480 + if navigation || select_all { 481 + let paras = cached_paragraphs(); 482 + if let Some(dir) = direction_hint { 483 + sync_cursor_from_dom_with_direction(&mut doc, editor_id, &paras, Some(dir)); 484 + } else { 485 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 486 + } 487 + let spans = syntax_spans(); 488 + let cursor_offset = doc.cursor.read().offset; 489 + let selection = *doc.selection.read(); 490 + update_syntax_visibility( 491 + cursor_offset, 492 + selection.as_ref(), 493 + &spans, 494 + &paras, 495 + ); 496 + } 497 + } 498 + }, 499 + 500 + onselect: { 501 + let mut doc = document.clone(); 502 + move |_evt| { 503 + tracing::debug!("onselect fired"); 504 let paras = cached_paragraphs(); 505 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 506 let spans = syntax_spans(); 507 + let cursor_offset = doc.cursor.read().offset; 508 + let selection = *doc.selection.read(); 509 update_syntax_visibility( 510 + cursor_offset, 511 + selection.as_ref(), 512 &spans, 513 &paras, 514 ); 515 } 516 }, 517 518 + onselectstart: { 519 + let mut doc = document.clone(); 520 + move |_evt| { 521 + tracing::debug!("onselectstart fired"); 522 + let paras = cached_paragraphs(); 523 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 524 + let spans = syntax_spans(); 525 + let cursor_offset = doc.cursor.read().offset; 526 + let selection = *doc.selection.read(); 527 + update_syntax_visibility( 528 + cursor_offset, 529 + selection.as_ref(), 530 + &spans, 531 + &paras, 532 + ); 533 + } 534 }, 535 536 + onselectionchange: { 537 + let mut doc = document.clone(); 538 + move |_evt| { 539 + tracing::debug!("onselectionchange fired"); 540 + let paras = cached_paragraphs(); 541 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 542 + let spans = syntax_spans(); 543 + let cursor_offset = doc.cursor.read().offset; 544 + let selection = *doc.selection.read(); 545 + update_syntax_visibility( 546 + cursor_offset, 547 + selection.as_ref(), 548 + &spans, 549 + &paras, 550 + ); 551 + } 552 }, 553 554 + onclick: { 555 + let mut doc = document.clone(); 556 + move |_evt| { 557 + tracing::debug!("onclick fired"); 558 + let paras = cached_paragraphs(); 559 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 560 + let spans = syntax_spans(); 561 + let cursor_offset = doc.cursor.read().offset; 562 + let selection = *doc.selection.read(); 563 + update_syntax_visibility( 564 + cursor_offset, 565 + selection.as_ref(), 566 + &spans, 567 + &paras, 568 + ); 569 + } 570 }, 571 572 // Android workaround: Handle Enter in keypress instead of keydown. 573 // Chrome Android fires confused composition events on Enter in keydown, 574 // but keypress fires after composition state settles. 575 + onkeypress: { 576 + let mut doc = document.clone(); 577 + move |evt| { 578 + use dioxus::prelude::keyboard_types::Key; 579 580 + let plat = platform::platform(); 581 + if plat.android && evt.key() == Key::Enter { 582 + tracing::debug!("Android: handling Enter in keypress"); 583 + evt.prevent_default(); 584 + handle_keydown(evt, &mut doc); 585 + } 586 } 587 }, 588 589 + onpaste: { 590 + let mut doc = document.clone(); 591 + move |evt| { 592 + handle_paste(evt, &mut doc); 593 + } 594 }, 595 596 + oncut: { 597 + let mut doc = document.clone(); 598 + move |evt| { 599 + handle_cut(evt, &mut doc); 600 + } 601 }, 602 603 + oncopy: { 604 + let doc = document.clone(); 605 + move |evt| { 606 + handle_copy(evt, &doc); 607 + } 608 }, 609 610 + onblur: { 611 + let mut doc = document.clone(); 612 + move |_| { 613 + // Cancel any in-progress IME composition on focus loss 614 + let had_composition = doc.composition.read().is_some(); 615 + if had_composition { 616 + tracing::debug!("onblur: clearing active composition"); 617 + } 618 + doc.composition.set(None); 619 } 620 }, 621 622 + oncompositionstart: { 623 + let mut doc = document.clone(); 624 + move |evt: CompositionEvent| { 625 + let data = evt.data().data(); 626 + tracing::debug!( 627 + data = %data, 628 + "compositionstart" 629 + ); 630 // Delete selection if present (composition replaces it) 631 + let sel = doc.selection.write().take(); 632 + if let Some(sel) = sel { 633 let (start, end) = 634 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 635 tracing::debug!( ··· 638 "compositionstart: deleting selection" 639 ); 640 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 641 + doc.cursor.write().offset = start; 642 } 643 644 + let cursor_offset = doc.cursor.read().offset; 645 tracing::debug!( 646 + cursor = cursor_offset, 647 "compositionstart: setting composition state" 648 ); 649 + doc.composition.set(Some(CompositionState { 650 + start_offset: cursor_offset, 651 text: data, 652 + })); 653 + } 654 }, 655 656 + oncompositionupdate: { 657 + let mut doc = document.clone(); 658 + move |evt: CompositionEvent| { 659 + let data = evt.data().data(); 660 + tracing::debug!( 661 + data = %data, 662 + "compositionupdate" 663 + ); 664 + let mut comp_guard = doc.composition.write(); 665 + if let Some(ref mut comp) = *comp_guard { 666 comp.text = data; 667 } else { 668 tracing::debug!("compositionupdate without active composition state"); 669 } 670 + } 671 }, 672 673 + oncompositionend: { 674 + let mut doc = document.clone(); 675 + move |evt: CompositionEvent| { 676 + let final_text = evt.data().data(); 677 + tracing::debug!( 678 + data = %final_text, 679 + "compositionend" 680 + ); 681 // Record when composition ended for Safari timing workaround 682 + doc.composition_ended_at.set(Some(web_time::Instant::now())); 683 684 + let comp = doc.composition.write().take(); 685 + if let Some(comp) = comp { 686 tracing::debug!( 687 start_offset = comp.start_offset, 688 final_text = %final_text, ··· 699 } 700 } 701 702 + let cursor_offset = doc.cursor.read().offset; 703 + let zw_count = cursor_offset - delete_start; 704 if zw_count > 0 { 705 // Splice: delete zero-width chars and insert new char in one op 706 let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 707 + doc.cursor.write().offset = delete_start + final_text.chars().count(); 708 + } else if cursor_offset == doc.len_chars() { 709 // Fast path: append at end 710 let _ = doc.push_tracked(&final_text); 711 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 712 } else { 713 + let _ = doc.insert_tracked(cursor_offset, &final_text); 714 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 715 } 716 } 717 } else { 718 tracing::debug!("compositionend without active composition state"); 719 } 720 + } 721 }, 722 } 723 724 // Debug panel snug below editor 725 div { class: "editor-debug", 726 + div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 727 ReportButton { 728 email: "editor-bugs@weaver.sh".to_string(), 729 editor_id: "markdown-editor".to_string(), ··· 733 734 // Toolbar in grid column 2, row 3 735 EditorToolbar { 736 + on_format: { 737 + let mut doc = document.clone(); 738 + move |action| { 739 + formatting::apply_formatting(&mut doc, action); 740 + } 741 }, 742 + on_image: { 743 + let mut doc = document.clone(); 744 + move |uploaded: super::image_upload::UploadedImage| { 745 + // Build data URL for immediate preview 746 + use base64::{Engine, engine::general_purpose::STANDARD}; 747 + let data_url = format!( 748 + "data:{};base64,{}", 749 + uploaded.mime_type, 750 + STANDARD.encode(&uploaded.data) 751 + ); 752 753 + // Add to resolver for immediate display 754 + let name = uploaded.name.clone(); 755 + image_resolver.with_mut(|resolver| { 756 + resolver.add_pending(name.clone(), data_url); 757 + }); 758 759 + // Insert markdown image syntax at cursor 760 + let alt_text = if uploaded.alt.is_empty() { 761 + name.clone() 762 + } else { 763 + uploaded.alt.clone() 764 + }; 765 + let markdown = format!("![{}](/image/{})", alt_text, name); 766 767 + let pos = doc.cursor.read().offset; 768 let _ = doc.insert_tracked(pos, &markdown); 769 + doc.cursor.write().offset = pos + markdown.chars().count(); 770 771 + // Upload to PDS in background if authenticated 772 + let is_authenticated = auth_state.read().is_authenticated(); 773 + if is_authenticated { 774 + let fetcher = fetcher.clone(); 775 + let name_for_upload = name.clone(); 776 + let alt_for_upload = alt_text.clone(); 777 + let data = uploaded.data.clone(); 778 + let mut doc_for_spawn = doc.clone(); 779 780 + spawn(async move { 781 + let client = fetcher.get_client(); 782 783 + // Upload blob and create temporary PublishedBlob record 784 + match client.publish_blob(data, &name_for_upload, None).await { 785 + Ok((strong_ref, published_blob)) => { 786 + // Extract the blob from PublishedBlob 787 + let blob = match published_blob.upload { 788 + BlobRef::Blob(b) => b, 789 + _ => { 790 + tracing::warn!("Unexpected BlobRef variant"); 791 + return; 792 + } 793 + }; 794 795 + // Get format from mime type 796 + let format = blob 797 + .mime_type 798 + .0 799 + .strip_prefix("image/") 800 + .unwrap_or("jpeg") 801 + .to_string(); 802 803 + // Get DID from fetcher 804 + let did = match fetcher.current_did().await { 805 + Some(d) => d.to_string(), 806 + None => { 807 + tracing::warn!("No DID available"); 808 + return; 809 + } 810 + }; 811 812 + let cid = blob.cid().to_string(); 813 814 + // Build Image using the builder API 815 + let name_for_resolver = name_for_upload.clone(); 816 + let image = Image::new() 817 + .alt(alt_for_upload.to_cowstr()) 818 + .image(BlobRef::Blob(blob)) 819 + .name(name_for_upload.to_cowstr()) 820 + .build(); 821 822 + // Add to document 823 + doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 824 825 + // Promote from pending to uploaded in resolver 826 + image_resolver.with_mut(|resolver| { 827 + resolver.promote_to_uploaded( 828 + &name_for_resolver, 829 + cid, 830 + did, 831 + format, 832 + ); 833 + }); 834 835 + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 836 + } 837 + Err(e) => { 838 + tracing::error!(error = %e, "Failed to upload image"); 839 + // Image stays as data URL - will work for preview but not publish 840 + } 841 } 842 + }); 843 + } else { 844 + tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 845 + } 846 } 847 + }, 848 } 849 850 }
+25 -4
crates/weaver-app/src/components/editor/cursor.rs
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 10 - use super::offset_map::{OffsetMapping, find_mapping_for_char}; 11 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use wasm_bindgen::JsCast; ··· 18 /// - `char_offset`: Cursor position as char offset in document 19 /// - `offset_map`: Mappings from source to DOM positions 20 /// - `editor_id`: DOM ID of the contenteditable element 21 /// 22 /// # Algorithm 23 /// 1. Find offset mapping containing char_offset ··· 29 char_offset: usize, 30 offset_map: &[OffsetMapping], 31 editor_id: &str, 32 ) -> Result<(), wasm_bindgen::JsValue> { 33 // Empty document - no cursor to restore 34 if offset_map.is_empty() { ··· 51 return Ok(()); 52 } 53 54 - // Find mapping for this cursor position 55 - let (mapping, _should_snap) = find_mapping_for_char(offset_map, char_offset) 56 - .ok_or("no mapping found for cursor offset")?; 57 58 tracing::trace!( 59 target: "weaver::cursor", ··· 160 _char_offset: usize, 161 _offset_map: &[OffsetMapping], 162 _editor_id: &str, 163 ) -> Result<(), String> { 164 Ok(()) 165 }
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 10 + use super::offset_map::{OffsetMapping, SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 11 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use wasm_bindgen::JsCast; ··· 18 /// - `char_offset`: Cursor position as char offset in document 19 /// - `offset_map`: Mappings from source to DOM positions 20 /// - `editor_id`: DOM ID of the contenteditable element 21 + /// - `snap_direction`: Optional direction hint for snapping from invisible content 22 /// 23 /// # Algorithm 24 /// 1. Find offset mapping containing char_offset ··· 30 char_offset: usize, 31 offset_map: &[OffsetMapping], 32 editor_id: &str, 33 + snap_direction: Option<SnapDirection>, 34 ) -> Result<(), wasm_bindgen::JsValue> { 35 // Empty document - no cursor to restore 36 if offset_map.is_empty() { ··· 53 return Ok(()); 54 } 55 56 + // Find mapping for this cursor position, snapping if needed 57 + let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { 58 + Some((m, false)) => (m, char_offset), // Valid position, use as-is 59 + Some((m, true)) => { 60 + // Position is on invisible content, snap to nearest valid 61 + if let Some(snapped) = find_nearest_valid_position(offset_map, char_offset, snap_direction) { 62 + tracing::trace!( 63 + target: "weaver::cursor", 64 + original_offset = char_offset, 65 + snapped_offset = snapped.char_offset(), 66 + direction = ?snapped.snapped, 67 + "snapping cursor from invisible content" 68 + ); 69 + (snapped.mapping, snapped.char_offset()) 70 + } else { 71 + // Fallback to original mapping if no valid snap target 72 + (m, char_offset) 73 + } 74 + } 75 + None => return Err("no mapping found for cursor offset".into()), 76 + }; 77 78 tracing::trace!( 79 target: "weaver::cursor", ··· 180 _char_offset: usize, 181 _offset_map: &[OffsetMapping], 182 _editor_id: &str, 183 + _snap_direction: Option<SnapDirection>, 184 ) -> Result<(), String> { 185 Ok(()) 186 }
+118 -73
crates/weaver-app/src/components/editor/document.rs
··· 2 //! 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 //! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. 5 6 use loro::{ 7 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, 8 cursor::{Cursor, Side}, ··· 30 /// Contains the document text (backed by Loro CRDT), cursor position, 31 /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 32 /// schema with CRDT containers for each field. 33 - #[derive(Debug)] 34 pub struct EditorDocument { 35 /// The Loro document containing all editor state. 36 doc: LoroDoc, 37 38 - // --- Entry schema containers --- 39 /// Markdown content (maps to entry.content) 40 content: LoroText, 41 ··· 60 /// None for new entries that haven't been published yet. 61 entry_uri: Option<AtUri<'static>>, 62 63 - // --- Editor state --- 64 /// Undo manager for the document. 65 - undo_mgr: UndoManager, 66 - 67 - /// Current cursor position (char offset) - fast local cache. 68 - /// This is the authoritative position for immediate operations. 69 - pub cursor: CursorState, 70 71 /// CRDT-aware cursor that tracks position through remote edits and undo/redo. 72 /// Recreated after our own edits, queried after undo/redo/remote edits. 73 loro_cursor: Option<Cursor>, 74 75 - /// Active selection if any 76 - pub selection: Option<Selection>, 77 78 - /// IME composition state (for Phase 3) 79 - pub composition: Option<CompositionState>, 80 81 /// Timestamp when the last composition ended. 82 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 83 - pub composition_ended_at: Option<web_time::Instant>, 84 85 /// Most recent edit info for incremental rendering optimization. 86 - /// Used to determine if we can skip full re-parsing. 87 - pub last_edit: Option<EditInfo>, 88 } 89 90 /// Cursor state including position and affinity. ··· 121 } 122 123 /// Information about the most recent edit, used for incremental rendering optimization. 124 - #[derive(Clone, Debug, Default)] 125 pub struct EditInfo { 126 /// Character offset where the edit occurred 127 pub edit_char_pos: usize, ··· 170 171 /// Create a new editor document with the given content. 172 /// Sets `created_at` to current time. 173 pub fn new(initial_content: String) -> Self { 174 let doc = LoroDoc::new(); 175 ··· 211 tags, 212 embeds, 213 entry_uri: None, 214 - undo_mgr, 215 - cursor: CursorState { 216 offset: 0, 217 affinity: Affinity::Before, 218 - }, 219 - loro_cursor, 220 - selection: None, 221 - composition: None, 222 - composition_ended_at: None, 223 - last_edit: None, 224 } 225 } 226 ··· 284 } 285 286 /// Set the entry title (replaces existing). 287 - pub fn set_title(&mut self, new_title: &str) { 288 let current_len = self.title.len_unicode(); 289 if current_len > 0 { 290 self.title.delete(0, current_len).ok(); ··· 298 } 299 300 /// Set the URL path/slug (replaces existing). 301 - pub fn set_path(&mut self, new_path: &str) { 302 let current_len = self.path.len_unicode(); 303 if current_len > 0 { 304 self.path.delete(0, current_len).ok(); ··· 312 } 313 314 /// Set the created_at timestamp (usually only called once on creation or when loading). 315 - pub fn set_created_at(&mut self, datetime: &str) { 316 let current_len = self.created_at.len_unicode(); 317 if current_len > 0 { 318 self.created_at.delete(0, current_len).ok(); ··· 346 } 347 348 /// Add a tag (if not already present). 349 - pub fn add_tag(&mut self, tag: &str) { 350 let existing = self.tags(); 351 if !existing.iter().any(|t| t == tag) { 352 self.tags.push(LoroValue::String(tag.into())).ok(); ··· 354 } 355 356 /// Remove a tag by value. 357 - pub fn remove_tag(&mut self, tag: &str) { 358 let len = self.tags.len(); 359 for i in (0..len).rev() { 360 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { ··· 367 } 368 369 /// Clear all tags. 370 - pub fn clear_tags(&mut self) { 371 let len = self.tags.len(); 372 if len > 0 { 373 self.tags.delete(0, len).ok(); ··· 484 let len_before = self.content.len_unicode(); 485 let result = self.content.insert(pos, text); 486 let len_after = self.content.len_unicode(); 487 - self.last_edit = Some(EditInfo { 488 edit_char_pos: pos, 489 inserted_len: len_after.saturating_sub(len_before), 490 deleted_len: 0, 491 contains_newline: text.contains('\n'), 492 in_block_syntax_zone, 493 doc_len_after: len_after, 494 - }); 495 result 496 } 497 ··· 501 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 502 let result = self.content.push_str(text); 503 let len_after = self.content.len_unicode(); 504 - self.last_edit = Some(EditInfo { 505 edit_char_pos: pos, 506 inserted_len: text.chars().count(), 507 deleted_len: 0, 508 contains_newline: text.contains('\n'), 509 in_block_syntax_zone, 510 doc_len_after: len_after, 511 - }); 512 result 513 } 514 ··· 519 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 520 521 let result = self.content.delete(start, len); 522 - self.last_edit = Some(EditInfo { 523 edit_char_pos: start, 524 inserted_len: 0, 525 deleted_len: len, 526 contains_newline, 527 in_block_syntax_zone, 528 doc_len_after: self.content.len_unicode(), 529 - }); 530 result 531 } 532 ··· 545 // because: len_after = len_before - deleted + inserted 546 let inserted_len = (len_after + len).saturating_sub(len_before); 547 548 - self.last_edit = Some(EditInfo { 549 edit_char_pos: start, 550 inserted_len, 551 deleted_len: len, 552 contains_newline: delete_has_newline || text.contains('\n'), 553 in_block_syntax_zone, 554 doc_len_after: len_after, 555 - }); 556 Ok(()) 557 } 558 ··· 564 // so it tracks through the undo operation 565 self.sync_loro_cursor(); 566 567 - let result = self.undo_mgr.undo()?; 568 if result { 569 // After undo, query Loro cursor for new position 570 self.sync_cursor_from_loro(); 571 } 572 Ok(result) 573 } ··· 579 // Sync Loro cursor to current position BEFORE redo 580 self.sync_loro_cursor(); 581 582 - let result = self.undo_mgr.redo()?; 583 if result { 584 // After redo, query Loro cursor for new position 585 self.sync_cursor_from_loro(); 586 } 587 Ok(result) 588 } 589 590 /// Check if undo is available. 591 pub fn can_undo(&self) -> bool { 592 - self.undo_mgr.can_undo() 593 } 594 595 /// Check if redo is available. 596 pub fn can_redo(&self) -> bool { 597 - self.undo_mgr.can_redo() 598 } 599 600 /// Get a slice of the content text. ··· 606 /// Sync the Loro cursor to the current cursor.offset position. 607 /// Call this after OUR edits where we know the new cursor position. 608 pub fn sync_loro_cursor(&mut self) { 609 - self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default()); 610 } 611 612 /// Update cursor.offset from the Loro cursor's tracked position. ··· 615 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 616 let loro_cursor = self.loro_cursor.as_ref()?; 617 let result = self.doc.get_cursor_pos(loro_cursor).ok()?; 618 - let new_offset = result.current.pos; 619 - self.cursor.offset = new_offset.min(self.len_chars()); 620 - Some(self.cursor.offset) 621 } 622 623 /// Get the Loro cursor for serialization. ··· 646 self.doc.state_frontiers() 647 } 648 649 /// Create a new EditorDocument from a binary snapshot. 650 /// Falls back to empty document if import fails. 651 /// ··· 655 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they 656 /// happen in real-time; it cannot rebuild history from imported CRDT ops. 657 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. 658 pub fn from_snapshot( 659 snapshot: &[u8], 660 loro_cursor: Option<Cursor>, ··· 691 fallback_offset 692 }; 693 694 - let cursor = CursorState { 695 offset: cursor_offset.min(max_offset), 696 affinity: Affinity::Before, 697 }; 698 699 // If no Loro cursor provided, create one at the restored position 700 let loro_cursor = 701 - loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default())); 702 703 Self { 704 doc, ··· 709 tags, 710 embeds, 711 entry_uri: None, 712 - undo_mgr, 713 - cursor, 714 loro_cursor, 715 - selection: None, 716 - composition: None, 717 - composition_ended_at: None, 718 - last_edit: None, 719 } 720 } 721 } 722 723 - // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. 724 - // This is intentional - the document should be the single source of truth. 725 - 726 - impl Clone for EditorDocument { 727 - fn clone(&self) -> Self { 728 - // Use snapshot export/import for a complete clone including all containers 729 - let snapshot = self.export_snapshot(); 730 - let mut new_doc = 731 - Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset); 732 - 733 - // Copy non-CRDT state 734 - new_doc.cursor = self.cursor; 735 - new_doc.sync_loro_cursor(); 736 - new_doc.selection = self.selection; 737 - new_doc.composition = self.composition.clone(); 738 - new_doc.composition_ended_at = self.composition_ended_at; 739 - new_doc.last_edit = self.last_edit.clone(); 740 - new_doc.entry_uri = self.entry_uri.clone(); 741 - new_doc 742 } 743 }
··· 2 //! 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 //! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. 5 + //! 6 + //! # Reactive Architecture 7 + //! 8 + //! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity: 9 + //! - Cursor/selection changes don't trigger content re-renders 10 + //! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation 11 + //! - The document struct itself is NOT wrapped in a Signal - use `use_hook` 12 13 + use std::cell::RefCell; 14 + use std::rc::Rc; 15 + 16 + use dioxus::prelude::*; 17 use loro::{ 18 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, 19 cursor::{Cursor, Side}, ··· 41 /// Contains the document text (backed by Loro CRDT), cursor position, 42 /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 43 /// schema with CRDT containers for each field. 44 + /// 45 + /// # Reactive Architecture 46 + /// 47 + /// The document itself is NOT wrapped in a Signal. Instead, individual fields 48 + /// that need reactivity are wrapped in Signals: 49 + /// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates 50 + /// - `last_edit` - triggers paragraph re-renders when content changes 51 + /// 52 + /// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`. 53 + /// 54 + /// # Cloning 55 + /// 56 + /// EditorDocument is cheap to clone - Loro types are Arc-backed handles, 57 + /// and Signals are Copy. Closures can capture clones without overhead. 58 + #[derive(Clone)] 59 pub struct EditorDocument { 60 /// The Loro document containing all editor state. 61 doc: LoroDoc, 62 63 + // --- Entry schema containers (Loro handles interior mutability) --- 64 /// Markdown content (maps to entry.content) 65 content: LoroText, 66 ··· 85 /// None for new entries that haven't been published yet. 86 entry_uri: Option<AtUri<'static>>, 87 88 + // --- Editor state (non-reactive) --- 89 /// Undo manager for the document. 90 + undo_mgr: Rc<RefCell<UndoManager>>, 91 92 /// CRDT-aware cursor that tracks position through remote edits and undo/redo. 93 /// Recreated after our own edits, queried after undo/redo/remote edits. 94 loro_cursor: Option<Cursor>, 95 96 + // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- 97 + /// Current cursor position. Signal so cursor changes don't dirty content memos. 98 + pub cursor: Signal<CursorState>, 99 100 + /// Active selection if any. Signal for same reason as cursor. 101 + pub selection: Signal<Option<Selection>>, 102 + 103 + /// IME composition state. Signal so composition updates are isolated. 104 + pub composition: Signal<Option<CompositionState>>, 105 106 /// Timestamp when the last composition ended. 107 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 108 + pub composition_ended_at: Signal<Option<web_time::Instant>>, 109 110 /// Most recent edit info for incremental rendering optimization. 111 + /// Signal so paragraphs memo can subscribe to content changes only. 112 + pub last_edit: Signal<Option<EditInfo>>, 113 + 114 + /// Pending snap direction for cursor restoration after edits. 115 + /// Set by input handlers, consumed by cursor restoration. 116 + pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>, 117 } 118 119 /// Cursor state including position and affinity. ··· 150 } 151 152 /// Information about the most recent edit, used for incremental rendering optimization. 153 + /// Derives PartialEq so it can be used with Dioxus memos for change detection. 154 + #[derive(Clone, Debug, Default, PartialEq)] 155 pub struct EditInfo { 156 /// Character offset where the edit occurred 157 pub edit_char_pos: usize, ··· 200 201 /// Create a new editor document with the given content. 202 /// Sets `created_at` to current time. 203 + /// 204 + /// # Note 205 + /// This creates Dioxus Signals for reactive fields. Call from within 206 + /// a component using `use_hook(|| EditorDocument::new(...))`. 207 pub fn new(initial_content: String) -> Self { 208 let doc = LoroDoc::new(); 209 ··· 245 tags, 246 embeds, 247 entry_uri: None, 248 + undo_mgr: Rc::new(RefCell::new(undo_mgr)), 249 + loro_cursor, 250 + // Reactive editor state - wrapped in Signals 251 + cursor: Signal::new(CursorState { 252 offset: 0, 253 affinity: Affinity::Before, 254 + }), 255 + selection: Signal::new(None), 256 + composition: Signal::new(None), 257 + composition_ended_at: Signal::new(None), 258 + last_edit: Signal::new(None), 259 + pending_snap: Signal::new(None), 260 } 261 } 262 ··· 320 } 321 322 /// Set the entry title (replaces existing). 323 + /// Takes &self because Loro has interior mutability. 324 + pub fn set_title(&self, new_title: &str) { 325 let current_len = self.title.len_unicode(); 326 if current_len > 0 { 327 self.title.delete(0, current_len).ok(); ··· 335 } 336 337 /// Set the URL path/slug (replaces existing). 338 + /// Takes &self because Loro has interior mutability. 339 + pub fn set_path(&self, new_path: &str) { 340 let current_len = self.path.len_unicode(); 341 if current_len > 0 { 342 self.path.delete(0, current_len).ok(); ··· 350 } 351 352 /// Set the created_at timestamp (usually only called once on creation or when loading). 353 + /// Takes &self because Loro has interior mutability. 354 + pub fn set_created_at(&self, datetime: &str) { 355 let current_len = self.created_at.len_unicode(); 356 if current_len > 0 { 357 self.created_at.delete(0, current_len).ok(); ··· 385 } 386 387 /// Add a tag (if not already present). 388 + /// Takes &self because Loro has interior mutability. 389 + pub fn add_tag(&self, tag: &str) { 390 let existing = self.tags(); 391 if !existing.iter().any(|t| t == tag) { 392 self.tags.push(LoroValue::String(tag.into())).ok(); ··· 394 } 395 396 /// Remove a tag by value. 397 + /// Takes &self because Loro has interior mutability. 398 + pub fn remove_tag(&self, tag: &str) { 399 let len = self.tags.len(); 400 for i in (0..len).rev() { 401 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { ··· 408 } 409 410 /// Clear all tags. 411 + /// Takes &self because Loro has interior mutability. 412 + pub fn clear_tags(&self) { 413 let len = self.tags.len(); 414 if len > 0 { 415 self.tags.delete(0, len).ok(); ··· 526 let len_before = self.content.len_unicode(); 527 let result = self.content.insert(pos, text); 528 let len_after = self.content.len_unicode(); 529 + self.last_edit.set(Some(EditInfo { 530 edit_char_pos: pos, 531 inserted_len: len_after.saturating_sub(len_before), 532 deleted_len: 0, 533 contains_newline: text.contains('\n'), 534 in_block_syntax_zone, 535 doc_len_after: len_after, 536 + })); 537 result 538 } 539 ··· 543 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 544 let result = self.content.push_str(text); 545 let len_after = self.content.len_unicode(); 546 + self.last_edit.set(Some(EditInfo { 547 edit_char_pos: pos, 548 inserted_len: text.chars().count(), 549 deleted_len: 0, 550 contains_newline: text.contains('\n'), 551 in_block_syntax_zone, 552 doc_len_after: len_after, 553 + })); 554 result 555 } 556 ··· 561 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 562 563 let result = self.content.delete(start, len); 564 + self.last_edit.set(Some(EditInfo { 565 edit_char_pos: start, 566 inserted_len: 0, 567 deleted_len: len, 568 contains_newline, 569 in_block_syntax_zone, 570 doc_len_after: self.content.len_unicode(), 571 + })); 572 result 573 } 574 ··· 587 // because: len_after = len_before - deleted + inserted 588 let inserted_len = (len_after + len).saturating_sub(len_before); 589 590 + self.last_edit.set(Some(EditInfo { 591 edit_char_pos: start, 592 inserted_len, 593 deleted_len: len, 594 contains_newline: delete_has_newline || text.contains('\n'), 595 in_block_syntax_zone, 596 doc_len_after: len_after, 597 + })); 598 Ok(()) 599 } 600 ··· 606 // so it tracks through the undo operation 607 self.sync_loro_cursor(); 608 609 + let result = self.undo_mgr.borrow_mut().undo()?; 610 if result { 611 // After undo, query Loro cursor for new position 612 self.sync_cursor_from_loro(); 613 + // Signal content change for re-render 614 + self.last_edit.set(None); 615 } 616 Ok(result) 617 } ··· 623 // Sync Loro cursor to current position BEFORE redo 624 self.sync_loro_cursor(); 625 626 + let result = self.undo_mgr.borrow_mut().redo()?; 627 if result { 628 // After redo, query Loro cursor for new position 629 self.sync_cursor_from_loro(); 630 + // Signal content change for re-render 631 + self.last_edit.set(None); 632 } 633 Ok(result) 634 } 635 636 /// Check if undo is available. 637 pub fn can_undo(&self) -> bool { 638 + self.undo_mgr.borrow().can_undo() 639 } 640 641 /// Check if redo is available. 642 pub fn can_redo(&self) -> bool { 643 + self.undo_mgr.borrow().can_redo() 644 } 645 646 /// Get a slice of the content text. ··· 652 /// Sync the Loro cursor to the current cursor.offset position. 653 /// Call this after OUR edits where we know the new cursor position. 654 pub fn sync_loro_cursor(&mut self) { 655 + let offset = self.cursor.read().offset; 656 + self.loro_cursor = self.content.get_cursor(offset, Side::default()); 657 } 658 659 /// Update cursor.offset from the Loro cursor's tracked position. ··· 662 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 663 let loro_cursor = self.loro_cursor.as_ref()?; 664 let result = self.doc.get_cursor_pos(loro_cursor).ok()?; 665 + let new_offset = result.current.pos.min(self.len_chars()); 666 + self.cursor.with_mut(|c| c.offset = new_offset); 667 + Some(new_offset) 668 } 669 670 /// Get the Loro cursor for serialization. ··· 693 self.doc.state_frontiers() 694 } 695 696 + /// Get the last edit info for incremental rendering. 697 + /// Reading this creates a reactive dependency on content changes. 698 + pub fn last_edit(&self) -> Option<EditInfo> { 699 + self.last_edit.read().clone() 700 + } 701 + 702 /// Create a new EditorDocument from a binary snapshot. 703 /// Falls back to empty document if import fails. 704 /// ··· 708 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they 709 /// happen in real-time; it cannot rebuild history from imported CRDT ops. 710 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. 711 + /// 712 + /// # Note 713 + /// This creates Dioxus Signals for reactive fields. Call from within 714 + /// a component using `use_hook`. 715 pub fn from_snapshot( 716 snapshot: &[u8], 717 loro_cursor: Option<Cursor>, ··· 748 fallback_offset 749 }; 750 751 + let cursor_state = CursorState { 752 offset: cursor_offset.min(max_offset), 753 affinity: Affinity::Before, 754 }; 755 756 // If no Loro cursor provided, create one at the restored position 757 let loro_cursor = 758 + loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default())); 759 760 Self { 761 doc, ··· 766 tags, 767 embeds, 768 entry_uri: None, 769 + undo_mgr: Rc::new(RefCell::new(undo_mgr)), 770 loro_cursor, 771 + // Reactive editor state - wrapped in Signals 772 + cursor: Signal::new(cursor_state), 773 + selection: Signal::new(None), 774 + composition: Signal::new(None), 775 + composition_ended_at: Signal::new(None), 776 + last_edit: Signal::new(None), 777 + pending_snap: Signal::new(None), 778 } 779 } 780 } 781 782 + impl PartialEq for EditorDocument { 783 + fn eq(&self, _other: &Self) -> bool { 784 + // EditorDocument uses interior mutability, so we can't meaningfully compare. 785 + // Return false to ensure components re-render when passed as props. 786 + false 787 } 788 }
+79 -21
crates/weaver-app/src/components/editor/dom_sync.rs
··· 6 use dioxus::prelude::*; 7 8 use super::document::{EditorDocument, Selection}; 9 use super::paragraph::ParagraphRender; 10 11 /// Sync internal cursor and selection state from browser DOM selection. 12 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 13 pub fn sync_cursor_from_dom( 14 - document: &mut Signal<EditorDocument>, 15 editor_id: &str, 16 paragraphs: &[ParagraphRender], 17 ) { 18 use wasm_bindgen::JsCast; 19 ··· 61 &anchor_node, 62 anchor_offset, 63 paragraphs, 64 ); 65 let focus_rope = dom_position_to_rope_offset( 66 &dom_document, ··· 68 &focus_node, 69 focus_offset, 70 paragraphs, 71 ); 72 73 - document.with_mut(|doc| { 74 - match (anchor_rope, focus_rope) { 75 - (Some(anchor), Some(focus)) => { 76 - doc.cursor.offset = focus; 77 - if anchor != focus { 78 - // There's an actual selection 79 - doc.selection = Some(Selection { 80 - anchor, 81 - head: focus, 82 - }); 83 - } else { 84 - // Collapsed selection (just cursor) 85 - doc.selection = None; 86 - } 87 - } 88 - _ => { 89 - tracing::warn!("Could not map DOM selection to rope offsets"); 90 } 91 } 92 - }); 93 } 94 95 /// Convert a DOM position (node + offset) to a rope char offset using offset maps. 96 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 97 fn dom_position_to_rope_offset( 98 dom_document: &web_sys::Document, ··· 100 node: &web_sys::Node, 101 offset_in_text_node: usize, 102 paragraphs: &[ParagraphRender], 103 ) -> Option<usize> { 104 use wasm_bindgen::JsCast; 105 ··· 170 && utf16_offset_in_container <= mapping_end 171 { 172 let offset_in_mapping = utf16_offset_in_container - mapping_start; 173 - return Some(mapping.char_range.start + offset_in_mapping); 174 } 175 } 176 } 177 } 178 179 None 180 } 181 182 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 183 pub fn sync_cursor_from_dom( 184 - _document: &mut Signal<EditorDocument>, 185 _editor_id: &str, 186 _paragraphs: &[ParagraphRender], 187 ) { 188 // No-op on non-wasm 189 } ··· 265 } 266 267 // Remove extra paragraphs if document got shorter 268 for idx in new_paragraphs.len()..old_paragraphs.len() { 269 let para_id = format!("para-{}", idx); 270 if let Some(elem) = document.get_element_by_id(&para_id) {
··· 6 use dioxus::prelude::*; 7 8 use super::document::{EditorDocument, Selection}; 9 + use super::offset_map::{find_nearest_valid_position, is_valid_cursor_position, SnapDirection}; 10 use super::paragraph::ParagraphRender; 11 12 /// Sync internal cursor and selection state from browser DOM selection. 13 + /// 14 + /// The optional `direction_hint` is used when snapping cursor from invisible content. 15 + /// Pass `SnapDirection::Backward` for left/up arrow keys, `SnapDirection::Forward` for right/down. 16 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 17 pub fn sync_cursor_from_dom( 18 + doc: &mut EditorDocument, 19 + editor_id: &str, 20 + paragraphs: &[ParagraphRender], 21 + ) { 22 + sync_cursor_from_dom_with_direction(doc, editor_id, paragraphs, None); 23 + } 24 + 25 + /// Sync cursor with optional direction hint for snapping. 26 + /// 27 + /// Use this when handling arrow keys to ensure cursor snaps in the expected direction. 28 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 29 + pub fn sync_cursor_from_dom_with_direction( 30 + doc: &mut EditorDocument, 31 editor_id: &str, 32 paragraphs: &[ParagraphRender], 33 + direction_hint: Option<SnapDirection>, 34 ) { 35 use wasm_bindgen::JsCast; 36 ··· 78 &anchor_node, 79 anchor_offset, 80 paragraphs, 81 + direction_hint, 82 ); 83 let focus_rope = dom_position_to_rope_offset( 84 &dom_document, ··· 86 &focus_node, 87 focus_offset, 88 paragraphs, 89 + direction_hint, 90 ); 91 92 + match (anchor_rope, focus_rope) { 93 + (Some(anchor), Some(focus)) => { 94 + doc.cursor.write().offset = focus; 95 + if anchor != focus { 96 + // There's an actual selection 97 + doc.selection.set(Some(Selection { 98 + anchor, 99 + head: focus, 100 + })); 101 + } else { 102 + // Collapsed selection (just cursor) 103 + doc.selection.set(None); 104 } 105 } 106 + _ => { 107 + tracing::warn!("Could not map DOM selection to rope offsets"); 108 + } 109 + } 110 } 111 112 /// Convert a DOM position (node + offset) to a rope char offset using offset maps. 113 + /// 114 + /// The `direction_hint` is used when snapping from invisible content to determine 115 + /// which direction to prefer. 116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 117 fn dom_position_to_rope_offset( 118 dom_document: &web_sys::Document, ··· 120 node: &web_sys::Node, 121 offset_in_text_node: usize, 122 paragraphs: &[ParagraphRender], 123 + direction_hint: Option<SnapDirection>, 124 ) -> Option<usize> { 125 use wasm_bindgen::JsCast; 126 ··· 191 && utf16_offset_in_container <= mapping_end 192 { 193 let offset_in_mapping = utf16_offset_in_container - mapping_start; 194 + let char_offset = mapping.char_range.start + offset_in_mapping; 195 + 196 + // Check if this position is valid (not on invisible content) 197 + if is_valid_cursor_position(&para.offset_map, char_offset) { 198 + return Some(char_offset); 199 + } 200 + 201 + // Position is on invisible content, snap to nearest valid 202 + if let Some(snapped) = 203 + find_nearest_valid_position(&para.offset_map, char_offset, direction_hint) 204 + { 205 + return Some(snapped.char_offset()); 206 + } 207 + 208 + // Fallback to original if no snap target 209 + return Some(char_offset); 210 } 211 } 212 } 213 } 214 215 + // No mapping found - try to find any valid position in paragraphs 216 + // This handles clicks on non-text elements like images 217 + for para in paragraphs { 218 + if let Some(snapped) = find_nearest_valid_position(&para.offset_map, para.char_range.start, direction_hint) { 219 + return Some(snapped.char_offset()); 220 + } 221 + } 222 + 223 None 224 } 225 226 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 227 pub fn sync_cursor_from_dom( 228 + _document: &mut EditorDocument, 229 + _editor_id: &str, 230 + _paragraphs: &[ParagraphRender], 231 + ) { 232 + // No-op on non-wasm 233 + } 234 + 235 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 236 + pub fn sync_cursor_from_dom_with_direction( 237 + _document: &mut EditorDocument, 238 _editor_id: &str, 239 _paragraphs: &[ParagraphRender], 240 + _direction_hint: Option<SnapDirection>, 241 ) { 242 // No-op on non-wasm 243 } ··· 319 } 320 321 // Remove extra paragraphs if document got shorter 322 + // Also mark cursor as needing restoration since structure changed 323 + if new_paragraphs.len() < old_paragraphs.len() { 324 + cursor_para_updated = true; 325 + } 326 for idx in new_paragraphs.len()..old_paragraphs.len() { 327 let para_id = format!("para-{}", idx); 328 if let Some(elem) = document.get_element_by_id(&para_id) {
+37 -36
crates/weaver-app/src/components/editor/formatting.rs
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 - use super::input::{ListContext, detect_list_context, find_line_end}; 4 - 5 use super::document::EditorDocument; 6 7 /// Formatting actions available in the editor. 8 #[derive(Clone, Debug, PartialEq)] ··· 59 /// 60 /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 61 pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { 62 - let (start, end) = if let Some(sel) = doc.selection { 63 // Use selection 64 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 65 } else { 66 // Expand to word 67 - find_word_boundaries(doc.loro_text(), doc.cursor.offset) 68 }; 69 70 match action { ··· 72 // Insert end marker first so start position stays valid 73 let _ = doc.insert_tracked(end, "**"); 74 let _ = doc.insert_tracked(start, "**"); 75 - doc.cursor.offset = end + 4; 76 - doc.selection = None; 77 } 78 FormatAction::Italic => { 79 let _ = doc.insert_tracked(end, "*"); 80 let _ = doc.insert_tracked(start, "*"); 81 - doc.cursor.offset = end + 2; 82 - doc.selection = None; 83 } 84 FormatAction::Strikethrough => { 85 let _ = doc.insert_tracked(end, "~~"); 86 let _ = doc.insert_tracked(start, "~~"); 87 - doc.cursor.offset = end + 4; 88 - doc.selection = None; 89 } 90 FormatAction::Code => { 91 let _ = doc.insert_tracked(end, "`"); 92 let _ = doc.insert_tracked(start, "`"); 93 - doc.cursor.offset = end + 2; 94 - doc.selection = None; 95 } 96 FormatAction::Link => { 97 // Insert [selected text](url) 98 let _ = doc.insert_tracked(end, "](url)"); 99 let _ = doc.insert_tracked(start, "["); 100 - doc.cursor.offset = end + 8; // Position cursor after ](url) 101 - doc.selection = None; 102 } 103 FormatAction::Image => { 104 // Insert ![alt text](url) 105 let _ = doc.insert_tracked(end, "](url)"); 106 let _ = doc.insert_tracked(start, "!["); 107 - doc.cursor.offset = end + 9; 108 - doc.selection = None; 109 } 110 FormatAction::Heading(level) => { 111 // Find start of current line 112 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 113 let prefix = "#".repeat(level as usize) + " "; 114 let _ = doc.insert_tracked(line_start, &prefix); 115 - doc.cursor.offset += prefix.len(); 116 - doc.selection = None; 117 } 118 FormatAction::BulletList => { 119 - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 120 let continuation = match ctx { 121 ListContext::Unordered { indent, marker } => { 122 format!("\n{}{} ", indent, marker) ··· 126 } 127 }; 128 let len = continuation.chars().count(); 129 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 130 - doc.cursor.offset += len; 131 - doc.selection = None; 132 } else { 133 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 134 let _ = doc.insert_tracked(line_start, " - "); 135 - doc.cursor.offset += 3; 136 - doc.selection = None; 137 } 138 } 139 FormatAction::NumberedList => { 140 - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 141 let continuation = match ctx { 142 ListContext::Unordered { .. } => { 143 format!("\n\n1. ") ··· 147 } 148 }; 149 let len = continuation.chars().count(); 150 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 151 - doc.cursor.offset += len; 152 - doc.selection = None; 153 } else { 154 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 155 let _ = doc.insert_tracked(line_start, "1. "); 156 - doc.cursor.offset += 3; 157 - doc.selection = None; 158 } 159 } 160 FormatAction::Quote => { 161 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 162 let _ = doc.insert_tracked(line_start, "> "); 163 - doc.cursor.offset += 2; 164 - doc.selection = None; 165 } 166 } 167 }
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 use super::document::EditorDocument; 4 + use super::input::{ListContext, detect_list_context, find_line_end}; 5 + use dioxus::prelude::*; 6 7 /// Formatting actions available in the editor. 8 #[derive(Clone, Debug, PartialEq)] ··· 59 /// 60 /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 61 pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { 62 + let cursor_offset = doc.cursor.read().offset; 63 + let (start, end) = if let Some(sel) = *doc.selection.read() { 64 // Use selection 65 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 66 } else { 67 // Expand to word 68 + find_word_boundaries(doc.loro_text(), cursor_offset) 69 }; 70 71 match action { ··· 73 // Insert end marker first so start position stays valid 74 let _ = doc.insert_tracked(end, "**"); 75 let _ = doc.insert_tracked(start, "**"); 76 + doc.cursor.write().offset = end + 4; 77 + doc.selection.set(None); 78 } 79 FormatAction::Italic => { 80 let _ = doc.insert_tracked(end, "*"); 81 let _ = doc.insert_tracked(start, "*"); 82 + doc.cursor.write().offset = end + 2; 83 + doc.selection.set(None); 84 } 85 FormatAction::Strikethrough => { 86 let _ = doc.insert_tracked(end, "~~"); 87 let _ = doc.insert_tracked(start, "~~"); 88 + doc.cursor.write().offset = end + 4; 89 + doc.selection.set(None); 90 } 91 FormatAction::Code => { 92 let _ = doc.insert_tracked(end, "`"); 93 let _ = doc.insert_tracked(start, "`"); 94 + doc.cursor.write().offset = end + 2; 95 + doc.selection.set(None); 96 } 97 FormatAction::Link => { 98 // Insert [selected text](url) 99 let _ = doc.insert_tracked(end, "](url)"); 100 let _ = doc.insert_tracked(start, "["); 101 + doc.cursor.write().offset = end + 8; // Position cursor after ](url) 102 + doc.selection.set(None); 103 } 104 FormatAction::Image => { 105 // Insert ![alt text](url) 106 let _ = doc.insert_tracked(end, "](url)"); 107 let _ = doc.insert_tracked(start, "!["); 108 + doc.cursor.write().offset = end + 9; 109 + doc.selection.set(None); 110 } 111 FormatAction::Heading(level) => { 112 // Find start of current line 113 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 114 let prefix = "#".repeat(level as usize) + " "; 115 let _ = doc.insert_tracked(line_start, &prefix); 116 + doc.cursor.write().offset = cursor_offset + prefix.len(); 117 + doc.selection.set(None); 118 } 119 FormatAction::BulletList => { 120 + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 121 let continuation = match ctx { 122 ListContext::Unordered { indent, marker } => { 123 format!("\n{}{} ", indent, marker) ··· 127 } 128 }; 129 let len = continuation.chars().count(); 130 + let _ = doc.insert_tracked(cursor_offset, &continuation); 131 + doc.cursor.write().offset = cursor_offset + len; 132 + doc.selection.set(None); 133 } else { 134 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 135 let _ = doc.insert_tracked(line_start, " - "); 136 + doc.cursor.write().offset = cursor_offset + 3; 137 + doc.selection.set(None); 138 } 139 } 140 FormatAction::NumberedList => { 141 + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 142 let continuation = match ctx { 143 ListContext::Unordered { .. } => { 144 format!("\n\n1. ") ··· 148 } 149 }; 150 let len = continuation.chars().count(); 151 + let _ = doc.insert_tracked(cursor_offset, &continuation); 152 + doc.cursor.write().offset = cursor_offset + len; 153 + doc.selection.set(None); 154 } else { 155 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 156 let _ = doc.insert_tracked(line_start, "1. "); 157 + doc.cursor.write().offset = cursor_offset + 3; 158 + doc.selection.set(None); 159 } 160 } 161 FormatAction::Quote => { 162 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 163 let _ = doc.insert_tracked(line_start, "> "); 164 + doc.cursor.write().offset = cursor_offset + 2; 165 + doc.selection.set(None); 166 } 167 } 168 }
+243 -218
crates/weaver-app/src/components/editor/input.rs
··· 6 7 use super::document::EditorDocument; 8 use super::formatting::{self, FormatAction}; 9 10 /// Check if we need to intercept this key event. 11 /// Returns true for content-modifying operations, false for navigation. ··· 37 } 38 39 /// Handle keyboard events and update document state. 40 - pub fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 41 use dioxus::prelude::keyboard_types::Key; 42 43 let key = evt.key(); 44 let mods = evt.modifiers(); 45 46 - document.with_mut(|doc| { 47 - match key { 48 - Key::Character(ch) => { 49 - // Keyboard shortcuts first 50 - if mods.ctrl() { 51 - match ch.as_str() { 52 - "b" => { 53 - formatting::apply_formatting(doc, FormatAction::Bold); 54 - return; 55 - } 56 - "i" => { 57 - formatting::apply_formatting(doc, FormatAction::Italic); 58 - return; 59 - } 60 - "z" => { 61 - if mods.shift() { 62 - // Ctrl+Shift+Z = redo 63 - if let Ok(true) = doc.redo() { 64 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 65 - } 66 - } else { 67 - // Ctrl+Z = undo 68 - if let Ok(true) = doc.undo() { 69 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 70 - } 71 - } 72 - doc.selection = None; 73 - return; 74 - } 75 - "y" => { 76 - // Ctrl+Y = redo (alternative) 77 if let Ok(true) = doc.redo() { 78 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 79 } 80 - doc.selection = None; 81 - return; 82 } 83 - "e" => { 84 - // Ctrl+E = copy as HTML (export) 85 - if let Some(sel) = doc.selection { 86 - let (start, end) = 87 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 88 - if start != end { 89 - if let Some(markdown) = doc.slice(start, end) { 90 - let clean_md = markdown 91 - .replace('\u{200C}', "") 92 - .replace('\u{200B}', ""); 93 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 94 - wasm_bindgen_futures::spawn_local(async move { 95 - if let Err(e) = copy_as_html(&clean_md).await { 96 - tracing::warn!("[COPY HTML] Failed: {:?}", e); 97 - } 98 - }); 99 - } 100 } 101 } 102 - return; 103 } 104 - _ => {} 105 } 106 } 107 108 - // Insert character at cursor (replacing selection if any) 109 - if let Some(sel) = doc.selection.take() { 110 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 111 - let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 112 - doc.cursor.offset = start + ch.chars().count(); 113 - } else { 114 - // Clean up any preceding zero-width chars (gap scaffolding) 115 - let mut delete_start = doc.cursor.offset; 116 - while delete_start > 0 { 117 - match get_char_at(doc.loro_text(), delete_start - 1) { 118 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 119 - _ => break, 120 - } 121 } 122 123 - let zw_count = doc.cursor.offset - delete_start; 124 - if zw_count > 0 { 125 - // Splice: delete zero-width chars and insert new char in one op 126 - let _ = doc.replace_tracked(delete_start, zw_count, &ch); 127 - doc.cursor.offset = delete_start + ch.chars().count(); 128 - } else if doc.cursor.offset == doc.len_chars() { 129 - // Fast path: append at end 130 - let _ = doc.push_tracked(&ch); 131 - doc.cursor.offset += ch.chars().count(); 132 - } else { 133 - let _ = doc.insert_tracked(doc.cursor.offset, &ch); 134 - doc.cursor.offset += ch.chars().count(); 135 - } 136 } 137 } 138 139 - Key::Backspace => { 140 - if let Some(sel) = doc.selection { 141 - // Delete selection 142 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 143 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 144 - doc.cursor.offset = start; 145 - doc.selection = None; 146 - } else if doc.cursor.offset > 0 { 147 - // Check if we're about to delete a newline 148 - let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); 149 150 - if prev_char == Some('\n') { 151 - let newline_pos = doc.cursor.offset - 1; 152 - let mut delete_start = newline_pos; 153 - let mut delete_end = doc.cursor.offset; 154 155 - // Check if there's another newline before this one (empty paragraph) 156 - // If so, delete both newlines to merge paragraphs 157 - if newline_pos > 0 { 158 - let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 159 - if prev_prev_char == Some('\n') { 160 - // Empty paragraph case: delete both newlines 161 - delete_start = newline_pos - 1; 162 - } 163 } 164 165 - // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 166 - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 167 - if ch == '\u{200C}' || ch == '\u{200B}' { 168 - delete_end += 1; 169 - } 170 } 171 172 - // Scan backwards through whitespace before the newline(s) 173 - while delete_start > 0 { 174 - let ch = get_char_at(doc.loro_text(), delete_start - 1); 175 - match ch { 176 - Some('\u{200C}') | Some('\u{200B}') => { 177 - delete_start -= 1; 178 - } 179 - Some('\n') => break, // stop at another newline 180 - _ => break, // stop at actual content 181 } 182 } 183 184 - // Delete from where we stopped to end (including any trailing zero-width) 185 - let _ = doc 186 - .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 187 - doc.cursor.offset = delete_start; 188 - } else { 189 - // Normal backspace - delete one char 190 - let prev = doc.cursor.offset - 1; 191 - let _ = doc.remove_tracked(prev, 1); 192 - doc.cursor.offset = prev; 193 - } 194 } 195 } 196 197 - Key::Delete => { 198 - if let Some(sel) = doc.selection.take() { 199 - // Delete selection 200 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 201 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 202 - doc.cursor.offset = start; 203 - } else if doc.cursor.offset < doc.len_chars() { 204 // Delete next char 205 - let _ = doc.remove_tracked(doc.cursor.offset, 1); 206 } 207 } 208 209 - // Arrow keys handled by browser, synced in onkeyup 210 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 211 - // Browser handles these naturally 212 - } 213 214 - Key::Enter => { 215 - if let Some(sel) = doc.selection.take() { 216 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 217 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 218 - doc.cursor.offset = start; 219 - } 220 221 - if mods.shift() { 222 - // Shift+Enter: hard line break (soft break) 223 - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 224 - doc.cursor.offset += 3; 225 - } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 226 - // We're in a list item 227 - if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { 228 - // Empty item - exit list by removing marker and inserting paragraph break 229 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 230 - let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); 231 232 - // Delete the empty list item line INCLUDING its trailing newline 233 - // line_end points to the newline, so +1 to include it 234 - let delete_end = (line_end + 1).min(doc.len_chars()); 235 236 - // Use replace_tracked to atomically delete line and insert paragraph break 237 - let _ = doc.replace_tracked( 238 - line_start, 239 - delete_end.saturating_sub(line_start), 240 - "\n\n\u{200C}\n", 241 - ); 242 - doc.cursor.offset = line_start + 2; 243 - } else { 244 - // Non-empty item - continue list 245 - let continuation = match ctx { 246 - ListContext::Unordered { indent, marker } => { 247 - format!("\n{}{} ", indent, marker) 248 - } 249 - ListContext::Ordered { indent, number } => { 250 - format!("\n{}{}. ", indent, number + 1) 251 - } 252 - }; 253 - let len = continuation.chars().count(); 254 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 255 - doc.cursor.offset += len; 256 - } 257 } else { 258 - // Not in a list - normal paragraph break 259 - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 260 - doc.cursor.offset += 2; 261 } 262 } 263 264 - // Home/End handled by browser, synced in onkeyup 265 - Key::Home | Key::End => { 266 - // Browser handles these naturally 267 - } 268 269 - _ => {} 270 - } 271 272 - // Sync Loro cursor when edits affect paragraph boundaries 273 - // This ensures cursor position is tracked correctly through structural changes 274 - if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { 275 - doc.sync_loro_cursor(); 276 - } 277 - }); 278 } 279 280 /// Handle paste events and insert text at cursor. 281 - pub fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 282 evt.prevent_default(); 283 284 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 297 .or_else(|| data_transfer.get_data("text/plain").ok()); 298 299 if let Some(text) = text { 300 - document.with_mut(|doc| { 301 - // Delete selection if present 302 - if let Some(sel) = doc.selection { 303 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 304 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 305 - doc.cursor.offset = start; 306 - doc.selection = None; 307 - } 308 309 - // Insert pasted text 310 - let _ = doc.insert_tracked(doc.cursor.offset, &text); 311 - doc.cursor.offset += text.chars().count(); 312 - }); 313 } 314 } 315 } else { ··· 319 } 320 321 /// Handle cut events - extract text, write to clipboard, then delete. 322 - pub fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 323 evt.prevent_default(); 324 325 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 329 330 let base_evt = evt.as_web_event(); 331 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 332 - let cut_text = document.with_mut(|doc| { 333 - if let Some(sel) = doc.selection { 334 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 335 if start != end { 336 // Extract text and strip zero-width chars ··· 348 349 // Now delete 350 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 351 - doc.cursor.offset = start; 352 - doc.selection = None; 353 354 - return Some(clean_text); 355 } 356 } 357 - None 358 - }); 359 360 // Async: also write custom MIME type for internal paste detection 361 if let Some(text) = cut_text { ··· 375 } 376 377 /// Handle copy events - extract text, clean it up, write to clipboard. 378 - pub fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 379 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 380 { 381 use dioxus::web::WebEventExt; ··· 383 384 let base_evt = evt.as_web_event(); 385 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 386 - let doc = document.read(); 387 - if let Some(sel) = doc.selection { 388 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 389 if start != end { 390 // Extract text ··· 419 420 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 421 { 422 - let _ = (evt, document); // suppress unused warnings 423 } 424 } 425
··· 6 7 use super::document::EditorDocument; 8 use super::formatting::{self, FormatAction}; 9 + use super::offset_map::SnapDirection; 10 11 /// Check if we need to intercept this key event. 12 /// Returns true for content-modifying operations, false for navigation. ··· 38 } 39 40 /// Handle keyboard events and update document state. 41 + pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 42 use dioxus::prelude::keyboard_types::Key; 43 44 let key = evt.key(); 45 let mods = evt.modifiers(); 46 47 + match key { 48 + Key::Character(ch) => { 49 + // Keyboard shortcuts first 50 + if mods.ctrl() { 51 + match ch.as_str() { 52 + "b" => { 53 + formatting::apply_formatting(doc, FormatAction::Bold); 54 + return; 55 + } 56 + "i" => { 57 + formatting::apply_formatting(doc, FormatAction::Italic); 58 + return; 59 + } 60 + "z" => { 61 + if mods.shift() { 62 + // Ctrl+Shift+Z = redo 63 if let Ok(true) = doc.redo() { 64 + let max = doc.len_chars(); 65 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 66 } 67 + } else { 68 + // Ctrl+Z = undo 69 + if let Ok(true) = doc.undo() { 70 + let max = doc.len_chars(); 71 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 72 + } 73 } 74 + doc.selection.set(None); 75 + return; 76 + } 77 + "y" => { 78 + // Ctrl+Y = redo (alternative) 79 + if let Ok(true) = doc.redo() { 80 + let max = doc.len_chars(); 81 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 82 + } 83 + doc.selection.set(None); 84 + return; 85 + } 86 + "e" => { 87 + // Ctrl+E = copy as HTML (export) 88 + if let Some(sel) = *doc.selection.read() { 89 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 90 + if start != end { 91 + if let Some(markdown) = doc.slice(start, end) { 92 + let clean_md = 93 + markdown.replace('\u{200C}', "").replace('\u{200B}', ""); 94 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 95 + wasm_bindgen_futures::spawn_local(async move { 96 + if let Err(e) = copy_as_html(&clean_md).await { 97 + tracing::warn!("[COPY HTML] Failed: {:?}", e); 98 + } 99 + }); 100 } 101 } 102 } 103 + return; 104 } 105 + _ => {} 106 } 107 + } 108 109 + // Insert character at cursor (replacing selection if any) 110 + let sel = doc.selection.write().take(); 111 + if let Some(sel) = sel { 112 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 113 + let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 114 + doc.cursor.write().offset = start + ch.chars().count(); 115 + } else { 116 + // Clean up any preceding zero-width chars (gap scaffolding) 117 + let cursor_offset = doc.cursor.read().offset; 118 + let mut delete_start = cursor_offset; 119 + while delete_start > 0 { 120 + match get_char_at(doc.loro_text(), delete_start - 1) { 121 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 122 + _ => break, 123 } 124 + } 125 126 + let zw_count = cursor_offset - delete_start; 127 + if zw_count > 0 { 128 + // Splice: delete zero-width chars and insert new char in one op 129 + let _ = doc.replace_tracked(delete_start, zw_count, &ch); 130 + doc.cursor.write().offset = delete_start + ch.chars().count(); 131 + } else if cursor_offset == doc.len_chars() { 132 + // Fast path: append at end 133 + let _ = doc.push_tracked(&ch); 134 + doc.cursor.write().offset = cursor_offset + ch.chars().count(); 135 + } else { 136 + let _ = doc.insert_tracked(cursor_offset, &ch); 137 + doc.cursor.write().offset = cursor_offset + ch.chars().count(); 138 } 139 } 140 + } 141 + 142 + Key::Backspace => { 143 + // Snap backward after backspace (toward deleted content) 144 + doc.pending_snap.set(Some(SnapDirection::Backward)); 145 146 + let sel = doc.selection.write().take(); 147 + if let Some(sel) = sel { 148 + // Delete selection 149 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 150 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 151 + doc.cursor.write().offset = start; 152 + } else if doc.cursor.read().offset > 0 { 153 + let cursor_offset = doc.cursor.read().offset; 154 + // Check if we're about to delete a newline 155 + let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); 156 157 + if prev_char == Some('\n') { 158 + let newline_pos = cursor_offset - 1; 159 + let mut delete_start = newline_pos; 160 + let mut delete_end = cursor_offset; 161 162 + // Check if there's another newline before this one (empty paragraph) 163 + // If so, delete both newlines to merge paragraphs 164 + if newline_pos > 0 { 165 + let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 166 + if prev_prev_char == Some('\n') { 167 + // Empty paragraph case: delete both newlines 168 + delete_start = newline_pos - 1; 169 } 170 + } 171 172 + // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 173 + if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 174 + if ch == '\u{200C}' || ch == '\u{200B}' { 175 + delete_end += 1; 176 } 177 + } 178 179 + // Scan backwards through whitespace before the newline(s) 180 + while delete_start > 0 { 181 + let ch = get_char_at(doc.loro_text(), delete_start - 1); 182 + match ch { 183 + Some('\u{200C}') | Some('\u{200B}') => { 184 + delete_start -= 1; 185 } 186 + Some('\n') => break, // stop at another newline 187 + _ => break, // stop at actual content 188 } 189 + } 190 191 + // Delete from where we stopped to end (including any trailing zero-width) 192 + let _ = 193 + doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 194 + doc.cursor.write().offset = delete_start; 195 + } else { 196 + // Normal backspace - delete one char 197 + let prev = cursor_offset - 1; 198 + let _ = doc.remove_tracked(prev, 1); 199 + doc.cursor.write().offset = prev; 200 } 201 } 202 + } 203 204 + Key::Delete => { 205 + // Snap forward after delete (toward remaining content) 206 + doc.pending_snap.set(Some(SnapDirection::Forward)); 207 + 208 + let sel = doc.selection.write().take(); 209 + if let Some(sel) = sel { 210 + // Delete selection 211 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 212 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 213 + doc.cursor.write().offset = start; 214 + } else { 215 + let cursor_offset = doc.cursor.read().offset; 216 + if cursor_offset < doc.len_chars() { 217 // Delete next char 218 + let _ = doc.remove_tracked(cursor_offset, 1); 219 } 220 } 221 + } 222 223 + // Arrow keys handled by browser, synced in onkeyup 224 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 225 + // Browser handles these naturally 226 + } 227 228 + Key::Enter => { 229 + // Snap forward after enter (into new paragraph/line) 230 + doc.pending_snap.set(Some(SnapDirection::Forward)); 231 232 + let sel = doc.selection.write().take(); 233 + if let Some(sel) = sel { 234 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 235 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 236 + doc.cursor.write().offset = start; 237 + } 238 239 + let cursor_offset = doc.cursor.read().offset; 240 + if mods.shift() { 241 + // Shift+Enter: hard line break (soft break) 242 + let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); 243 + doc.cursor.write().offset = cursor_offset + 3; 244 + } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 245 + // We're in a list item 246 + if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { 247 + // Empty item - exit list by removing marker and inserting paragraph break 248 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 249 + let line_end = find_line_end(doc.loro_text(), cursor_offset); 250 251 + // Delete the empty list item line INCLUDING its trailing newline 252 + // line_end points to the newline, so +1 to include it 253 + let delete_end = (line_end + 1).min(doc.len_chars()); 254 + 255 + // Use replace_tracked to atomically delete line and insert paragraph break 256 + let _ = doc.replace_tracked( 257 + line_start, 258 + delete_end.saturating_sub(line_start), 259 + "\n\n\u{200C}\n", 260 + ); 261 + doc.cursor.write().offset = line_start + 2; 262 } else { 263 + // Non-empty item - continue list 264 + let continuation = match ctx { 265 + ListContext::Unordered { indent, marker } => { 266 + format!("\n{}{} ", indent, marker) 267 + } 268 + ListContext::Ordered { indent, number } => { 269 + format!("\n{}{}. ", indent, number + 1) 270 + } 271 + }; 272 + let len = continuation.chars().count(); 273 + let _ = doc.insert_tracked(cursor_offset, &continuation); 274 + doc.cursor.write().offset = cursor_offset + len; 275 } 276 + } else { 277 + // Not in a list - normal paragraph break 278 + let _ = doc.insert_tracked(cursor_offset, "\n\n"); 279 + doc.cursor.write().offset = cursor_offset + 2; 280 } 281 + } 282 283 + // Home/End handled by browser, synced in onkeyup 284 + Key::Home | Key::End => { 285 + // Browser handles these naturally 286 + } 287 288 + _ => {} 289 + } 290 291 + // Sync Loro cursor when edits affect paragraph boundaries 292 + // This ensures cursor position is tracked correctly through structural changes 293 + if doc 294 + .last_edit 295 + .read() 296 + .as_ref() 297 + .is_some_and(|e| e.contains_newline) 298 + { 299 + doc.sync_loro_cursor(); 300 + } 301 } 302 303 /// Handle paste events and insert text at cursor. 304 + pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 305 evt.prevent_default(); 306 307 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 320 .or_else(|| data_transfer.get_data("text/plain").ok()); 321 322 if let Some(text) = text { 323 + // Delete selection if present 324 + let sel = doc.selection.write().take(); 325 + if let Some(sel) = sel { 326 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 327 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 328 + doc.cursor.write().offset = start; 329 + } 330 331 + // Insert pasted text 332 + let cursor_offset = doc.cursor.read().offset; 333 + let _ = doc.insert_tracked(cursor_offset, &text); 334 + doc.cursor.write().offset = cursor_offset + text.chars().count(); 335 } 336 } 337 } else { ··· 341 } 342 343 /// Handle cut events - extract text, write to clipboard, then delete. 344 + pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 345 evt.prevent_default(); 346 347 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 351 352 let base_evt = evt.as_web_event(); 353 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 354 + let cut_text = { 355 + let sel = doc.selection.write().take(); 356 + if let Some(sel) = sel { 357 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 358 if start != end { 359 // Extract text and strip zero-width chars ··· 371 372 // Now delete 373 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 374 + doc.cursor.write().offset = start; 375 376 + Some(clean_text) 377 + } else { 378 + None 379 } 380 + } else { 381 + None 382 } 383 + }; 384 385 // Async: also write custom MIME type for internal paste detection 386 if let Some(text) = cut_text { ··· 400 } 401 402 /// Handle copy events - extract text, clean it up, write to clipboard. 403 + pub fn handle_copy(evt: Event<ClipboardData>, doc: &EditorDocument) { 404 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 405 { 406 use dioxus::web::WebEventExt; ··· 408 409 let base_evt = evt.as_web_event(); 410 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 411 + let sel = *doc.selection.read(); 412 + if let Some(sel) = sel { 413 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 414 if start != end { 415 // Extract text ··· 444 445 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 446 { 447 + let _ = (evt, doc); // suppress unused warnings 448 } 449 } 450
+246
crates/weaver-app/src/components/editor/offset_map.rs
··· 153 Some((mapping, should_snap)) 154 } 155 156 #[cfg(test)] 157 mod tests { 158 use super::*; ··· 281 assert!(mapping.contains_char(12)); 282 assert!(mapping.contains_char(14)); 283 assert!(!mapping.contains_char(15)); 284 } 285 }
··· 153 Some((mapping, should_snap)) 154 } 155 156 + /// Direction hint for cursor snapping. 157 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 158 + pub enum SnapDirection { 159 + Backward, 160 + Forward, 161 + } 162 + 163 + /// Result of finding a valid cursor position. 164 + #[derive(Debug, Clone)] 165 + pub struct SnappedPosition<'a> { 166 + pub mapping: &'a OffsetMapping, 167 + pub offset_in_mapping: usize, 168 + pub snapped: Option<SnapDirection>, 169 + } 170 + 171 + impl SnappedPosition<'_> { 172 + /// Get the absolute char offset for this position. 173 + pub fn char_offset(&self) -> usize { 174 + self.mapping.char_range.start + self.offset_in_mapping 175 + } 176 + } 177 + 178 + /// Find the nearest valid cursor position to a char offset. 179 + /// 180 + /// A valid position is one that maps to visible content (utf16_len > 0). 181 + /// If the position is already valid, returns it directly. Otherwise, 182 + /// searches in the preferred direction first, falling back to the other 183 + /// direction if needed. 184 + /// 185 + /// # Arguments 186 + /// - `offset_map`: The offset mappings for the paragraph 187 + /// - `char_offset`: The target char offset 188 + /// - `preferred_direction`: Which direction to search first when snapping 189 + /// 190 + /// # Returns 191 + /// The snapped position, or None if no valid position exists. 192 + pub fn find_nearest_valid_position( 193 + offset_map: &[OffsetMapping], 194 + char_offset: usize, 195 + preferred_direction: Option<SnapDirection>, 196 + ) -> Option<SnappedPosition<'_>> { 197 + if offset_map.is_empty() { 198 + return None; 199 + } 200 + 201 + // Try exact match first 202 + if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { 203 + if !should_snap { 204 + // Position is valid, return it directly 205 + let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); 206 + return Some(SnappedPosition { 207 + mapping, 208 + offset_in_mapping, 209 + snapped: None, 210 + }); 211 + } 212 + } 213 + 214 + // Position is invalid or not found - search for nearest valid 215 + let search_order = match preferred_direction { 216 + Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], 217 + Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], 218 + }; 219 + 220 + for direction in search_order { 221 + if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { 222 + return Some(pos); 223 + } 224 + } 225 + 226 + None 227 + } 228 + 229 + /// Search for a valid position in a specific direction. 230 + fn find_valid_in_direction( 231 + offset_map: &[OffsetMapping], 232 + char_offset: usize, 233 + direction: SnapDirection, 234 + ) -> Option<SnappedPosition<'_>> { 235 + match direction { 236 + SnapDirection::Forward => { 237 + // Find first visible mapping at or after char_offset 238 + for mapping in offset_map { 239 + if mapping.char_range.start >= char_offset && !mapping.is_invisible() { 240 + return Some(SnappedPosition { 241 + mapping, 242 + offset_in_mapping: 0, 243 + snapped: Some(SnapDirection::Forward), 244 + }); 245 + } 246 + // Also check if char_offset falls within this visible mapping 247 + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 248 + let offset_in_mapping = char_offset - mapping.char_range.start; 249 + return Some(SnappedPosition { 250 + mapping, 251 + offset_in_mapping, 252 + snapped: Some(SnapDirection::Forward), 253 + }); 254 + } 255 + } 256 + None 257 + } 258 + SnapDirection::Backward => { 259 + // Find last visible mapping at or before char_offset 260 + for mapping in offset_map.iter().rev() { 261 + if mapping.char_range.end <= char_offset && !mapping.is_invisible() { 262 + // Snap to end of this mapping 263 + let offset_in_mapping = mapping.char_range.len(); 264 + return Some(SnappedPosition { 265 + mapping, 266 + offset_in_mapping, 267 + snapped: Some(SnapDirection::Backward), 268 + }); 269 + } 270 + // Also check if char_offset falls within this visible mapping 271 + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 272 + let offset_in_mapping = char_offset - mapping.char_range.start; 273 + return Some(SnappedPosition { 274 + mapping, 275 + offset_in_mapping, 276 + snapped: Some(SnapDirection::Backward), 277 + }); 278 + } 279 + } 280 + None 281 + } 282 + } 283 + } 284 + 285 + /// Check if a char offset is at a valid (non-invisible) cursor position. 286 + pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 287 + find_mapping_for_char(offset_map, char_offset) 288 + .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) 289 + .unwrap_or(false) 290 + } 291 + 292 #[cfg(test)] 293 mod tests { 294 use super::*; ··· 417 assert!(mapping.contains_char(12)); 418 assert!(mapping.contains_char(14)); 419 assert!(!mapping.contains_char(15)); 420 + } 421 + 422 + fn make_test_mappings() -> Vec<OffsetMapping> { 423 + vec![ 424 + OffsetMapping { 425 + byte_range: 0..2, 426 + char_range: 0..2, 427 + node_id: "n0".to_string(), 428 + char_offset_in_node: 0, 429 + child_index: None, 430 + utf16_len: 0, // invisible: "![" 431 + }, 432 + OffsetMapping { 433 + byte_range: 2..5, 434 + char_range: 2..5, 435 + node_id: "n0".to_string(), 436 + char_offset_in_node: 0, 437 + child_index: None, 438 + utf16_len: 3, // visible: "alt" 439 + }, 440 + OffsetMapping { 441 + byte_range: 5..15, 442 + char_range: 5..15, 443 + node_id: "n0".to_string(), 444 + char_offset_in_node: 3, 445 + child_index: None, 446 + utf16_len: 0, // invisible: "](url.png)" 447 + }, 448 + OffsetMapping { 449 + byte_range: 15..20, 450 + char_range: 15..20, 451 + node_id: "n0".to_string(), 452 + char_offset_in_node: 3, 453 + child_index: None, 454 + utf16_len: 5, // visible: " text" 455 + }, 456 + ] 457 + } 458 + 459 + #[test] 460 + fn test_find_nearest_valid_position_exact_match() { 461 + let mappings = make_test_mappings(); 462 + 463 + // Position 3 is in visible mapping (2..5) 464 + let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); 465 + assert_eq!(pos.char_offset(), 3); 466 + assert!(pos.snapped.is_none()); 467 + } 468 + 469 + #[test] 470 + fn test_find_nearest_valid_position_snap_forward() { 471 + let mappings = make_test_mappings(); 472 + 473 + // Position 0 is invisible, should snap forward to 2 474 + let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); 475 + assert_eq!(pos.char_offset(), 2); 476 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 477 + } 478 + 479 + #[test] 480 + fn test_find_nearest_valid_position_snap_backward() { 481 + let mappings = make_test_mappings(); 482 + 483 + // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) 484 + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 485 + assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 486 + assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 487 + } 488 + 489 + #[test] 490 + fn test_find_nearest_valid_position_default_forward() { 491 + let mappings = make_test_mappings(); 492 + 493 + // Position 0 is invisible, None direction defaults to forward 494 + let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); 495 + assert_eq!(pos.char_offset(), 2); 496 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 497 + } 498 + 499 + #[test] 500 + fn test_find_nearest_valid_position_snap_forward_from_invisible() { 501 + let mappings = make_test_mappings(); 502 + 503 + // Position 10 is in invisible range (5..15), forward finds visible (15..20) 504 + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); 505 + assert_eq!(pos.char_offset(), 15); 506 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 507 + } 508 + 509 + #[test] 510 + fn test_is_valid_cursor_position() { 511 + let mappings = make_test_mappings(); 512 + 513 + // Invisible positions 514 + assert!(!is_valid_cursor_position(&mappings, 0)); 515 + assert!(!is_valid_cursor_position(&mappings, 1)); 516 + assert!(!is_valid_cursor_position(&mappings, 10)); 517 + 518 + // Visible positions 519 + assert!(is_valid_cursor_position(&mappings, 2)); 520 + assert!(is_valid_cursor_position(&mappings, 3)); 521 + assert!(is_valid_cursor_position(&mappings, 4)); 522 + assert!(is_valid_cursor_position(&mappings, 15)); 523 + assert!(is_valid_cursor_position(&mappings, 17)); 524 + } 525 + 526 + #[test] 527 + fn test_find_nearest_valid_position_empty() { 528 + let mappings: Vec<OffsetMapping> = vec![]; 529 + assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); 530 } 531 }
+11 -15
crates/weaver-app/src/components/editor/publish.rs
··· 213 /// Props for the publish button component. 214 #[derive(Props, Clone, PartialEq)] 215 pub struct PublishButtonProps { 216 - /// The editor document signal 217 - pub document: Signal<EditorDocument>, 218 /// Storage key for the draft 219 pub draft_key: String, 220 } ··· 233 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 234 235 let is_authenticated = auth_state.read().is_authenticated(); 236 - let doc = props.document; 237 let draft_key = props.draft_key.clone(); 238 239 // Check if we're editing an existing entry 240 - let is_editing_existing = doc().entry_uri().is_some(); 241 242 // Validate that we have required fields 243 - let can_publish = { 244 - let d = doc(); 245 - !d.title().trim().is_empty() && !d.content().trim().is_empty() 246 - }; 247 248 let open_dialog = move |_| { 249 error_message.set(None); ··· 256 }; 257 258 let draft_key_clone = draft_key.clone(); 259 let do_publish = move |_| { 260 let fetcher = fetcher.clone(); 261 let draft_key = draft_key_clone.clone(); 262 let notebook = if use_notebook() { 263 Some(notebook_title()) 264 } else { ··· 268 spawn(async move { 269 is_publishing.set(true); 270 error_message.set(None); 271 - 272 - // Get document snapshot for publishing 273 - let doc_snapshot = doc(); 274 275 match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { 276 Ok(result) => { ··· 358 } 359 360 div { class: "publish-preview", 361 - p { "Title: {doc().title()}" } 362 - p { "Path: {doc().path()}" } 363 - if !doc().tags().is_empty() { 364 - p { "Tags: {doc().tags().join(\", \")}" } 365 } 366 } 367
··· 213 /// Props for the publish button component. 214 #[derive(Props, Clone, PartialEq)] 215 pub struct PublishButtonProps { 216 + /// The editor document 217 + pub document: EditorDocument, 218 /// Storage key for the draft 219 pub draft_key: String, 220 } ··· 233 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 234 235 let is_authenticated = auth_state.read().is_authenticated(); 236 + let doc = props.document.clone(); 237 let draft_key = props.draft_key.clone(); 238 239 // Check if we're editing an existing entry 240 + let is_editing_existing = doc.entry_uri().is_some(); 241 242 // Validate that we have required fields 243 + let can_publish = !doc.title().trim().is_empty() && !doc.content().trim().is_empty(); 244 245 let open_dialog = move |_| { 246 error_message.set(None); ··· 253 }; 254 255 let draft_key_clone = draft_key.clone(); 256 + let doc_for_publish = doc.clone(); 257 let do_publish = move |_| { 258 let fetcher = fetcher.clone(); 259 let draft_key = draft_key_clone.clone(); 260 + let doc_snapshot = doc_for_publish.clone(); 261 let notebook = if use_notebook() { 262 Some(notebook_title()) 263 } else { ··· 267 spawn(async move { 268 is_publishing.set(true); 269 error_message.set(None); 270 271 match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { 272 Ok(result) => { ··· 354 } 355 356 div { class: "publish-preview", 357 + p { "Title: {doc.title()}" } 358 + p { "Path: {doc.path()}" } 359 + if !doc.tags().is_empty() { 360 + p { "Tags: {doc.tags().join(\", \")}" } 361 } 362 } 363
+3 -11
crates/weaver-app/src/components/editor/render.rs
··· 85 86 let mut adjusted_syntax = cached.syntax_spans.clone(); 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 { ··· 284 285 let mut adjusted_syntax = cached.syntax_spans.clone(); 286 for span in &mut adjusted_syntax { 287 - span.char_range.start = (span.char_range.start as isize + char_delta) as usize; 288 - span.char_range.end = (span.char_range.end as isize + char_delta) as usize; 289 } 290 291 (cached.html.clone(), adjusted_map, adjusted_syntax) ··· 356 mapping.char_range.end += para_char_start; 357 } 358 for span in &mut syntax_spans { 359 - span.char_range.start += para_char_start; 360 - span.char_range.end += para_char_start; 361 } 362 363 (output, offset_map, syntax_spans)
··· 85 86 let mut adjusted_syntax = cached.syntax_spans.clone(); 87 for span in &mut adjusted_syntax { 88 + span.adjust_positions(char_delta); 89 } 90 91 ParagraphRender { ··· 278 279 let mut adjusted_syntax = cached.syntax_spans.clone(); 280 for span in &mut adjusted_syntax { 281 + span.adjust_positions(char_delta); 282 } 283 284 (cached.html.clone(), adjusted_map, adjusted_syntax) ··· 349 mapping.char_range.end += para_char_start; 350 } 351 for span in &mut syntax_spans { 352 + span.adjust_positions(para_char_start as isize); 353 } 354 355 (output, offset_map, syntax_spans)
+3 -2
crates/weaver-app/src/components/editor/storage.rs
··· 9 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use gloo_storage::{LocalStorage, Storage}; 14 use jacquard::IntoStatic; ··· 86 title: doc.title(), 87 snapshot: snapshot_b64, 88 cursor: doc.loro_cursor().cloned(), 89 - cursor_offset: doc.cursor.offset, 90 editing_uri: doc.entry_uri().map(|u| u.to_string()), 91 }; 92 LocalStorage::set(storage_key(key), &snapshot) ··· 129 130 // Fallback: create new doc from text content 131 let mut doc = EditorDocument::new(snapshot.content); 132 - doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); 133 doc.sync_loro_cursor(); 134 doc.set_entry_uri(entry_uri); 135 Some(doc)
··· 9 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 12 + use dioxus::prelude::*; 13 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 14 use gloo_storage::{LocalStorage, Storage}; 15 use jacquard::IntoStatic; ··· 87 title: doc.title(), 88 snapshot: snapshot_b64, 89 cursor: doc.loro_cursor().cloned(), 90 + cursor_offset: doc.cursor.read().offset, 91 editing_uri: doc.entry_uri().map(|u| u.to_string()), 92 }; 93 LocalStorage::set(storage_key(key), &snapshot) ··· 130 131 // Fallback: create new doc from text content 132 let mut doc = EditorDocument::new(snapshot.content); 133 + doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); 134 doc.sync_loro_cursor(); 135 doc.set_entry_uri(entry_uri); 136 Some(doc)
+26 -10
crates/weaver-app/src/components/editor/visibility.rs
··· 198 syntax_spans: &[SyntaxSpanInfo], 199 paragraphs: &[ParagraphRender], 200 ) { 201 let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 202 203 let Some(window) = web_sys::window() else { ··· 207 return; 208 }; 209 210 - // Update each syntax span's visibility 211 - for span in syntax_spans { 212 - let selector = format!("[data-syn-id='{}']", span.syn_id); 213 - if let Ok(Some(element)) = document.query_selector(&selector) { 214 - let class_list = element.class_list(); 215 - if visibility.is_visible(&span.syn_id) { 216 - let _ = class_list.remove_1("hidden"); 217 - } else { 218 - let _ = class_list.add_1("hidden"); 219 - } 220 } 221 } 222 }
··· 198 syntax_spans: &[SyntaxSpanInfo], 199 paragraphs: &[ParagraphRender], 200 ) { 201 + use wasm_bindgen::JsCast; 202 + 203 let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 204 205 let Some(window) = web_sys::window() else { ··· 209 return; 210 }; 211 212 + // Single querySelectorAll instead of N individual queries 213 + let Ok(node_list) = document.query_selector_all("[data-syn-id]") else { 214 + return; 215 + }; 216 + 217 + for i in 0..node_list.length() { 218 + let Some(node) = node_list.item(i) else { 219 + continue; 220 + }; 221 + 222 + // Cast to Element to access attributes and class_list 223 + let Some(element) = node.dyn_ref::<web_sys::Element>() else { 224 + continue; 225 + }; 226 + 227 + let Some(syn_id) = element.get_attribute("data-syn-id") else { 228 + continue; 229 + }; 230 + 231 + let class_list = element.class_list(); 232 + if visibility.is_visible(&syn_id) { 233 + let _ = class_list.remove_1("hidden"); 234 + } else { 235 + let _ = class_list.add_1("hidden"); 236 } 237 } 238 }
+309 -67
crates/weaver-app/src/components/editor/writer.rs
··· 56 pub formatted_range: Option<Range<usize>>, 57 } 58 59 /// Classify syntax text as inline or block level 60 fn classify_syntax(text: &str) -> SyntaxType { 61 let trimmed = text.trim_start(); ··· 838 // the closing syntax must be emitted AFTER the closing HTML tag, not before. 839 // Otherwise the closing `**` span ends up INSIDE the <strong> element. 840 // These tags handle their own closing syntax in end_tag(). 841 use markdown_weaver::TagEnd; 842 - let is_inline_format_end = matches!( 843 &event, 844 - Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) 845 ); 846 847 - if matches!(&event, Event::End(_)) && !is_inline_format_end { 848 // Emit gap from last_byte_offset to range.end 849 self.emit_gap_before(range.end)?; 850 } else if !matches!(&event, Event::End(_)) { ··· 976 Ok(()) 977 } 978 979 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> { 980 use Event::*; 981 ··· 1026 } 1027 } 1028 Code(text) => { 1029 - let char_start = self.last_char_offset; 1030 let raw_text = &self.source[range.clone()]; 1031 1032 - // Emit opening backtick and track it 1033 - if raw_text.starts_with('`') { 1034 let syn_id = self.gen_syn_id(); 1035 let backtick_char_end = char_start + 1; 1036 write!( 1037 &mut self.writer, ··· 1042 syn_id, 1043 char_range: char_start..backtick_char_end, 1044 syntax_type: SyntaxType::Inline, 1045 - formatted_range: None, 1046 }); 1047 self.last_char_offset += 1; 1048 - } 1049 1050 self.write("<code>")?; 1051 ··· 1070 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 1071 syn_id, backtick_char_start, backtick_char_end 1072 )?; 1073 self.syntax_spans.push(SyntaxSpanInfo { 1074 syn_id, 1075 char_range: backtick_char_start..backtick_char_end, 1076 syntax_type: SyntaxType::Inline, 1077 - formatted_range: None, 1078 }); 1079 self.last_char_offset += 1; 1080 } 1081 } 1082 InlineMath(text) => { 1083 let raw_text = &self.source[range.clone()]; 1084 1085 - // Emit opening $ and track it 1086 - if raw_text.starts_with('$') { 1087 let syn_id = self.gen_syn_id(); 1088 let char_start = self.last_char_offset; 1089 let char_end = char_start + 1; ··· 1096 syn_id, 1097 char_range: char_start..char_end, 1098 syntax_type: SyntaxType::Inline, 1099 - formatted_range: None, 1100 }); 1101 self.last_char_offset += 1; 1102 - } 1103 1104 self.write(r#"<span class="math math-inline">"#)?; 1105 let text_char_len = text.chars().count(); ··· 1117 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 1118 syn_id, char_start, char_end 1119 )?; 1120 self.syntax_spans.push(SyntaxSpanInfo { 1121 syn_id, 1122 char_range: char_start..char_end, 1123 syntax_type: SyntaxType::Inline, 1124 - formatted_range: None, 1125 }); 1126 self.last_char_offset += 1; 1127 } 1128 } ··· 1377 None 1378 } 1379 } 1380 - Tag::Link { .. } => { 1381 - if raw_text.starts_with('[') { 1382 Some("[") 1383 } else { 1384 None 1385 } 1386 } 1387 _ => None, 1388 }; 1389 ··· 1425 let byte_end = range.start + syntax_byte_len; 1426 self.record_mapping(byte_start..byte_end, char_start..char_end); 1427 1428 - // For paired inline syntax (Strong, Emphasis, Strikethrough), 1429 - // track the opening span so we can set formatted_range when closing 1430 - if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) { 1431 self.pending_inline_formats.push((syn_id, char_start)); 1432 } 1433 ··· 1911 attrs, 1912 .. 1913 } => { 1914 - // Emit opening ![ 1915 - if range.start < range.end { 1916 - let raw_text = &self.source[range.clone()]; 1917 - if raw_text.starts_with("![") { 1918 - let syn_id = self.gen_syn_id(); 1919 - let char_start = self.last_char_offset; 1920 - let char_end = char_start + 2; 1921 1922 - write!( 1923 - &mut self.writer, 1924 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![</span>", 1925 - syn_id, char_start, char_end 1926 - )?; 1927 1928 - self.syntax_spans.push(SyntaxSpanInfo { 1929 - syn_id, 1930 - char_range: char_start..char_end, 1931 - syntax_type: SyntaxType::Inline, 1932 - formatted_range: None, 1933 - }); 1934 - } 1935 } 1936 1937 self.write("<img src=\"")?; 1938 - // Try to resolve image URL via resolver, fall back to original 1939 let resolved_url = self 1940 .image_resolver 1941 .as_ref() ··· 1946 escape_href(&mut self.writer, &dest_url)?; 1947 } 1948 self.write("\" alt=\"")?; 1949 - // Consume text events for alt attribute 1950 - self.raw_text()?; 1951 self.write("\"")?; 1952 if !title.is_empty() { 1953 self.write(" title=\"")?; ··· 1975 } 1976 self.write(" />")?; 1977 1978 - // Emit closing ](url) 1979 - if range.start < range.end { 1980 - let raw_text = &self.source[range]; 1981 - if let Some(paren_pos) = raw_text.rfind("](") { 1982 - let syntax = &raw_text[paren_pos..]; 1983 - let syn_id = self.gen_syn_id(); 1984 - let char_start = self.last_char_offset; 1985 - let syntax_char_len = syntax.chars().count(); 1986 - let char_end = char_start + syntax_char_len; 1987 1988 - write!( 1989 - &mut self.writer, 1990 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1991 - syn_id, char_start, char_end 1992 - )?; 1993 - escape_html(&mut self.writer, syntax)?; 1994 - self.write("</span>")?; 1995 1996 - self.syntax_spans.push(SyntaxSpanInfo { 1997 - syn_id, 1998 - char_range: char_start..char_end, 1999 - syntax_type: SyntaxType::Inline, 2000 - formatted_range: None, 2001 - }); 2002 - } 2003 - } 2004 Ok(()) 2005 } 2006 Tag::Embed { ··· 2009 title, 2010 id, 2011 attrs, 2012 - } => self.write_embed(embed_type, dest_url, title, id, attrs), 2013 Tag::WeaverBlock(_, _) => { 2014 self.in_non_writing_block = true; 2015 Ok(()) ··· 2305 self.finalize_paired_inline_format(); 2306 Ok(()) 2307 } 2308 - TagEnd::Link => self.write("</a>"), 2309 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 2310 TagEnd::Embed => Ok(()), 2311 TagEnd::WeaverBlock(_) => { ··· 2340 { 2341 fn write_embed( 2342 &mut self, 2343 embed_type: EmbedType, 2344 dest_url: CowStr<'_>, 2345 title: CowStr<'_>, 2346 id: CowStr<'_>, 2347 attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 2348 ) -> Result<(), W::Error> { 2349 // Try to get content from attributes first 2350 let content_from_attrs = if let Some(ref attrs) = attrs { 2351 attrs ··· 2413 } 2414 self.write("></iframe>")?; 2415 } 2416 Ok(()) 2417 } 2418 }
··· 56 pub formatted_range: Option<Range<usize>>, 57 } 58 59 + impl SyntaxSpanInfo { 60 + /// Adjust all position fields by a character delta. 61 + /// 62 + /// This adjusts both `char_range` and `formatted_range` (if present) together, 63 + /// ensuring they stay in sync. Use this instead of manually adjusting fields 64 + /// to avoid forgetting one. 65 + pub fn adjust_positions(&mut self, char_delta: isize) { 66 + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 67 + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 68 + if let Some(ref mut fr) = self.formatted_range { 69 + fr.start = (fr.start as isize + char_delta) as usize; 70 + fr.end = (fr.end as isize + char_delta) as usize; 71 + } 72 + } 73 + } 74 + 75 /// Classify syntax text as inline or block level 76 fn classify_syntax(text: &str) -> SyntaxType { 77 let trimmed = text.trim_start(); ··· 854 // the closing syntax must be emitted AFTER the closing HTML tag, not before. 855 // Otherwise the closing `**` span ends up INSIDE the <strong> element. 856 // These tags handle their own closing syntax in end_tag(). 857 + // Image and Embed handle ALL their syntax in the Start event, so exclude them too. 858 use markdown_weaver::TagEnd; 859 + let is_self_handled_end = matches!( 860 &event, 861 + Event::End( 862 + TagEnd::Strong 863 + | TagEnd::Emphasis 864 + | TagEnd::Strikethrough 865 + | TagEnd::Image 866 + | TagEnd::Embed 867 + ) 868 ); 869 870 + if matches!(&event, Event::End(_)) && !is_self_handled_end { 871 // Emit gap from last_byte_offset to range.end 872 self.emit_gap_before(range.end)?; 873 } else if !matches!(&event, Event::End(_)) { ··· 999 Ok(()) 1000 } 1001 1002 + /// Consume events until End tag without writing anything. 1003 + /// Used when we've already extracted content from source and just need to advance the iterator. 1004 + fn consume_until_end(&mut self) { 1005 + use Event::*; 1006 + let mut nest = 0; 1007 + while let Some((event, _range)) = self.events.next() { 1008 + match event { 1009 + Start(_) => nest += 1, 1010 + End(_) => { 1011 + if nest == 0 { 1012 + break; 1013 + } 1014 + nest -= 1; 1015 + } 1016 + _ => {} 1017 + } 1018 + } 1019 + } 1020 + 1021 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> { 1022 use Event::*; 1023 ··· 1068 } 1069 } 1070 Code(text) => { 1071 + let format_start = self.last_char_offset; 1072 let raw_text = &self.source[range.clone()]; 1073 1074 + // Track opening span index so we can set formatted_range later 1075 + let opening_span_idx = if raw_text.starts_with('`') { 1076 let syn_id = self.gen_syn_id(); 1077 + let char_start = self.last_char_offset; 1078 let backtick_char_end = char_start + 1; 1079 write!( 1080 &mut self.writer, ··· 1085 syn_id, 1086 char_range: char_start..backtick_char_end, 1087 syntax_type: SyntaxType::Inline, 1088 + formatted_range: None, // Set after we know the full range 1089 }); 1090 self.last_char_offset += 1; 1091 + Some(self.syntax_spans.len() - 1) 1092 + } else { 1093 + None 1094 + }; 1095 1096 self.write("<code>")?; 1097 ··· 1116 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 1117 syn_id, backtick_char_start, backtick_char_end 1118 )?; 1119 + 1120 + // Now we know the full formatted range 1121 + let formatted_range = format_start..backtick_char_end; 1122 + 1123 self.syntax_spans.push(SyntaxSpanInfo { 1124 syn_id, 1125 char_range: backtick_char_start..backtick_char_end, 1126 syntax_type: SyntaxType::Inline, 1127 + formatted_range: Some(formatted_range.clone()), 1128 }); 1129 + 1130 + // Update opening span with formatted_range 1131 + if let Some(idx) = opening_span_idx { 1132 + self.syntax_spans[idx].formatted_range = Some(formatted_range); 1133 + } 1134 + 1135 self.last_char_offset += 1; 1136 } 1137 } 1138 InlineMath(text) => { 1139 + let format_start = self.last_char_offset; 1140 let raw_text = &self.source[range.clone()]; 1141 1142 + // Track opening span index so we can set formatted_range later 1143 + let opening_span_idx = if raw_text.starts_with('$') { 1144 let syn_id = self.gen_syn_id(); 1145 let char_start = self.last_char_offset; 1146 let char_end = char_start + 1; ··· 1153 syn_id, 1154 char_range: char_start..char_end, 1155 syntax_type: SyntaxType::Inline, 1156 + formatted_range: None, // Set after we know the full range 1157 }); 1158 self.last_char_offset += 1; 1159 + Some(self.syntax_spans.len() - 1) 1160 + } else { 1161 + None 1162 + }; 1163 1164 self.write(r#"<span class="math math-inline">"#)?; 1165 let text_char_len = text.chars().count(); ··· 1177 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 1178 syn_id, char_start, char_end 1179 )?; 1180 + 1181 + // Now we know the full formatted range 1182 + let formatted_range = format_start..char_end; 1183 + 1184 self.syntax_spans.push(SyntaxSpanInfo { 1185 syn_id, 1186 char_range: char_start..char_end, 1187 syntax_type: SyntaxType::Inline, 1188 + formatted_range: Some(formatted_range.clone()), 1189 }); 1190 + 1191 + // Update opening span with formatted_range 1192 + if let Some(idx) = opening_span_idx { 1193 + self.syntax_spans[idx].formatted_range = Some(formatted_range); 1194 + } 1195 + 1196 self.last_char_offset += 1; 1197 } 1198 } ··· 1447 None 1448 } 1449 } 1450 + Tag::Link { link_type, .. } => { 1451 + if matches!(link_type, LinkType::WikiLink { .. }) { 1452 + if raw_text.starts_with("[[") { 1453 + Some("[[") 1454 + } else { 1455 + None 1456 + } 1457 + } else if raw_text.starts_with('[') { 1458 Some("[") 1459 } else { 1460 None 1461 } 1462 } 1463 + // Note: Tag::Image and Tag::Embed handle their own syntax spans 1464 + // in their respective handlers, so don't emit here 1465 _ => None, 1466 }; 1467 ··· 1503 let byte_end = range.start + syntax_byte_len; 1504 self.record_mapping(byte_start..byte_end, char_start..char_end); 1505 1506 + // For paired inline syntax, track opening span for formatted_range 1507 + if matches!( 1508 + tag, 1509 + Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } 1510 + ) { 1511 self.pending_inline_formats.push((syn_id, char_start)); 1512 } 1513 ··· 1991 attrs, 1992 .. 1993 } => { 1994 + // Image rendering: all syntax elements share one syn_id for visibility toggling 1995 + // Structure: ![ alt text ](url) <img> cursor-landing 1996 + let raw_text = &self.source[range.clone()]; 1997 + let syn_id = self.gen_syn_id(); 1998 + let opening_char_start = self.last_char_offset; 1999 2000 + // Find the alt text and closing syntax positions 2001 + let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len()); 2002 + let alt_text = if raw_text.starts_with("![") && paren_pos > 2 { 2003 + &raw_text[2..paren_pos] 2004 + } else { 2005 + "" 2006 + }; 2007 + let closing_syntax = if paren_pos < raw_text.len() { 2008 + &raw_text[paren_pos..] 2009 + } else { 2010 + "" 2011 + }; 2012 2013 + // Calculate char positions 2014 + let alt_char_len = alt_text.chars().count(); 2015 + let closing_char_len = closing_syntax.chars().count(); 2016 + let opening_char_end = opening_char_start + 2; // "![" 2017 + let alt_char_start = opening_char_end; 2018 + let alt_char_end = alt_char_start + alt_char_len; 2019 + let closing_char_start = alt_char_end; 2020 + let closing_char_end = closing_char_start + closing_char_len; 2021 + let formatted_range = opening_char_start..closing_char_end; 2022 + 2023 + // 1. Emit opening ![ syntax span 2024 + if raw_text.starts_with("![") { 2025 + write!( 2026 + &mut self.writer, 2027 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![</span>", 2028 + syn_id, opening_char_start, opening_char_end 2029 + )?; 2030 + 2031 + self.syntax_spans.push(SyntaxSpanInfo { 2032 + syn_id: syn_id.clone(), 2033 + char_range: opening_char_start..opening_char_end, 2034 + syntax_type: SyntaxType::Inline, 2035 + formatted_range: Some(formatted_range.clone()), 2036 + }); 2037 + 2038 + // Record offset mapping for ![ 2039 + self.record_mapping( 2040 + range.start..range.start + 2, 2041 + opening_char_start..opening_char_end, 2042 + ); 2043 } 2044 2045 + // 2. Emit alt text span (same syn_id, editable when visible) 2046 + if !alt_text.is_empty() { 2047 + write!( 2048 + &mut self.writer, 2049 + "<span class=\"image-alt\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2050 + syn_id, alt_char_start, alt_char_end 2051 + )?; 2052 + escape_html(&mut self.writer, alt_text)?; 2053 + self.write("</span>")?; 2054 + 2055 + self.syntax_spans.push(SyntaxSpanInfo { 2056 + syn_id: syn_id.clone(), 2057 + char_range: alt_char_start..alt_char_end, 2058 + syntax_type: SyntaxType::Inline, 2059 + formatted_range: Some(formatted_range.clone()), 2060 + }); 2061 + 2062 + // Record offset mapping for alt text 2063 + self.record_mapping( 2064 + range.start + 2..range.start + 2 + alt_text.len(), 2065 + alt_char_start..alt_char_end, 2066 + ); 2067 + } 2068 + 2069 + // 3. Emit closing ](url) syntax span 2070 + if !closing_syntax.is_empty() { 2071 + write!( 2072 + &mut self.writer, 2073 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2074 + syn_id, closing_char_start, closing_char_end 2075 + )?; 2076 + escape_html(&mut self.writer, closing_syntax)?; 2077 + self.write("</span>")?; 2078 + 2079 + self.syntax_spans.push(SyntaxSpanInfo { 2080 + syn_id: syn_id.clone(), 2081 + char_range: closing_char_start..closing_char_end, 2082 + syntax_type: SyntaxType::Inline, 2083 + formatted_range: Some(formatted_range.clone()), 2084 + }); 2085 + 2086 + // Record offset mapping for ](url) 2087 + self.record_mapping( 2088 + range.start + paren_pos..range.end, 2089 + closing_char_start..closing_char_end, 2090 + ); 2091 + } 2092 + 2093 + // 4. Emit <img> element (no syn_id - always visible) 2094 self.write("<img src=\"")?; 2095 let resolved_url = self 2096 .image_resolver 2097 .as_ref() ··· 2102 escape_href(&mut self.writer, &dest_url)?; 2103 } 2104 self.write("\" alt=\"")?; 2105 + escape_html(&mut self.writer, alt_text)?; 2106 self.write("\"")?; 2107 if !title.is_empty() { 2108 self.write(" title=\"")?; ··· 2130 } 2131 self.write(" />")?; 2132 2133 + // Consume the text events for alt (they're still in the iterator) 2134 + // Use consume_until_end() since we already wrote alt text from source 2135 + self.consume_until_end(); 2136 2137 + // Update offsets 2138 + self.last_char_offset = closing_char_end; 2139 + self.last_byte_offset = range.end; 2140 2141 Ok(()) 2142 } 2143 Tag::Embed { ··· 2146 title, 2147 id, 2148 attrs, 2149 + } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2150 Tag::WeaverBlock(_, _) => { 2151 self.in_non_writing_block = true; 2152 Ok(()) ··· 2442 self.finalize_paired_inline_format(); 2443 Ok(()) 2444 } 2445 + TagEnd::Link => { 2446 + self.write("</a>")?; 2447 + // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) 2448 + let raw_text = &self.source[range.clone()]; 2449 + if raw_text.ends_with("]]") { 2450 + // WikiLink: emit ]] as closing syntax 2451 + let syn_id = self.gen_syn_id(); 2452 + let char_start = self.last_char_offset; 2453 + let char_end = char_start + 2; 2454 + 2455 + write!( 2456 + &mut self.writer, 2457 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">]]</span>", 2458 + syn_id, char_start, char_end 2459 + )?; 2460 + 2461 + self.syntax_spans.push(SyntaxSpanInfo { 2462 + syn_id, 2463 + char_range: char_start..char_end, 2464 + syntax_type: SyntaxType::Inline, 2465 + formatted_range: None, // Will be set by finalize 2466 + }); 2467 + 2468 + self.last_char_offset = char_end; 2469 + self.last_byte_offset = range.end; 2470 + } else { 2471 + self.emit_gap_before(range.end)?; 2472 + } 2473 + self.finalize_paired_inline_format(); 2474 + Ok(()) 2475 + } 2476 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 2477 TagEnd::Embed => Ok(()), 2478 TagEnd::WeaverBlock(_) => { ··· 2507 { 2508 fn write_embed( 2509 &mut self, 2510 + range: Range<usize>, 2511 embed_type: EmbedType, 2512 dest_url: CowStr<'_>, 2513 title: CowStr<'_>, 2514 id: CowStr<'_>, 2515 attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 2516 ) -> Result<(), W::Error> { 2517 + // Track opening span index for formatted_range 2518 + let opening_span_idx: Option<usize>; 2519 + let opening_char_start: usize; 2520 + 2521 + // Emit opening ![[ 2522 + if range.start < range.end { 2523 + let raw_text = &self.source[range.clone()]; 2524 + if raw_text.starts_with("![[") { 2525 + let syn_id = self.gen_syn_id(); 2526 + let char_start = self.last_char_offset; 2527 + let char_end = char_start + 3; // "![[" 2528 + 2529 + write!( 2530 + &mut self.writer, 2531 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">![[</span>", 2532 + syn_id, char_start, char_end 2533 + )?; 2534 + 2535 + opening_span_idx = Some(self.syntax_spans.len()); 2536 + opening_char_start = char_start; 2537 + self.syntax_spans.push(SyntaxSpanInfo { 2538 + syn_id, 2539 + char_range: char_start..char_end, 2540 + syntax_type: SyntaxType::Inline, 2541 + formatted_range: None, 2542 + }); 2543 + 2544 + self.last_char_offset = char_end; 2545 + self.last_byte_offset = range.start + 3; 2546 + } else { 2547 + opening_span_idx = None; 2548 + opening_char_start = self.last_char_offset; 2549 + } 2550 + } else { 2551 + opening_span_idx = None; 2552 + opening_char_start = self.last_char_offset; 2553 + } 2554 + 2555 // Try to get content from attributes first 2556 let content_from_attrs = if let Some(ref attrs) = attrs { 2557 attrs ··· 2619 } 2620 self.write("></iframe>")?; 2621 } 2622 + 2623 + // Emit closing ]] 2624 + if range.start < range.end { 2625 + let raw_text = &self.source[range.clone()]; 2626 + if raw_text.ends_with("]]") { 2627 + let syn_id = self.gen_syn_id(); 2628 + let char_start = self.last_char_offset; 2629 + let char_end = char_start + 2; // "]]" 2630 + 2631 + write!( 2632 + &mut self.writer, 2633 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">]]</span>", 2634 + syn_id, char_start, char_end 2635 + )?; 2636 + 2637 + // Set formatted_range on both opening and closing spans 2638 + let formatted_range = opening_char_start..char_end; 2639 + self.syntax_spans.push(SyntaxSpanInfo { 2640 + syn_id, 2641 + char_range: char_start..char_end, 2642 + syntax_type: SyntaxType::Inline, 2643 + formatted_range: Some(formatted_range.clone()), 2644 + }); 2645 + 2646 + // Update opening span's formatted_range 2647 + if let Some(idx) = opening_span_idx { 2648 + if let Some(span) = self.syntax_spans.get_mut(idx) { 2649 + span.formatted_range = Some(formatted_range); 2650 + } 2651 + } 2652 + 2653 + self.last_char_offset = char_end; 2654 + self.last_byte_offset = range.end; 2655 + } 2656 + } 2657 + 2658 Ok(()) 2659 } 2660 }