perf tuning/refactor

Orual 157cb538 468ca19d

+1504 -786
+2 -1
crates/weaver-app/assets/styling/editor.css
··· 227 227 228 228 /* Hidden syntax spans - collapsed when cursor is not near */ 229 229 .md-syntax-inline.hidden, 230 - .md-syntax-block.hidden { 230 + .md-syntax-block.hidden, 231 + .image-alt.hidden { 231 232 display: none; 232 233 } 233 234
+402 -328
crates/weaver-app/src/components/editor/component.rs
··· 11 11 use crate::fetch::Fetcher; 12 12 13 13 use super::document::{CompositionState, EditorDocument}; 14 - use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; 14 + use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom}; 15 + use super::offset_map::SnapDirection; 15 16 use super::formatting; 16 17 use super::input::{ 17 18 get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, ··· 45 46 // Try to restore from localStorage (includes CRDT state for undo history) 46 47 // Use "current" as the default draft key for now 47 48 let draft_key = "current"; 48 - let mut document = use_signal(move || { 49 + // Document is NOT in a signal - its fields are individually reactive 50 + let mut document = use_hook(|| { 49 51 storage::load_from_storage(draft_key) 50 52 .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 51 53 }); ··· 58 60 let mut image_resolver = use_signal(EditorImageResolver::default); 59 61 60 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(); 61 65 let paragraphs = use_memo(move || { 62 - let doc = document(); 66 + let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency 63 67 let cache = render_cache.peek(); 64 - let edit = doc.last_edit.as_ref(); 65 68 let resolver = image_resolver(); 66 69 67 - let (paras, new_cache) = 68 - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver)); 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 + ); 69 76 70 77 // Update cache for next render (write-only via spawn to avoid reactive loop) 71 78 dioxus::prelude::spawn(async move { ··· 96 103 97 104 // Update DOM when paragraphs change (incremental rendering) 98 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"))] 99 108 use_effect(move || { 100 109 tracing::debug!("DOM update effect triggered"); 101 110 102 - // Read document once to avoid multiple borrows 103 - let doc = document(); 104 - 105 111 tracing::debug!( 106 - composition_active = doc.composition.is_some(), 107 - cursor = doc.cursor.offset, 112 + composition_active = doc_for_dom.composition.read().is_some(), 113 + cursor = doc_for_dom.cursor.read().offset, 108 114 "DOM update: checking state" 109 115 ); 110 116 111 117 // Skip DOM updates during IME composition - browser controls the preview 112 - if doc.composition.is_some() { 118 + if doc_for_dom.composition.read().is_some() { 113 119 tracing::debug!("skipping DOM update during composition"); 114 120 return; 115 121 } 116 122 117 123 tracing::debug!( 118 - cursor = doc.cursor.offset, 119 - len = doc.len_chars(), 124 + cursor = doc_for_dom.cursor.read().offset, 125 + len = doc_for_dom.len_chars(), 120 126 "DOM update proceeding (not in composition)" 121 127 ); 122 128 123 - let cursor_offset = doc.cursor.offset; 124 - let selection = doc.selection; 125 - drop(doc); // Release borrow before other operations 129 + let cursor_offset = doc_for_dom.cursor.read().offset; 130 + let selection = *doc_for_dom.selection.read(); 126 131 127 132 let new_paras = paragraphs(); 128 133 let map = offset_map(); ··· 138 143 use wasm_bindgen::JsCast; 139 144 use wasm_bindgen::prelude::*; 140 145 146 + // Read and consume pending snap direction 147 + let snap_direction = doc_for_dom.pending_snap.write().take(); 148 + 141 149 // Use requestAnimationFrame to wait for browser paint 142 150 if let Some(window) = web_sys::window() { 143 151 let closure = Closure::once(move || { 144 152 if let Err(e) = 145 - super::cursor::restore_cursor_position(cursor_offset, &map, editor_id) 153 + super::cursor::restore_cursor_position(cursor_offset, &map, editor_id, snap_direction) 146 154 { 147 155 tracing::warn!("Cursor restoration failed: {:?}", e); 148 156 } ··· 164 172 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 165 173 166 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(); 167 177 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 168 178 use_effect(move || { 169 179 // Check every 500ms if there are unsaved changes 180 + let mut doc = doc_for_autosave.clone(); 170 181 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(); 182 + let current_frontiers = doc.state_frontiers(); 173 183 174 184 // Only save if frontiers changed (document was edited) 175 185 let needs_save = { ··· 181 191 }; // drop last_frontiers borrow here 182 192 183 193 if needs_save { 184 - document.with_mut(|doc| { 185 - doc.sync_loro_cursor(); 186 - let _ = storage::save_to_storage(doc, draft_key); 187 - }); 194 + doc.sync_loro_cursor(); 195 + let _ = storage::save_to_storage(&doc, draft_key); 188 196 189 197 // Update last saved frontiers 190 198 last_saved_frontiers.set(Some(current_frontiers)); ··· 194 202 }); 195 203 196 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(); 197 207 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 198 208 use_effect(move || { 199 209 use wasm_bindgen::JsCast; ··· 219 229 None => return, 220 230 }; 221 231 222 - let mut document_signal = document; 232 + let mut doc = doc_for_beforeinput.clone(); 223 233 let cached_paras = cached_paragraphs; 224 234 225 235 let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { ··· 235 245 evt.prevent_default(); 236 246 237 247 // 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 - } 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 + } 244 254 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 + 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 + } 255 265 } 256 266 257 267 // Android workaround: When swipe keyboard picks a suggestion, ··· 264 274 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 265 275 // Defer cursor sync by 20ms to let selection settle 266 276 let paras = cached_paras; 267 - let doc_sig = document_signal; 277 + let mut doc_for_timeout = doc.clone(); 268 278 let window = web_sys::window(); 269 279 if let Some(window) = window { 270 280 let closure = Closure::once(move || { 271 281 let paras = paras(); 272 - sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, &paras); 282 + sync_cursor_from_dom(&mut doc_for_timeout, editor_id, &paras); 273 283 }); 274 284 let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 275 285 closure.as_ref().unchecked_ref(), ··· 299 309 r#type: "text", 300 310 class: "title-input", 301 311 placeholder: "Entry title...", 302 - value: "{document().title()}", 303 - oninput: move |e| { 304 - document.with_mut(|doc| doc.set_title(&e.value())); 312 + value: "{document.title()}", 313 + oninput: { 314 + let doc = document.clone(); 315 + move |e| { 316 + doc.set_title(&e.value()); 317 + } 305 318 }, 306 319 } 307 320 } ··· 314 327 r#type: "text", 315 328 class: "path-input", 316 329 placeholder: "url-slug", 317 - value: "{document().path()}", 318 - oninput: move |e| { 319 - document.with_mut(|doc| doc.set_path(&e.value())); 330 + value: "{document.path()}", 331 + oninput: { 332 + let doc = document.clone(); 333 + move |e| { 334 + doc.set_path(&e.value()); 335 + } 320 336 }, 321 337 } 322 338 } ··· 324 340 div { class: "meta-tags", 325 341 label { "Tags" } 326 342 div { class: "tags-container", 327 - for tag in document().tags() { 343 + for tag in document.tags() { 328 344 span { 329 345 class: "tag-chip", 330 346 "{tag}" 331 347 button { 332 348 class: "tag-remove", 333 349 onclick: { 350 + let doc = document.clone(); 334 351 let tag_to_remove = tag.clone(); 335 352 move |_| { 336 - document.with_mut(|doc| doc.remove_tag(&tag_to_remove)); 353 + doc.remove_tag(&tag_to_remove); 337 354 } 338 355 }, 339 356 "×" ··· 346 363 placeholder: "Add tag...", 347 364 value: "{new_tag}", 348 365 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()); 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 + } 356 376 } 357 377 }, 358 378 } ··· 360 380 } 361 381 362 382 PublishButton { 363 - document: document, 383 + document: document.clone(), 364 384 draft_key: draft_key.to_string(), 365 385 } 366 386 } ··· 372 392 class: "editor-content", 373 393 contenteditable: "true", 374 394 375 - onkeydown: move |evt| { 376 - use dioxus::prelude::keyboard_types::Key; 377 - use std::time::Duration; 395 + onkeydown: { 396 + let mut doc = document.clone(); 397 + move |evt| { 398 + use dioxus::prelude::keyboard_types::Key; 399 + use std::time::Duration; 378 400 379 - let plat = platform::platform(); 380 - let mods = evt.modifiers(); 381 - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 401 + let plat = platform::platform(); 402 + let mods = evt.modifiers(); 403 + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 382 404 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 - } 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 + } 395 415 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) { 416 + // Allow modifier shortcuts through during composition 417 + if !has_modifier { 413 418 tracing::debug!( 414 - "Safari: ignoring Enter within 500ms of compositionend" 419 + key = ?evt.key(), 420 + "keydown during composition - delegating to browser" 415 421 ); 416 422 return; 417 423 } 424 + // Fall through to handle the shortcut 418 425 } 419 - } 420 426 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 + // 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 + } 427 440 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); 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 + } 433 454 } 434 455 }, 435 456 436 - onkeyup: move |evt| { 437 - use dioxus::prelude::keyboard_types::Key; 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 + }; 438 468 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 - ); 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 + ); 445 475 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"); 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"); 449 479 450 - if navigation || select_all { 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"); 451 504 let paras = cached_paragraphs(); 452 - sync_cursor_from_dom(&mut document, editor_id, &paras); 453 - let doc = document(); 505 + sync_cursor_from_dom(&mut doc, editor_id, &paras); 454 506 let spans = syntax_spans(); 507 + let cursor_offset = doc.cursor.read().offset; 508 + let selection = *doc.selection.read(); 455 509 update_syntax_visibility( 456 - doc.cursor.offset, 457 - doc.selection.as_ref(), 510 + cursor_offset, 511 + selection.as_ref(), 458 512 &spans, 459 513 &paras, 460 514 ); 461 515 } 462 516 }, 463 517 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 - ); 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 + } 476 534 }, 477 535 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 - ); 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 + } 504 552 }, 505 553 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 - ); 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 + } 518 570 }, 519 571 520 572 // Android workaround: Handle Enter in keypress instead of keydown. 521 573 // Chrome Android fires confused composition events on Enter in keydown, 522 574 // but keypress fires after composition state settles. 523 - onkeypress: move |evt| { 524 - use dioxus::prelude::keyboard_types::Key; 575 + onkeypress: { 576 + let mut doc = document.clone(); 577 + move |evt| { 578 + use dioxus::prelude::keyboard_types::Key; 525 579 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); 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 + } 531 586 } 532 587 }, 533 588 534 - onpaste: move |evt| { 535 - handle_paste(evt, &mut document); 589 + onpaste: { 590 + let mut doc = document.clone(); 591 + move |evt| { 592 + handle_paste(evt, &mut doc); 593 + } 536 594 }, 537 595 538 - oncut: move |evt| { 539 - handle_cut(evt, &mut document); 596 + oncut: { 597 + let mut doc = document.clone(); 598 + move |evt| { 599 + handle_cut(evt, &mut doc); 600 + } 540 601 }, 541 602 542 - oncopy: move |evt| { 543 - handle_copy(evt, &document); 603 + oncopy: { 604 + let doc = document.clone(); 605 + move |evt| { 606 + handle_copy(evt, &doc); 607 + } 544 608 }, 545 609 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"); 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); 551 619 } 552 - document.with_mut(|doc| { 553 - doc.composition = None; 554 - }); 555 620 }, 556 621 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| { 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 + ); 564 630 // Delete selection if present (composition replaces it) 565 - if let Some(sel) = doc.selection.take() { 631 + let sel = doc.selection.write().take(); 632 + if let Some(sel) = sel { 566 633 let (start, end) = 567 634 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 568 635 tracing::debug!( ··· 571 638 "compositionstart: deleting selection" 572 639 ); 573 640 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 574 - doc.cursor.offset = start; 641 + doc.cursor.write().offset = start; 575 642 } 576 643 644 + let cursor_offset = doc.cursor.read().offset; 577 645 tracing::debug!( 578 - cursor = doc.cursor.offset, 646 + cursor = cursor_offset, 579 647 "compositionstart: setting composition state" 580 648 ); 581 - doc.composition = Some(CompositionState { 582 - start_offset: doc.cursor.offset, 649 + doc.composition.set(Some(CompositionState { 650 + start_offset: cursor_offset, 583 651 text: data, 584 - }); 585 - }); 652 + })); 653 + } 586 654 }, 587 655 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 { 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 { 596 666 comp.text = data; 597 667 } else { 598 668 tracing::debug!("compositionupdate without active composition state"); 599 669 } 600 - }); 670 + } 601 671 }, 602 672 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| { 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 + ); 610 681 // Record when composition ended for Safari timing workaround 611 - doc.composition_ended_at = Some(web_time::Instant::now()); 682 + doc.composition_ended_at.set(Some(web_time::Instant::now())); 612 683 613 - if let Some(comp) = doc.composition.take() { 684 + let comp = doc.composition.write().take(); 685 + if let Some(comp) = comp { 614 686 tracing::debug!( 615 687 start_offset = comp.start_offset, 616 688 final_text = %final_text, ··· 627 699 } 628 700 } 629 701 630 - let zw_count = doc.cursor.offset - delete_start; 702 + let cursor_offset = doc.cursor.read().offset; 703 + let zw_count = cursor_offset - delete_start; 631 704 if zw_count > 0 { 632 705 // Splice: delete zero-width chars and insert new char in one op 633 706 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() { 707 + doc.cursor.write().offset = delete_start + final_text.chars().count(); 708 + } else if cursor_offset == doc.len_chars() { 636 709 // Fast path: append at end 637 710 let _ = doc.push_tracked(&final_text); 638 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 711 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 639 712 } else { 640 - let _ = doc.insert_tracked(doc.cursor.offset, &final_text); 641 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 713 + let _ = doc.insert_tracked(cursor_offset, &final_text); 714 + doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 642 715 } 643 716 } 644 717 } else { 645 718 tracing::debug!("compositionend without active composition state"); 646 719 } 647 - }); 720 + } 648 721 }, 649 722 } 650 723 651 724 // Debug panel snug below editor 652 725 div { class: "editor-debug", 653 - div { "Cursor: {document().cursor.offset}, Chars: {document().len_chars()}" }, 726 + div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 654 727 ReportButton { 655 728 email: "editor-bugs@weaver.sh".to_string(), 656 729 editor_id: "markdown-editor".to_string(), ··· 660 733 661 734 // Toolbar in grid column 2, row 3 662 735 EditorToolbar { 663 - on_format: move |action| { 664 - document.with_mut(|doc| { 665 - formatting::apply_formatting(doc, action); 666 - }); 736 + on_format: { 737 + let mut doc = document.clone(); 738 + move |action| { 739 + formatting::apply_formatting(&mut doc, action); 740 + } 667 741 }, 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 - ); 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 + ); 676 752 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 - }); 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 + }); 682 758 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); 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); 690 766 691 - document.with_mut(|doc| { 692 - let pos = doc.cursor.offset; 767 + let pos = doc.cursor.read().offset; 693 768 let _ = doc.insert_tracked(pos, &markdown); 694 - doc.cursor.offset = pos + markdown.chars().count(); 695 - }); 769 + doc.cursor.write().offset = pos + markdown.chars().count(); 696 770 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(); 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(); 704 779 705 - spawn(async move { 706 - let client = fetcher.get_client(); 780 + spawn(async move { 781 + let client = fetcher.get_client(); 707 782 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 - }; 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 + }; 719 794 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(); 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(); 727 802 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 - }; 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 + }; 736 811 737 - let cid = blob.cid().to_string(); 812 + let cid = blob.cid().to_string(); 738 813 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(); 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(); 746 821 747 - // Add to document 748 - document.with_mut(|doc| { 749 - doc.add_image(&image, Some(&strong_ref.uri)); 750 - }); 822 + // Add to document 823 + doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 751 824 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 - }); 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 + }); 761 834 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 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 + } 767 841 } 768 - } 769 - }); 770 - } else { 771 - tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 842 + }); 843 + } else { 844 + tracing::info!(name = %name, "Image added with data URL (not authenticated)"); 845 + } 772 846 } 773 - } 847 + }, 774 848 } 775 849 776 850 }
+25 -4
crates/weaver-app/src/components/editor/cursor.rs
··· 7 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 8 //! 4. Setting cursor with web_sys Selection API 9 9 10 - use super::offset_map::{OffsetMapping, find_mapping_for_char}; 10 + use super::offset_map::{OffsetMapping, SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 11 11 12 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 13 use wasm_bindgen::JsCast; ··· 18 18 /// - `char_offset`: Cursor position as char offset in document 19 19 /// - `offset_map`: Mappings from source to DOM positions 20 20 /// - `editor_id`: DOM ID of the contenteditable element 21 + /// - `snap_direction`: Optional direction hint for snapping from invisible content 21 22 /// 22 23 /// # Algorithm 23 24 /// 1. Find offset mapping containing char_offset ··· 29 30 char_offset: usize, 30 31 offset_map: &[OffsetMapping], 31 32 editor_id: &str, 33 + snap_direction: Option<SnapDirection>, 32 34 ) -> Result<(), wasm_bindgen::JsValue> { 33 35 // Empty document - no cursor to restore 34 36 if offset_map.is_empty() { ··· 51 53 return Ok(()); 52 54 } 53 55 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")?; 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 + }; 57 77 58 78 tracing::trace!( 59 79 target: "weaver::cursor", ··· 160 180 _char_offset: usize, 161 181 _offset_map: &[OffsetMapping], 162 182 _editor_id: &str, 183 + _snap_direction: Option<SnapDirection>, 163 184 ) -> Result<(), String> { 164 185 Ok(()) 165 186 }
+118 -73
crates/weaver-app/src/components/editor/document.rs
··· 2 2 //! 3 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 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` 5 12 13 + use std::cell::RefCell; 14 + use std::rc::Rc; 15 + 16 + use dioxus::prelude::*; 6 17 use loro::{ 7 18 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, 8 19 cursor::{Cursor, Side}, ··· 30 41 /// Contains the document text (backed by Loro CRDT), cursor position, 31 42 /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 32 43 /// schema with CRDT containers for each field. 33 - #[derive(Debug)] 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)] 34 59 pub struct EditorDocument { 35 60 /// The Loro document containing all editor state. 36 61 doc: LoroDoc, 37 62 38 - // --- Entry schema containers --- 63 + // --- Entry schema containers (Loro handles interior mutability) --- 39 64 /// Markdown content (maps to entry.content) 40 65 content: LoroText, 41 66 ··· 60 85 /// None for new entries that haven't been published yet. 61 86 entry_uri: Option<AtUri<'static>>, 62 87 63 - // --- Editor state --- 88 + // --- Editor state (non-reactive) --- 64 89 /// 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, 90 + undo_mgr: Rc<RefCell<UndoManager>>, 70 91 71 92 /// CRDT-aware cursor that tracks position through remote edits and undo/redo. 72 93 /// Recreated after our own edits, queried after undo/redo/remote edits. 73 94 loro_cursor: Option<Cursor>, 74 95 75 - /// Active selection if any 76 - pub selection: Option<Selection>, 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>, 77 99 78 - /// IME composition state (for Phase 3) 79 - pub composition: Option<CompositionState>, 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>>, 80 105 81 106 /// Timestamp when the last composition ended. 82 107 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 83 - pub composition_ended_at: Option<web_time::Instant>, 108 + pub composition_ended_at: Signal<Option<web_time::Instant>>, 84 109 85 110 /// 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>, 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>>, 88 117 } 89 118 90 119 /// Cursor state including position and affinity. ··· 121 150 } 122 151 123 152 /// Information about the most recent edit, used for incremental rendering optimization. 124 - #[derive(Clone, Debug, Default)] 153 + /// Derives PartialEq so it can be used with Dioxus memos for change detection. 154 + #[derive(Clone, Debug, Default, PartialEq)] 125 155 pub struct EditInfo { 126 156 /// Character offset where the edit occurred 127 157 pub edit_char_pos: usize, ··· 170 200 171 201 /// Create a new editor document with the given content. 172 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(...))`. 173 207 pub fn new(initial_content: String) -> Self { 174 208 let doc = LoroDoc::new(); 175 209 ··· 211 245 tags, 212 246 embeds, 213 247 entry_uri: None, 214 - undo_mgr, 215 - cursor: CursorState { 248 + undo_mgr: Rc::new(RefCell::new(undo_mgr)), 249 + loro_cursor, 250 + // Reactive editor state - wrapped in Signals 251 + cursor: Signal::new(CursorState { 216 252 offset: 0, 217 253 affinity: Affinity::Before, 218 - }, 219 - loro_cursor, 220 - selection: None, 221 - composition: None, 222 - composition_ended_at: None, 223 - last_edit: None, 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), 224 260 } 225 261 } 226 262 ··· 284 320 } 285 321 286 322 /// Set the entry title (replaces existing). 287 - pub fn set_title(&mut self, new_title: &str) { 323 + /// Takes &self because Loro has interior mutability. 324 + pub fn set_title(&self, new_title: &str) { 288 325 let current_len = self.title.len_unicode(); 289 326 if current_len > 0 { 290 327 self.title.delete(0, current_len).ok(); ··· 298 335 } 299 336 300 337 /// Set the URL path/slug (replaces existing). 301 - pub fn set_path(&mut self, new_path: &str) { 338 + /// Takes &self because Loro has interior mutability. 339 + pub fn set_path(&self, new_path: &str) { 302 340 let current_len = self.path.len_unicode(); 303 341 if current_len > 0 { 304 342 self.path.delete(0, current_len).ok(); ··· 312 350 } 313 351 314 352 /// Set the created_at timestamp (usually only called once on creation or when loading). 315 - pub fn set_created_at(&mut self, datetime: &str) { 353 + /// Takes &self because Loro has interior mutability. 354 + pub fn set_created_at(&self, datetime: &str) { 316 355 let current_len = self.created_at.len_unicode(); 317 356 if current_len > 0 { 318 357 self.created_at.delete(0, current_len).ok(); ··· 346 385 } 347 386 348 387 /// Add a tag (if not already present). 349 - pub fn add_tag(&mut self, tag: &str) { 388 + /// Takes &self because Loro has interior mutability. 389 + pub fn add_tag(&self, tag: &str) { 350 390 let existing = self.tags(); 351 391 if !existing.iter().any(|t| t == tag) { 352 392 self.tags.push(LoroValue::String(tag.into())).ok(); ··· 354 394 } 355 395 356 396 /// Remove a tag by value. 357 - pub fn remove_tag(&mut self, tag: &str) { 397 + /// Takes &self because Loro has interior mutability. 398 + pub fn remove_tag(&self, tag: &str) { 358 399 let len = self.tags.len(); 359 400 for i in (0..len).rev() { 360 401 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { ··· 367 408 } 368 409 369 410 /// Clear all tags. 370 - pub fn clear_tags(&mut self) { 411 + /// Takes &self because Loro has interior mutability. 412 + pub fn clear_tags(&self) { 371 413 let len = self.tags.len(); 372 414 if len > 0 { 373 415 self.tags.delete(0, len).ok(); ··· 484 526 let len_before = self.content.len_unicode(); 485 527 let result = self.content.insert(pos, text); 486 528 let len_after = self.content.len_unicode(); 487 - self.last_edit = Some(EditInfo { 529 + self.last_edit.set(Some(EditInfo { 488 530 edit_char_pos: pos, 489 531 inserted_len: len_after.saturating_sub(len_before), 490 532 deleted_len: 0, 491 533 contains_newline: text.contains('\n'), 492 534 in_block_syntax_zone, 493 535 doc_len_after: len_after, 494 - }); 536 + })); 495 537 result 496 538 } 497 539 ··· 501 543 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 502 544 let result = self.content.push_str(text); 503 545 let len_after = self.content.len_unicode(); 504 - self.last_edit = Some(EditInfo { 546 + self.last_edit.set(Some(EditInfo { 505 547 edit_char_pos: pos, 506 548 inserted_len: text.chars().count(), 507 549 deleted_len: 0, 508 550 contains_newline: text.contains('\n'), 509 551 in_block_syntax_zone, 510 552 doc_len_after: len_after, 511 - }); 553 + })); 512 554 result 513 555 } 514 556 ··· 519 561 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 520 562 521 563 let result = self.content.delete(start, len); 522 - self.last_edit = Some(EditInfo { 564 + self.last_edit.set(Some(EditInfo { 523 565 edit_char_pos: start, 524 566 inserted_len: 0, 525 567 deleted_len: len, 526 568 contains_newline, 527 569 in_block_syntax_zone, 528 570 doc_len_after: self.content.len_unicode(), 529 - }); 571 + })); 530 572 result 531 573 } 532 574 ··· 545 587 // because: len_after = len_before - deleted + inserted 546 588 let inserted_len = (len_after + len).saturating_sub(len_before); 547 589 548 - self.last_edit = Some(EditInfo { 590 + self.last_edit.set(Some(EditInfo { 549 591 edit_char_pos: start, 550 592 inserted_len, 551 593 deleted_len: len, 552 594 contains_newline: delete_has_newline || text.contains('\n'), 553 595 in_block_syntax_zone, 554 596 doc_len_after: len_after, 555 - }); 597 + })); 556 598 Ok(()) 557 599 } 558 600 ··· 564 606 // so it tracks through the undo operation 565 607 self.sync_loro_cursor(); 566 608 567 - let result = self.undo_mgr.undo()?; 609 + let result = self.undo_mgr.borrow_mut().undo()?; 568 610 if result { 569 611 // After undo, query Loro cursor for new position 570 612 self.sync_cursor_from_loro(); 613 + // Signal content change for re-render 614 + self.last_edit.set(None); 571 615 } 572 616 Ok(result) 573 617 } ··· 579 623 // Sync Loro cursor to current position BEFORE redo 580 624 self.sync_loro_cursor(); 581 625 582 - let result = self.undo_mgr.redo()?; 626 + let result = self.undo_mgr.borrow_mut().redo()?; 583 627 if result { 584 628 // After redo, query Loro cursor for new position 585 629 self.sync_cursor_from_loro(); 630 + // Signal content change for re-render 631 + self.last_edit.set(None); 586 632 } 587 633 Ok(result) 588 634 } 589 635 590 636 /// Check if undo is available. 591 637 pub fn can_undo(&self) -> bool { 592 - self.undo_mgr.can_undo() 638 + self.undo_mgr.borrow().can_undo() 593 639 } 594 640 595 641 /// Check if redo is available. 596 642 pub fn can_redo(&self) -> bool { 597 - self.undo_mgr.can_redo() 643 + self.undo_mgr.borrow().can_redo() 598 644 } 599 645 600 646 /// Get a slice of the content text. ··· 606 652 /// Sync the Loro cursor to the current cursor.offset position. 607 653 /// Call this after OUR edits where we know the new cursor position. 608 654 pub fn sync_loro_cursor(&mut self) { 609 - self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default()); 655 + let offset = self.cursor.read().offset; 656 + self.loro_cursor = self.content.get_cursor(offset, Side::default()); 610 657 } 611 658 612 659 /// Update cursor.offset from the Loro cursor's tracked position. ··· 615 662 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 616 663 let loro_cursor = self.loro_cursor.as_ref()?; 617 664 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) 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) 621 668 } 622 669 623 670 /// Get the Loro cursor for serialization. ··· 646 693 self.doc.state_frontiers() 647 694 } 648 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 + 649 702 /// Create a new EditorDocument from a binary snapshot. 650 703 /// Falls back to empty document if import fails. 651 704 /// ··· 655 708 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they 656 709 /// happen in real-time; it cannot rebuild history from imported CRDT ops. 657 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`. 658 715 pub fn from_snapshot( 659 716 snapshot: &[u8], 660 717 loro_cursor: Option<Cursor>, ··· 691 748 fallback_offset 692 749 }; 693 750 694 - let cursor = CursorState { 751 + let cursor_state = CursorState { 695 752 offset: cursor_offset.min(max_offset), 696 753 affinity: Affinity::Before, 697 754 }; 698 755 699 756 // If no Loro cursor provided, create one at the restored position 700 757 let loro_cursor = 701 - loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default())); 758 + loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default())); 702 759 703 760 Self { 704 761 doc, ··· 709 766 tags, 710 767 embeds, 711 768 entry_uri: None, 712 - undo_mgr, 713 - cursor, 769 + undo_mgr: Rc::new(RefCell::new(undo_mgr)), 714 770 loro_cursor, 715 - selection: None, 716 - composition: None, 717 - composition_ended_at: None, 718 - last_edit: None, 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), 719 778 } 720 779 } 721 780 } 722 781 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 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 742 787 } 743 788 }
+79 -21
crates/weaver-app/src/components/editor/dom_sync.rs
··· 6 6 use dioxus::prelude::*; 7 7 8 8 use super::document::{EditorDocument, Selection}; 9 + use super::offset_map::{find_nearest_valid_position, is_valid_cursor_position, SnapDirection}; 9 10 use super::paragraph::ParagraphRender; 10 11 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. 12 16 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 13 17 pub fn sync_cursor_from_dom( 14 - document: &mut Signal<EditorDocument>, 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, 15 31 editor_id: &str, 16 32 paragraphs: &[ParagraphRender], 33 + direction_hint: Option<SnapDirection>, 17 34 ) { 18 35 use wasm_bindgen::JsCast; 19 36 ··· 61 78 &anchor_node, 62 79 anchor_offset, 63 80 paragraphs, 81 + direction_hint, 64 82 ); 65 83 let focus_rope = dom_position_to_rope_offset( 66 84 &dom_document, ··· 68 86 &focus_node, 69 87 focus_offset, 70 88 paragraphs, 89 + direction_hint, 71 90 ); 72 91 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"); 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); 90 104 } 91 105 } 92 - }); 106 + _ => { 107 + tracing::warn!("Could not map DOM selection to rope offsets"); 108 + } 109 + } 93 110 } 94 111 95 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. 96 116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 97 117 fn dom_position_to_rope_offset( 98 118 dom_document: &web_sys::Document, ··· 100 120 node: &web_sys::Node, 101 121 offset_in_text_node: usize, 102 122 paragraphs: &[ParagraphRender], 123 + direction_hint: Option<SnapDirection>, 103 124 ) -> Option<usize> { 104 125 use wasm_bindgen::JsCast; 105 126 ··· 170 191 && utf16_offset_in_container <= mapping_end 171 192 { 172 193 let offset_in_mapping = utf16_offset_in_container - mapping_start; 173 - return Some(mapping.char_range.start + offset_in_mapping); 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); 174 210 } 175 211 } 176 212 } 177 213 } 178 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 + 179 223 None 180 224 } 181 225 182 226 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 183 227 pub fn sync_cursor_from_dom( 184 - _document: &mut Signal<EditorDocument>, 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, 185 238 _editor_id: &str, 186 239 _paragraphs: &[ParagraphRender], 240 + _direction_hint: Option<SnapDirection>, 187 241 ) { 188 242 // No-op on non-wasm 189 243 } ··· 265 319 } 266 320 267 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 + } 268 326 for idx in new_paragraphs.len()..old_paragraphs.len() { 269 327 let para_id = format!("para-{}", idx); 270 328 if let Some(elem) = document.get_element_by_id(&para_id) {
+37 -36
crates/weaver-app/src/components/editor/formatting.rs
··· 1 1 //! Formatting actions and utilities for applying markdown formatting. 2 2 3 - use super::input::{ListContext, detect_list_context, find_line_end}; 4 - 5 3 use super::document::EditorDocument; 4 + use super::input::{ListContext, detect_list_context, find_line_end}; 5 + use dioxus::prelude::*; 6 6 7 7 /// Formatting actions available in the editor. 8 8 #[derive(Clone, Debug, PartialEq)] ··· 59 59 /// 60 60 /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 61 61 pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { 62 - let (start, end) = if let Some(sel) = doc.selection { 62 + let cursor_offset = doc.cursor.read().offset; 63 + let (start, end) = if let Some(sel) = *doc.selection.read() { 63 64 // Use selection 64 65 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 65 66 } else { 66 67 // Expand to word 67 - find_word_boundaries(doc.loro_text(), doc.cursor.offset) 68 + find_word_boundaries(doc.loro_text(), cursor_offset) 68 69 }; 69 70 70 71 match action { ··· 72 73 // Insert end marker first so start position stays valid 73 74 let _ = doc.insert_tracked(end, "**"); 74 75 let _ = doc.insert_tracked(start, "**"); 75 - doc.cursor.offset = end + 4; 76 - doc.selection = None; 76 + doc.cursor.write().offset = end + 4; 77 + doc.selection.set(None); 77 78 } 78 79 FormatAction::Italic => { 79 80 let _ = doc.insert_tracked(end, "*"); 80 81 let _ = doc.insert_tracked(start, "*"); 81 - doc.cursor.offset = end + 2; 82 - doc.selection = None; 82 + doc.cursor.write().offset = end + 2; 83 + doc.selection.set(None); 83 84 } 84 85 FormatAction::Strikethrough => { 85 86 let _ = doc.insert_tracked(end, "~~"); 86 87 let _ = doc.insert_tracked(start, "~~"); 87 - doc.cursor.offset = end + 4; 88 - doc.selection = None; 88 + doc.cursor.write().offset = end + 4; 89 + doc.selection.set(None); 89 90 } 90 91 FormatAction::Code => { 91 92 let _ = doc.insert_tracked(end, "`"); 92 93 let _ = doc.insert_tracked(start, "`"); 93 - doc.cursor.offset = end + 2; 94 - doc.selection = None; 94 + doc.cursor.write().offset = end + 2; 95 + doc.selection.set(None); 95 96 } 96 97 FormatAction::Link => { 97 98 // Insert [selected text](url) 98 99 let _ = doc.insert_tracked(end, "](url)"); 99 100 let _ = doc.insert_tracked(start, "["); 100 - doc.cursor.offset = end + 8; // Position cursor after ](url) 101 - doc.selection = None; 101 + doc.cursor.write().offset = end + 8; // Position cursor after ](url) 102 + doc.selection.set(None); 102 103 } 103 104 FormatAction::Image => { 104 105 // Insert ![alt text](url) 105 106 let _ = doc.insert_tracked(end, "](url)"); 106 107 let _ = doc.insert_tracked(start, "!["); 107 - doc.cursor.offset = end + 9; 108 - doc.selection = None; 108 + doc.cursor.write().offset = end + 9; 109 + doc.selection.set(None); 109 110 } 110 111 FormatAction::Heading(level) => { 111 112 // Find start of current line 112 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 113 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 113 114 let prefix = "#".repeat(level as usize) + " "; 114 115 let _ = doc.insert_tracked(line_start, &prefix); 115 - doc.cursor.offset += prefix.len(); 116 - doc.selection = None; 116 + doc.cursor.write().offset = cursor_offset + prefix.len(); 117 + doc.selection.set(None); 117 118 } 118 119 FormatAction::BulletList => { 119 - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 120 + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 120 121 let continuation = match ctx { 121 122 ListContext::Unordered { indent, marker } => { 122 123 format!("\n{}{} ", indent, marker) ··· 126 127 } 127 128 }; 128 129 let len = continuation.chars().count(); 129 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 130 - doc.cursor.offset += len; 131 - doc.selection = None; 130 + let _ = doc.insert_tracked(cursor_offset, &continuation); 131 + doc.cursor.write().offset = cursor_offset + len; 132 + doc.selection.set(None); 132 133 } else { 133 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 134 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 134 135 let _ = doc.insert_tracked(line_start, " - "); 135 - doc.cursor.offset += 3; 136 - doc.selection = None; 136 + doc.cursor.write().offset = cursor_offset + 3; 137 + doc.selection.set(None); 137 138 } 138 139 } 139 140 FormatAction::NumberedList => { 140 - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 141 + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 141 142 let continuation = match ctx { 142 143 ListContext::Unordered { .. } => { 143 144 format!("\n\n1. ") ··· 147 148 } 148 149 }; 149 150 let len = continuation.chars().count(); 150 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 151 - doc.cursor.offset += len; 152 - doc.selection = None; 151 + let _ = doc.insert_tracked(cursor_offset, &continuation); 152 + doc.cursor.write().offset = cursor_offset + len; 153 + doc.selection.set(None); 153 154 } else { 154 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 155 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 155 156 let _ = doc.insert_tracked(line_start, "1. "); 156 - doc.cursor.offset += 3; 157 - doc.selection = None; 157 + doc.cursor.write().offset = cursor_offset + 3; 158 + doc.selection.set(None); 158 159 } 159 160 } 160 161 FormatAction::Quote => { 161 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 162 + let line_start = find_line_start(doc.loro_text(), cursor_offset); 162 163 let _ = doc.insert_tracked(line_start, "> "); 163 - doc.cursor.offset += 2; 164 - doc.selection = None; 164 + doc.cursor.write().offset = cursor_offset + 2; 165 + doc.selection.set(None); 165 166 } 166 167 } 167 168 }
+243 -218
crates/weaver-app/src/components/editor/input.rs
··· 6 6 7 7 use super::document::EditorDocument; 8 8 use super::formatting::{self, FormatAction}; 9 + use super::offset_map::SnapDirection; 9 10 10 11 /// Check if we need to intercept this key event. 11 12 /// Returns true for content-modifying operations, false for navigation. ··· 37 38 } 38 39 39 40 /// Handle keyboard events and update document state. 40 - pub fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 41 + pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 41 42 use dioxus::prelude::keyboard_types::Key; 42 43 43 44 let key = evt.key(); 44 45 let mods = evt.modifiers(); 45 46 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) 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 77 63 if let Ok(true) = doc.redo() { 78 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 64 + let max = doc.len_chars(); 65 + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 79 66 } 80 - doc.selection = None; 81 - return; 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 + } 82 73 } 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 - } 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 100 } 101 101 } 102 - return; 103 102 } 104 - _ => {} 103 + return; 105 104 } 105 + _ => {} 106 106 } 107 + } 107 108 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 - } 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, 121 123 } 124 + } 122 125 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 - } 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(); 136 138 } 137 139 } 140 + } 141 + 142 + Key::Backspace => { 143 + // Snap backward after backspace (toward deleted content) 144 + doc.pending_snap.set(Some(SnapDirection::Backward)); 138 145 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); 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); 149 156 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; 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; 154 161 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 - } 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; 163 169 } 170 + } 164 171 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 - } 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; 170 176 } 177 + } 171 178 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 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; 181 185 } 186 + Some('\n') => break, // stop at another newline 187 + _ => break, // stop at actual content 182 188 } 189 + } 183 190 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 - } 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; 194 200 } 195 201 } 202 + } 196 203 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 + 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() { 204 217 // Delete next char 205 - let _ = doc.remove_tracked(doc.cursor.offset, 1); 218 + let _ = doc.remove_tracked(cursor_offset, 1); 206 219 } 207 220 } 221 + } 208 222 209 - // Arrow keys handled by browser, synced in onkeyup 210 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 211 - // Browser handles these naturally 212 - } 223 + // Arrow keys handled by browser, synced in onkeyup 224 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 225 + // Browser handles these naturally 226 + } 213 227 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 - } 228 + Key::Enter => { 229 + // Snap forward after enter (into new paragraph/line) 230 + doc.pending_snap.set(Some(SnapDirection::Forward)); 220 231 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); 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 + } 231 238 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()); 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); 235 250 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 - } 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; 257 262 } else { 258 - // Not in a list - normal paragraph break 259 - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 260 - doc.cursor.offset += 2; 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; 261 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; 262 280 } 281 + } 263 282 264 - // Home/End handled by browser, synced in onkeyup 265 - Key::Home | Key::End => { 266 - // Browser handles these naturally 267 - } 283 + // Home/End handled by browser, synced in onkeyup 284 + Key::Home | Key::End => { 285 + // Browser handles these naturally 286 + } 268 287 269 - _ => {} 270 - } 288 + _ => {} 289 + } 271 290 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 - }); 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 + } 278 301 } 279 302 280 303 /// Handle paste events and insert text at cursor. 281 - pub fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 304 + pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 282 305 evt.prevent_default(); 283 306 284 307 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 297 320 .or_else(|| data_transfer.get_data("text/plain").ok()); 298 321 299 322 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 - } 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 + } 308 330 309 - // Insert pasted text 310 - let _ = doc.insert_tracked(doc.cursor.offset, &text); 311 - doc.cursor.offset += text.chars().count(); 312 - }); 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(); 313 335 } 314 336 } 315 337 } else { ··· 319 341 } 320 342 321 343 /// Handle cut events - extract text, write to clipboard, then delete. 322 - pub fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 344 + pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 323 345 evt.prevent_default(); 324 346 325 347 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 329 351 330 352 let base_evt = evt.as_web_event(); 331 353 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 { 354 + let cut_text = { 355 + let sel = doc.selection.write().take(); 356 + if let Some(sel) = sel { 334 357 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 335 358 if start != end { 336 359 // Extract text and strip zero-width chars ··· 348 371 349 372 // Now delete 350 373 let _ = doc.remove_tracked(start, end.saturating_sub(start)); 351 - doc.cursor.offset = start; 352 - doc.selection = None; 374 + doc.cursor.write().offset = start; 353 375 354 - return Some(clean_text); 376 + Some(clean_text) 377 + } else { 378 + None 355 379 } 380 + } else { 381 + None 356 382 } 357 - None 358 - }); 383 + }; 359 384 360 385 // Async: also write custom MIME type for internal paste detection 361 386 if let Some(text) = cut_text { ··· 375 400 } 376 401 377 402 /// Handle copy events - extract text, clean it up, write to clipboard. 378 - pub fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 403 + pub fn handle_copy(evt: Event<ClipboardData>, doc: &EditorDocument) { 379 404 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 380 405 { 381 406 use dioxus::web::WebEventExt; ··· 383 408 384 409 let base_evt = evt.as_web_event(); 385 410 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 386 - let doc = document.read(); 387 - if let Some(sel) = doc.selection { 411 + let sel = *doc.selection.read(); 412 + if let Some(sel) = sel { 388 413 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 389 414 if start != end { 390 415 // Extract text ··· 419 444 420 445 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 421 446 { 422 - let _ = (evt, document); // suppress unused warnings 447 + let _ = (evt, doc); // suppress unused warnings 423 448 } 424 449 } 425 450
+246
crates/weaver-app/src/components/editor/offset_map.rs
··· 153 153 Some((mapping, should_snap)) 154 154 } 155 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 + 156 292 #[cfg(test)] 157 293 mod tests { 158 294 use super::*; ··· 281 417 assert!(mapping.contains_char(12)); 282 418 assert!(mapping.contains_char(14)); 283 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()); 284 530 } 285 531 }
+11 -15
crates/weaver-app/src/components/editor/publish.rs
··· 213 213 /// Props for the publish button component. 214 214 #[derive(Props, Clone, PartialEq)] 215 215 pub struct PublishButtonProps { 216 - /// The editor document signal 217 - pub document: Signal<EditorDocument>, 216 + /// The editor document 217 + pub document: EditorDocument, 218 218 /// Storage key for the draft 219 219 pub draft_key: String, 220 220 } ··· 233 233 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 234 234 235 235 let is_authenticated = auth_state.read().is_authenticated(); 236 - let doc = props.document; 236 + let doc = props.document.clone(); 237 237 let draft_key = props.draft_key.clone(); 238 238 239 239 // Check if we're editing an existing entry 240 - let is_editing_existing = doc().entry_uri().is_some(); 240 + let is_editing_existing = doc.entry_uri().is_some(); 241 241 242 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 - }; 243 + let can_publish = !doc.title().trim().is_empty() && !doc.content().trim().is_empty(); 247 244 248 245 let open_dialog = move |_| { 249 246 error_message.set(None); ··· 256 253 }; 257 254 258 255 let draft_key_clone = draft_key.clone(); 256 + let doc_for_publish = doc.clone(); 259 257 let do_publish = move |_| { 260 258 let fetcher = fetcher.clone(); 261 259 let draft_key = draft_key_clone.clone(); 260 + let doc_snapshot = doc_for_publish.clone(); 262 261 let notebook = if use_notebook() { 263 262 Some(notebook_title()) 264 263 } else { ··· 268 267 spawn(async move { 269 268 is_publishing.set(true); 270 269 error_message.set(None); 271 - 272 - // Get document snapshot for publishing 273 - let doc_snapshot = doc(); 274 270 275 271 match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { 276 272 Ok(result) => { ··· 358 354 } 359 355 360 356 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(\", \")}" } 357 + p { "Title: {doc.title()}" } 358 + p { "Path: {doc.path()}" } 359 + if !doc.tags().is_empty() { 360 + p { "Tags: {doc.tags().join(\", \")}" } 365 361 } 366 362 } 367 363
+3 -11
crates/weaver-app/src/components/editor/render.rs
··· 85 85 86 86 let mut adjusted_syntax = cached.syntax_spans.clone(); 87 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 - } 88 + span.adjust_positions(char_delta); 95 89 } 96 90 97 91 ParagraphRender { ··· 284 278 285 279 let mut adjusted_syntax = cached.syntax_spans.clone(); 286 280 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; 281 + span.adjust_positions(char_delta); 289 282 } 290 283 291 284 (cached.html.clone(), adjusted_map, adjusted_syntax) ··· 356 349 mapping.char_range.end += para_char_start; 357 350 } 358 351 for span in &mut syntax_spans { 359 - span.char_range.start += para_char_start; 360 - span.char_range.end += para_char_start; 352 + span.adjust_positions(para_char_start as isize); 361 353 } 362 354 363 355 (output, offset_map, syntax_spans)
+3 -2
crates/weaver-app/src/components/editor/storage.rs
··· 9 9 10 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 11 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 12 + use dioxus::prelude::*; 12 13 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 14 use gloo_storage::{LocalStorage, Storage}; 14 15 use jacquard::IntoStatic; ··· 86 87 title: doc.title(), 87 88 snapshot: snapshot_b64, 88 89 cursor: doc.loro_cursor().cloned(), 89 - cursor_offset: doc.cursor.offset, 90 + cursor_offset: doc.cursor.read().offset, 90 91 editing_uri: doc.entry_uri().map(|u| u.to_string()), 91 92 }; 92 93 LocalStorage::set(storage_key(key), &snapshot) ··· 129 130 130 131 // Fallback: create new doc from text content 131 132 let mut doc = EditorDocument::new(snapshot.content); 132 - doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); 133 + doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); 133 134 doc.sync_loro_cursor(); 134 135 doc.set_entry_uri(entry_uri); 135 136 Some(doc)
+26 -10
crates/weaver-app/src/components/editor/visibility.rs
··· 198 198 syntax_spans: &[SyntaxSpanInfo], 199 199 paragraphs: &[ParagraphRender], 200 200 ) { 201 + use wasm_bindgen::JsCast; 202 + 201 203 let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 202 204 203 205 let Some(window) = web_sys::window() else { ··· 207 209 return; 208 210 }; 209 211 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 - } 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"); 220 236 } 221 237 } 222 238 }
+309 -67
crates/weaver-app/src/components/editor/writer.rs
··· 56 56 pub formatted_range: Option<Range<usize>>, 57 57 } 58 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 + 59 75 /// Classify syntax text as inline or block level 60 76 fn classify_syntax(text: &str) -> SyntaxType { 61 77 let trimmed = text.trim_start(); ··· 838 854 // the closing syntax must be emitted AFTER the closing HTML tag, not before. 839 855 // Otherwise the closing `**` span ends up INSIDE the <strong> element. 840 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. 841 858 use markdown_weaver::TagEnd; 842 - let is_inline_format_end = matches!( 859 + let is_self_handled_end = matches!( 843 860 &event, 844 - Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) 861 + Event::End( 862 + TagEnd::Strong 863 + | TagEnd::Emphasis 864 + | TagEnd::Strikethrough 865 + | TagEnd::Image 866 + | TagEnd::Embed 867 + ) 845 868 ); 846 869 847 - if matches!(&event, Event::End(_)) && !is_inline_format_end { 870 + if matches!(&event, Event::End(_)) && !is_self_handled_end { 848 871 // Emit gap from last_byte_offset to range.end 849 872 self.emit_gap_before(range.end)?; 850 873 } else if !matches!(&event, Event::End(_)) { ··· 976 999 Ok(()) 977 1000 } 978 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 + 979 1021 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> { 980 1022 use Event::*; 981 1023 ··· 1026 1068 } 1027 1069 } 1028 1070 Code(text) => { 1029 - let char_start = self.last_char_offset; 1071 + let format_start = self.last_char_offset; 1030 1072 let raw_text = &self.source[range.clone()]; 1031 1073 1032 - // Emit opening backtick and track it 1033 - if raw_text.starts_with('`') { 1074 + // Track opening span index so we can set formatted_range later 1075 + let opening_span_idx = if raw_text.starts_with('`') { 1034 1076 let syn_id = self.gen_syn_id(); 1077 + let char_start = self.last_char_offset; 1035 1078 let backtick_char_end = char_start + 1; 1036 1079 write!( 1037 1080 &mut self.writer, ··· 1042 1085 syn_id, 1043 1086 char_range: char_start..backtick_char_end, 1044 1087 syntax_type: SyntaxType::Inline, 1045 - formatted_range: None, 1088 + formatted_range: None, // Set after we know the full range 1046 1089 }); 1047 1090 self.last_char_offset += 1; 1048 - } 1091 + Some(self.syntax_spans.len() - 1) 1092 + } else { 1093 + None 1094 + }; 1049 1095 1050 1096 self.write("<code>")?; 1051 1097 ··· 1070 1116 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">`</span>", 1071 1117 syn_id, backtick_char_start, backtick_char_end 1072 1118 )?; 1119 + 1120 + // Now we know the full formatted range 1121 + let formatted_range = format_start..backtick_char_end; 1122 + 1073 1123 self.syntax_spans.push(SyntaxSpanInfo { 1074 1124 syn_id, 1075 1125 char_range: backtick_char_start..backtick_char_end, 1076 1126 syntax_type: SyntaxType::Inline, 1077 - formatted_range: None, 1127 + formatted_range: Some(formatted_range.clone()), 1078 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 + 1079 1135 self.last_char_offset += 1; 1080 1136 } 1081 1137 } 1082 1138 InlineMath(text) => { 1139 + let format_start = self.last_char_offset; 1083 1140 let raw_text = &self.source[range.clone()]; 1084 1141 1085 - // Emit opening $ and track it 1086 - if raw_text.starts_with('$') { 1142 + // Track opening span index so we can set formatted_range later 1143 + let opening_span_idx = if raw_text.starts_with('$') { 1087 1144 let syn_id = self.gen_syn_id(); 1088 1145 let char_start = self.last_char_offset; 1089 1146 let char_end = char_start + 1; ··· 1096 1153 syn_id, 1097 1154 char_range: char_start..char_end, 1098 1155 syntax_type: SyntaxType::Inline, 1099 - formatted_range: None, 1156 + formatted_range: None, // Set after we know the full range 1100 1157 }); 1101 1158 self.last_char_offset += 1; 1102 - } 1159 + Some(self.syntax_spans.len() - 1) 1160 + } else { 1161 + None 1162 + }; 1103 1163 1104 1164 self.write(r#"<span class="math math-inline">"#)?; 1105 1165 let text_char_len = text.chars().count(); ··· 1117 1177 "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">$</span>", 1118 1178 syn_id, char_start, char_end 1119 1179 )?; 1180 + 1181 + // Now we know the full formatted range 1182 + let formatted_range = format_start..char_end; 1183 + 1120 1184 self.syntax_spans.push(SyntaxSpanInfo { 1121 1185 syn_id, 1122 1186 char_range: char_start..char_end, 1123 1187 syntax_type: SyntaxType::Inline, 1124 - formatted_range: None, 1188 + formatted_range: Some(formatted_range.clone()), 1125 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 + 1126 1196 self.last_char_offset += 1; 1127 1197 } 1128 1198 } ··· 1377 1447 None 1378 1448 } 1379 1449 } 1380 - Tag::Link { .. } => { 1381 - if raw_text.starts_with('[') { 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('[') { 1382 1458 Some("[") 1383 1459 } else { 1384 1460 None 1385 1461 } 1386 1462 } 1463 + // Note: Tag::Image and Tag::Embed handle their own syntax spans 1464 + // in their respective handlers, so don't emit here 1387 1465 _ => None, 1388 1466 }; 1389 1467 ··· 1425 1503 let byte_end = range.start + syntax_byte_len; 1426 1504 self.record_mapping(byte_start..byte_end, char_start..char_end); 1427 1505 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) { 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 + ) { 1431 1511 self.pending_inline_formats.push((syn_id, char_start)); 1432 1512 } 1433 1513 ··· 1911 1991 attrs, 1912 1992 .. 1913 1993 } => { 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; 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; 1921 1999 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 - )?; 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 + }; 1927 2012 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 - } 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 + ); 1935 2043 } 1936 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) 1937 2094 self.write("<img src=\"")?; 1938 - // Try to resolve image URL via resolver, fall back to original 1939 2095 let resolved_url = self 1940 2096 .image_resolver 1941 2097 .as_ref() ··· 1946 2102 escape_href(&mut self.writer, &dest_url)?; 1947 2103 } 1948 2104 self.write("\" alt=\"")?; 1949 - // Consume text events for alt attribute 1950 - self.raw_text()?; 2105 + escape_html(&mut self.writer, alt_text)?; 1951 2106 self.write("\"")?; 1952 2107 if !title.is_empty() { 1953 2108 self.write(" title=\"")?; ··· 1975 2130 } 1976 2131 self.write(" />")?; 1977 2132 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; 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(); 1987 2136 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>")?; 2137 + // Update offsets 2138 + self.last_char_offset = closing_char_end; 2139 + self.last_byte_offset = range.end; 1995 2140 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 2141 Ok(()) 2005 2142 } 2006 2143 Tag::Embed { ··· 2009 2146 title, 2010 2147 id, 2011 2148 attrs, 2012 - } => self.write_embed(embed_type, dest_url, title, id, attrs), 2149 + } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2013 2150 Tag::WeaverBlock(_, _) => { 2014 2151 self.in_non_writing_block = true; 2015 2152 Ok(()) ··· 2305 2442 self.finalize_paired_inline_format(); 2306 2443 Ok(()) 2307 2444 } 2308 - TagEnd::Link => self.write("</a>"), 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 + } 2309 2476 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 2310 2477 TagEnd::Embed => Ok(()), 2311 2478 TagEnd::WeaverBlock(_) => { ··· 2340 2507 { 2341 2508 fn write_embed( 2342 2509 &mut self, 2510 + range: Range<usize>, 2343 2511 embed_type: EmbedType, 2344 2512 dest_url: CowStr<'_>, 2345 2513 title: CowStr<'_>, 2346 2514 id: CowStr<'_>, 2347 2515 attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 2348 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 + 2349 2555 // Try to get content from attributes first 2350 2556 let content_from_attrs = if let Some(ref attrs) = attrs { 2351 2557 attrs ··· 2413 2619 } 2414 2620 self.write("></iframe>")?; 2415 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 + 2416 2658 Ok(()) 2417 2659 } 2418 2660 }