couple major editor bugs (spaces and paragraph joins) fixed, decent perf boost in this + prev

Orual 119371ff 6483830a

+521 -112
+25 -2
crates/weaver-app/src/components/editor/beforeinput.rs
··· 553 553 // Log raw DOM position for debugging 554 554 let start_node_name = start_container.node_name(); 555 555 let start_text = start_container.text_content().unwrap_or_default(); 556 + let end_node_name = end_container.node_name(); 557 + let end_text = end_container.text_content().unwrap_or_default(); 558 + 559 + // Check if containers are the editor element itself 560 + let start_is_editor = start_container.dyn_ref::<web_sys::Element>() 561 + .map(|e| e == &editor_element) 562 + .unwrap_or(false); 563 + let end_is_editor = end_container.dyn_ref::<web_sys::Element>() 564 + .map(|e| e == &editor_element) 565 + .unwrap_or(false); 566 + 556 567 tracing::trace!( 557 568 start_node_name = %start_node_name, 558 569 start_offset, 559 - start_text_preview = %start_text.chars().take(20).collect::<String>(), 560 - "get_target_range_from_event: raw DOM position" 570 + start_is_editor, 571 + start_text_preview = %start_text.chars().take(30).collect::<String>(), 572 + end_node_name = %end_node_name, 573 + end_offset, 574 + end_is_editor, 575 + end_text_preview = %end_text.chars().take(30).collect::<String>(), 576 + collapsed = static_range.collapsed(), 577 + "get_target_range_from_event: raw StaticRange from browser" 561 578 ); 562 579 563 580 let start = dom_position_to_text_offset( ··· 576 593 paragraphs, 577 594 None, 578 595 )?; 596 + 597 + tracing::trace!( 598 + start, 599 + end, 600 + "get_target_range_from_event: computed text offsets" 601 + ); 579 602 580 603 Some(Range::new(start, end)) 581 604 }
+2 -2
crates/weaver-app/src/components/editor/component.rs
··· 465 465 let resolver = image_resolver(); 466 466 let resolved = resolved_content(); 467 467 468 - tracing::debug!( 468 + tracing::trace!( 469 469 "Rendering with {} pre-resolved embeds", 470 470 resolved.embed_content.len() 471 471 ); ··· 780 780 &snapshot, 781 781 ); 782 782 let write_ms = crate::perf::now() - write_start; 783 - tracing::debug!(export_ms, encode_ms, write_ms, "worker autosave complete"); 783 + tracing::trace!(export_ms, encode_ms, write_ms, "worker autosave complete"); 784 784 } 785 785 WorkerOutput::Error { message } => { 786 786 tracing::error!("Worker error: {}", message);
+124 -28
crates/weaver-app/src/components/editor/dom_sync.rs
··· 135 135 136 136 // Find the containing element with a node ID (walk up from text node) 137 137 let mut current_node = node.clone(); 138 + let mut walked_from: Option<web_sys::Node> = None; // Track the child we walked up from 138 139 let node_id = loop { 140 + let node_name = current_node.node_name(); 141 + let node_id_attr = current_node 142 + .dyn_ref::<web_sys::Element>() 143 + .and_then(|e| e.get_attribute("id")); 144 + tracing::trace!( 145 + node_name = %node_name, 146 + node_id_attr = ?node_id_attr, 147 + "dom_position_to_text_offset: walk-up iteration" 148 + ); 149 + 139 150 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 140 151 if element == editor_element { 141 - // Selection is on the editor container itself (e.g., Cmd+A select all) 152 + // Selection is on the editor container itself 153 + // 154 + // IMPORTANT: If we WALKED UP to the editor from a descendant, 155 + // offset_in_text_node is the offset within that descendant, NOT the 156 + // child index in the editor. We need to find which paragraph contains 157 + // the node we walked from. 158 + if let Some(ref walked_node) = walked_from { 159 + // We walked up from a descendant - find which paragraph it belongs to 160 + tracing::debug!( 161 + walked_from_node_name = %walked_node.node_name(), 162 + "dom_position_to_text_offset: walked up to editor from descendant" 163 + ); 164 + 165 + // Find paragraph containing this node by checking paragraph wrapper divs 166 + for (idx, para) in paragraphs.iter().enumerate() { 167 + if let Some(para_elem) = dom_document.get_element_by_id(&para.id) { 168 + let para_node: &web_sys::Node = para_elem.as_ref(); 169 + if para_node.contains(Some(walked_node)) { 170 + // Found the paragraph - return its start 171 + tracing::trace!( 172 + para_id = %para.id, 173 + para_idx = idx, 174 + char_start = para.char_range.start, 175 + "dom_position_to_text_offset: found containing paragraph" 176 + ); 177 + return Some(para.char_range.start); 178 + } 179 + } 180 + } 181 + // Couldn't find containing paragraph, fall through 182 + tracing::warn!("dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"); 183 + break None; 184 + } 185 + 186 + // Selection is directly on the editor container (e.g., Cmd+A select all) 142 187 // Return boundary position based on offset: 143 188 // offset 0 = start of editor, offset == child count = end of editor 144 189 let child_count = editor_element.child_element_count() as usize; ··· 158 203 if let Some(id) = id { 159 204 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs 160 205 let is_node_id = id.starts_with('n') || id.contains("-n"); 206 + tracing::trace!( 207 + id = %id, 208 + is_node_id, 209 + starts_with_n = id.starts_with('n'), 210 + contains_dash_n = id.contains("-n"), 211 + "dom_position_to_text_offset: checking ID pattern" 212 + ); 161 213 if is_node_id { 162 214 break Some(id); 163 215 } 164 216 } 165 217 } 166 218 219 + walked_from = Some(current_node.clone()); 167 220 current_node = current_node.parent_node()?; 168 221 }; 169 222 ··· 178 231 // Skip text nodes inside contenteditable="false" elements (like embeds) 179 232 let mut utf16_offset_in_container = 0; 180 233 181 - // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions 182 - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) { 183 - // Track the non-editable element we're inside (if any) 184 - let mut skip_until_exit: Option<web_sys::Element> = None; 234 + // Check if the node IS the container element itself (not a text node descendant) 235 + // In this case, offset_in_text_node is actually a child index, not a character offset 236 + let node_is_container = node 237 + .dyn_ref::<web_sys::Element>() 238 + .map(|e| e == &container) 239 + .unwrap_or(false); 240 + 241 + if node_is_container { 242 + // offset_in_text_node is a child index - count text content up to that child 243 + let child_index = offset_in_text_node; 244 + let children = container.child_nodes(); 245 + let mut text_counted = 0usize; 185 246 186 - while let Ok(Some(dom_node)) = walker.next_node() { 187 - // Check if we've exited the non-editable subtree 188 - if let Some(ref skip_elem) = skip_until_exit { 189 - if !skip_elem.contains(Some(&dom_node)) { 190 - skip_until_exit = None; 247 + for i in 0..child_index.min(children.length() as usize) { 248 + if let Some(child) = children.get(i as u32) { 249 + if let Some(text) = child.text_content() { 250 + text_counted += text.encode_utf16().count(); 191 251 } 192 252 } 253 + } 254 + utf16_offset_in_container = text_counted; 193 255 194 - // Check if entering a non-editable element 195 - if skip_until_exit.is_none() { 196 - if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() { 197 - if element.get_attribute("contenteditable").as_deref() == Some("false") { 198 - skip_until_exit = Some(element.clone()); 199 - continue; 256 + tracing::debug!( 257 + child_index, 258 + utf16_offset = utf16_offset_in_container, 259 + "dom_position_to_text_offset: node is container, using child index" 260 + ); 261 + } else { 262 + // Normal case: node is a text node, walk to find it 263 + // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions 264 + if let Ok(walker) = 265 + dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) 266 + { 267 + // Track the non-editable element we're inside (if any) 268 + let mut skip_until_exit: Option<web_sys::Element> = None; 269 + 270 + while let Ok(Some(dom_node)) = walker.next_node() { 271 + // Check if we've exited the non-editable subtree 272 + if let Some(ref skip_elem) = skip_until_exit { 273 + if !skip_elem.contains(Some(&dom_node)) { 274 + skip_until_exit = None; 200 275 } 201 276 } 202 - } 203 277 204 - // Skip everything inside non-editable regions 205 - if skip_until_exit.is_some() { 206 - continue; 207 - } 278 + // Check if entering a non-editable element 279 + if skip_until_exit.is_none() { 280 + if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() { 281 + if element.get_attribute("contenteditable").as_deref() == Some("false") { 282 + skip_until_exit = Some(element.clone()); 283 + continue; 284 + } 285 + } 286 + } 208 287 209 - // Only process text nodes 210 - if dom_node.node_type() == web_sys::Node::TEXT_NODE { 211 - if &dom_node == node { 212 - utf16_offset_in_container += offset_in_text_node; 213 - break; 288 + // Skip everything inside non-editable regions 289 + if skip_until_exit.is_some() { 290 + continue; 214 291 } 215 292 216 - if let Some(text) = dom_node.text_content() { 217 - utf16_offset_in_container += text.encode_utf16().count(); 293 + // Only process text nodes 294 + if dom_node.node_type() == web_sys::Node::TEXT_NODE { 295 + if &dom_node == node { 296 + utf16_offset_in_container += offset_in_text_node; 297 + break; 298 + } 299 + 300 + if let Some(text) = dom_node.text_content() { 301 + utf16_offset_in_container += text.encode_utf16().count(); 302 + } 218 303 } 219 304 } 220 305 } ··· 248 333 { 249 334 let offset_in_mapping = utf16_offset_in_container - mapping_start; 250 335 let char_offset = mapping.char_range.start + offset_in_mapping; 336 + 337 + tracing::trace!( 338 + node_id = %node_id, 339 + utf16_offset = utf16_offset_in_container, 340 + mapping_start, 341 + mapping_end, 342 + offset_in_mapping, 343 + char_range_start = mapping.char_range.start, 344 + char_offset, 345 + "dom_position_to_text_offset: MATCHED mapping" 346 + ); 251 347 252 348 // Check if this position is valid (not on invisible content) 253 349 if is_valid_cursor_position(&para.offset_map, char_offset) {
+301 -71
crates/weaver-app/src/components/editor/render.rs
··· 224 224 let current_len = text.len_unicode(); 225 225 let current_byte_len = text.len_utf8(); 226 226 227 + // If we have cache but no edit, just return cached data (no re-render needed) 228 + // This happens on cursor position changes, clicks, etc. 229 + if let (Some(c), None) = (cache, edit) { 230 + // Verify cache is still valid (document length matches) 231 + let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); 232 + if cached_len == current_len { 233 + tracing::trace!( 234 + target: "weaver::render", 235 + "no edit, returning cached paragraphs" 236 + ); 237 + let paragraphs: Vec<ParagraphRender> = c 238 + .paragraphs 239 + .iter() 240 + .map(|p| ParagraphRender { 241 + id: p.id.clone(), 242 + byte_range: p.byte_range.clone(), 243 + char_range: p.char_range.clone(), 244 + html: p.html.clone(), 245 + offset_map: p.offset_map.clone(), 246 + syntax_spans: p.syntax_spans.clone(), 247 + source_hash: p.source_hash, 248 + }) 249 + .collect(); 250 + let paragraphs = add_gap_paragraphs(paragraphs, text, &source); 251 + return ( 252 + paragraphs, 253 + c.clone(), 254 + c.paragraphs 255 + .iter() 256 + .flat_map(|p| p.collected_refs.clone()) 257 + .collect(), 258 + ); 259 + } 260 + } 261 + 227 262 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 228 263 229 264 tracing::debug!( ··· 335 370 // Adjust ranges based on position relative to edit 336 371 let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { 337 372 // Before edit - no change 338 - (cached_para.byte_range.clone(), cached_para.char_range.clone()) 373 + ( 374 + cached_para.byte_range.clone(), 375 + cached_para.char_range.clone(), 376 + ) 339 377 } else if cached_para.char_range.start > edit_pos { 340 378 // After edit - shift by delta 341 379 ( ··· 347 385 } else { 348 386 // Contains edit - expand end 349 387 ( 350 - cached_para.byte_range.start..apply_delta(cached_para.byte_range.end, byte_delta), 351 - cached_para.char_range.start..apply_delta(cached_para.char_range.end, char_delta), 388 + cached_para.byte_range.start 389 + ..apply_delta(cached_para.byte_range.end, byte_delta), 390 + cached_para.char_range.start 391 + ..apply_delta(cached_para.char_range.end, char_delta), 352 392 ) 353 393 }; 354 394 ··· 370 410 &para_text, 371 411 parser, 372 412 ) 413 + .with_node_id_prefix(&cached_para.id) 373 414 .with_image_resolver(&resolver) 374 415 .with_embed_provider(resolved_content); 375 416 ··· 380 421 let (html, offset_map, syntax_spans, para_refs) = match writer.run() { 381 422 Ok(result) => { 382 423 // Adjust offsets to be document-absolute 383 - let mut offset_map = result.offset_maps_by_paragraph.into_iter().next().unwrap_or_default(); 424 + let mut offset_map = result 425 + .offset_maps_by_paragraph 426 + .into_iter() 427 + .next() 428 + .unwrap_or_default(); 384 429 for m in &mut offset_map { 385 430 m.char_range.start += char_range.start; 386 431 m.char_range.end += char_range.start; 387 432 m.byte_range.start += byte_range.start; 388 433 m.byte_range.end += byte_range.start; 389 434 } 390 - let mut syntax_spans = result.syntax_spans_by_paragraph.into_iter().next().unwrap_or_default(); 435 + let mut syntax_spans = result 436 + .syntax_spans_by_paragraph 437 + .into_iter() 438 + .next() 439 + .unwrap_or_default(); 391 440 for s in &mut syntax_spans { 392 441 s.adjust_positions(char_range.start as isize); 393 442 } 394 - let para_refs = result.collected_refs_by_paragraph.into_iter().next().unwrap_or_default(); 443 + let para_refs = result 444 + .collected_refs_by_paragraph 445 + .into_iter() 446 + .next() 447 + .unwrap_or_default(); 395 448 let html = result.html_segments.into_iter().next().unwrap_or_default(); 396 449 (html, offset_map, syntax_spans, para_refs) 397 450 } ··· 485 538 } 486 539 487 540 // ============ SLOW PATH ============ 488 - // Full render when boundaries might have changed 541 + // Partial render: reuse cached paragraphs before edit, parse from affected to end 489 542 let render_start = crate::perf::now(); 543 + 544 + // Try partial parse if we have cache and edit info 545 + let (reused_paragraphs, parse_start_byte, parse_start_char) = 546 + if let (Some(c), Some(e)) = (cache, edit) { 547 + // Find the first cached paragraph that contains or is after the edit 548 + let edit_pos = e.edit_char_pos; 549 + let affected_idx = c 550 + .paragraphs 551 + .iter() 552 + .position(|p| p.char_range.end >= edit_pos); 553 + 554 + if let Some(mut idx) = affected_idx { 555 + // If edit is near the start of a paragraph (within first few chars), 556 + // the previous paragraph is also affected (e.g., backspace to join) 557 + const BOUNDARY_SLOP: usize = 3; 558 + let para_start = c.paragraphs[idx].char_range.start; 559 + if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP { 560 + idx -= 1; 561 + } 562 + 563 + if idx > 0 { 564 + // Reuse paragraphs before the affected one 565 + let reused: Vec<_> = c.paragraphs[..idx].to_vec(); 566 + let last_reused = &c.paragraphs[idx - 1]; 567 + tracing::trace!( 568 + reused_count = idx, 569 + parse_start_byte = last_reused.byte_range.end, 570 + parse_start_char = last_reused.char_range.end, 571 + "slow path: partial parse from affected paragraph" 572 + ); 573 + ( 574 + reused, 575 + last_reused.byte_range.end, 576 + last_reused.char_range.end, 577 + ) 578 + } else { 579 + // Edit is in first paragraph, parse everything 580 + (Vec::new(), 0, 0) 581 + } 582 + } else { 583 + // Edit is after all paragraphs (appending), parse from end 584 + if let Some(last) = c.paragraphs.last() { 585 + let reused = c.paragraphs.clone(); 586 + (reused, last.byte_range.end, last.char_range.end) 587 + } else { 588 + (Vec::new(), 0, 0) 589 + } 590 + } 591 + } else { 592 + // No cache or no edit info, parse everything 593 + (Vec::new(), 0, 0) 594 + }; 595 + 596 + // Parse from the start point to end of document 597 + let parse_slice = &source[parse_start_byte..]; 490 598 let parser = 491 - Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); 599 + Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); 492 600 493 601 // Use provided resolver or empty default 494 602 let resolver = image_resolver.cloned().unwrap_or_default(); 495 603 496 - // Build writer with all resolvers 604 + // Create a temporary LoroText for the slice (needed by writer) 605 + let slice_doc = loro::LoroDoc::new(); 606 + let slice_text = slice_doc.get_text("content"); 607 + let _ = slice_text.insert(0, parse_slice); 608 + 609 + // Determine starting paragraph ID for freshly parsed paragraphs 610 + // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML 611 + let reused_count = reused_paragraphs.len(); 612 + 613 + // If reused_count = 0 (full re-render), start from 0 for DOM stability 614 + // Otherwise, use next_para_id to avoid collisions with reused paragraphs 615 + let parsed_para_id_start = if reused_count == 0 { 616 + 0 617 + } else { 618 + cache.map(|c| c.next_para_id).unwrap_or(0) 619 + }; 620 + 621 + tracing::trace!( 622 + parsed_para_id_start, 623 + reused_count, 624 + "slow path: paragraph ID allocation" 625 + ); 626 + 627 + // Find if cursor paragraph is being re-parsed (not reused) 628 + // If so, we want it to keep its cached prefix for DOM/offset_map stability 629 + let cursor_para_override: Option<(usize, String)> = cache.and_then(|c| { 630 + // Find cached paragraph containing cursor 631 + let cached_cursor_idx = c.paragraphs.iter().position(|p| { 632 + p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end 633 + })?; 634 + 635 + // If cursor paragraph is reused (not being re-parsed), no override needed 636 + if cached_cursor_idx < reused_count { 637 + return None; 638 + } 639 + 640 + // Cursor paragraph is being re-parsed - use its cached ID 641 + let cached_para = &c.paragraphs[cached_cursor_idx]; 642 + let parsed_index = cached_cursor_idx - reused_count; 643 + 644 + tracing::trace!( 645 + cached_cursor_idx, 646 + reused_count, 647 + parsed_index, 648 + cached_id = %cached_para.id, 649 + "slow path: cursor paragraph override" 650 + ); 651 + 652 + Some((parsed_index, cached_para.id.clone())) 653 + }); 654 + 655 + // Build writer with all resolvers and auto-incrementing paragraph prefixes 497 656 let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( 498 - &source, 499 - text, 657 + parse_slice, 658 + &slice_text, 500 659 parser, 501 660 ) 661 + .with_auto_incrementing_prefix(parsed_para_id_start) 502 662 .with_image_resolver(&resolver) 503 663 .with_embed_provider(resolved_content); 504 664 665 + // Apply cursor paragraph override if needed 666 + if let Some((idx, ref prefix)) = cursor_para_override { 667 + writer = writer.with_static_prefix_at_index(idx, prefix); 668 + } 669 + 505 670 if let Some(idx) = entry_index { 506 671 writer = writer.with_entry_index(idx); 507 672 } ··· 511 676 Err(_) => return (Vec::new(), RenderCache::default(), vec![]), 512 677 }; 513 678 679 + // Get the final paragraph ID counter from the writer (accounts for all parsed paragraphs) 680 + let parsed_para_count = writer_result.paragraph_ranges.len(); 681 + 514 682 let render_ms = crate::perf::now() - render_start; 515 683 516 - let paragraph_ranges = writer_result.paragraph_ranges.clone(); 684 + // Adjust parsed paragraph ranges to be document-absolute 685 + let parsed_paragraph_ranges: Vec<_> = writer_result 686 + .paragraph_ranges 687 + .iter() 688 + .map(|(byte_range, char_range)| { 689 + ( 690 + (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte), 691 + (char_range.start + parse_start_char)..(char_range.end + parse_start_char), 692 + ) 693 + }) 694 + .collect(); 517 695 518 - // Log discovered paragraphs 519 - for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 520 - let preview: String = text_slice_to_string(text, char_range.clone()) 521 - .chars() 522 - .take(30) 523 - .collect(); 524 - tracing::trace!( 525 - target: "weaver::render", 526 - para_idx = i, 527 - char_range = ?char_range, 528 - byte_range = ?byte_range, 529 - preview = %preview, 530 - "paragraph boundary" 531 - ); 696 + // Combine reused ranges with parsed ranges 697 + let paragraph_ranges: Vec<_> = reused_paragraphs 698 + .iter() 699 + .map(|p| (p.byte_range.clone(), p.char_range.clone())) 700 + .chain(parsed_paragraph_ranges.clone()) 701 + .collect(); 702 + 703 + // Log discovered paragraphs (only if trace is enabled to avoid wasted work) 704 + if tracing::enabled!(tracing::Level::TRACE) { 705 + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 706 + let preview: String = text_slice_to_string(text, char_range.clone()) 707 + .chars() 708 + .take(30) 709 + .collect(); 710 + tracing::trace!( 711 + target: "weaver::render", 712 + para_idx = i, 713 + char_range = ?char_range, 714 + byte_range = ?byte_range, 715 + preview = %preview, 716 + "paragraph boundary" 717 + ); 718 + } 532 719 } 533 720 534 - // Build paragraphs from full render segments 721 + // Build paragraphs from render results 535 722 let build_start = crate::perf::now(); 536 723 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 537 724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 538 725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 539 - let mut next_para_id = cache.map(|c| c.next_para_id).unwrap_or(0); 726 + // next_para_id must account for all IDs allocated by the writer 727 + let mut next_para_id = parsed_para_id_start + parsed_para_count; 728 + let reused_count = reused_paragraphs.len(); 540 729 541 730 // Find which paragraph contains cursor (for stable ID assignment) 542 731 let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { 543 732 char_range.start <= cursor_offset && cursor_offset <= char_range.end 544 733 }); 545 734 546 - tracing::debug!( 735 + tracing::trace!( 547 736 cursor_offset, 548 737 ?cursor_para_idx, 549 738 edit_char_pos = ?edit.map(|e| e.edit_char_pos), 739 + reused_count, 740 + parsed_count = parsed_paragraph_ranges.len(), 550 741 "ID assignment: cursor and edit info" 551 742 ); 552 743 ··· 560 751 let source_hash = hash_source(&para_source); 561 752 let is_cursor_para = Some(idx) == cursor_para_idx; 562 753 563 - // ID assignment: cursor paragraph matches by edit position, others match by hash 564 - let para_id = if is_cursor_para { 565 - let edit_in_this_para = edit 566 - .map(|e| char_range.start <= e.edit_char_pos && e.edit_char_pos <= char_range.end) 567 - .unwrap_or(false); 568 - let lookup_pos = if edit_in_this_para { 569 - edit.map(|e| e.edit_char_pos).unwrap_or(cursor_offset) 754 + // Check if this is a reused paragraph or a freshly parsed one 755 + let is_reused = idx < reused_count; 756 + 757 + // ID assignment depends on whether this is reused or freshly parsed 758 + let para_id = if is_reused { 759 + // Reused paragraph: keep its existing ID (HTML already has matching prefixes) 760 + reused_paragraphs[idx].id.clone() 761 + } else { 762 + // Freshly parsed: ID MUST match what the writer used for node ID prefixes 763 + let parsed_idx = idx - reused_count; 764 + 765 + // Check if this is the cursor paragraph with an override 766 + let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override { 767 + if parsed_idx == override_idx { 768 + // Use the override prefix (matches what writer used) 769 + override_prefix.clone() 770 + } else { 771 + // Use auto-incremented ID (matches what writer used) 772 + make_paragraph_id(parsed_para_id_start + parsed_idx) 773 + } 570 774 } else { 571 - cursor_offset 775 + // No override, use auto-incremented ID 776 + make_paragraph_id(parsed_para_id_start + parsed_idx) 572 777 }; 573 - let found_cached = cache.and_then(|c| { 574 - c.paragraphs 575 - .iter() 576 - .find(|p| p.char_range.start <= lookup_pos && lookup_pos <= p.char_range.end) 577 - }); 578 778 579 - if let Some(cached) = found_cached { 580 - tracing::debug!( 581 - lookup_pos, 582 - edit_in_this_para, 583 - cursor_offset, 584 - cached_id = %cached.id, 585 - cached_range = ?cached.char_range, 586 - "cursor para: reusing cached ID" 779 + if idx < 3 || is_cursor_para { 780 + tracing::trace!( 781 + idx, 782 + parsed_idx, 783 + is_cursor_para, 784 + para_id = %id, 785 + "slow path: assigned paragraph ID" 587 786 ); 588 - cached.id.clone() 589 - } else { 590 - let id = make_paragraph_id(next_para_id); 591 - next_para_id += 1; 592 - id 593 787 } 788 + 789 + id 790 + }; 791 + 792 + // Get data either from reused cache or from fresh parse 793 + let (html, offset_map, syntax_spans, para_refs) = if is_reused { 794 + // Reused from cache - take directly 795 + let reused = &reused_paragraphs[idx]; 796 + ( 797 + reused.html.clone(), 798 + reused.offset_map.clone(), 799 + reused.syntax_spans.clone(), 800 + reused.collected_refs.clone(), 801 + ) 594 802 } else { 595 - // Non-cursor: match by content hash 596 - cached_by_hash 597 - .get(&source_hash) 598 - .map(|p| p.id.clone()) 599 - .unwrap_or_else(|| { 600 - let id = make_paragraph_id(next_para_id); 601 - next_para_id += 1; 602 - id 603 - }) 604 - }; 803 + // Freshly parsed - get from writer_result with offset adjustment 804 + let parsed_idx = idx - reused_count; 805 + let html = writer_result 806 + .html_segments 807 + .get(parsed_idx) 808 + .cloned() 809 + .unwrap_or_default(); 810 + 811 + // Adjust offset maps to document-absolute positions 812 + let mut offset_map = writer_result 813 + .offset_maps_by_paragraph 814 + .get(parsed_idx) 815 + .cloned() 816 + .unwrap_or_default(); 817 + for m in &mut offset_map { 818 + m.char_range.start += parse_start_char; 819 + m.char_range.end += parse_start_char; 820 + m.byte_range.start += parse_start_byte; 821 + m.byte_range.end += parse_start_byte; 822 + } 605 823 606 - // Get data from full render segments 607 - let html = writer_result.html_segments.get(idx).cloned().unwrap_or_default(); 608 - let offset_map = writer_result.offset_maps_by_paragraph.get(idx).cloned().unwrap_or_default(); 609 - let syntax_spans = writer_result.syntax_spans_by_paragraph.get(idx).cloned().unwrap_or_default(); 610 - let para_refs = writer_result.collected_refs_by_paragraph.get(idx).cloned().unwrap_or_default(); 824 + // Adjust syntax spans to document-absolute positions 825 + let mut syntax_spans = writer_result 826 + .syntax_spans_by_paragraph 827 + .get(parsed_idx) 828 + .cloned() 829 + .unwrap_or_default(); 830 + for s in &mut syntax_spans { 831 + s.adjust_positions(parse_start_char as isize); 832 + } 833 + 834 + let para_refs = writer_result 835 + .collected_refs_by_paragraph 836 + .get(parsed_idx) 837 + .cloned() 838 + .unwrap_or_default(); 839 + (html, offset_map, syntax_spans, para_refs) 840 + }; 611 841 612 842 all_refs.extend(para_refs.clone()); 613 843 ··· 635 865 } 636 866 637 867 let build_ms = crate::perf::now() - build_start; 638 - tracing::debug!( 868 + tracing::trace!( 639 869 render_ms, 640 870 build_ms, 641 871 paragraphs = paragraph_ranges.len(),
+1 -1
crates/weaver-app/src/components/editor/worker.rs
··· 227 227 RaceResult::CoordinatorMsg(None) => break, // Coordinator closed 228 228 RaceResult::CoordinatorMsg(Some(msg)) => { 229 229 // Fall through to message handling below 230 - tracing::debug!(?msg, "Worker: received message"); 230 + tracing::trace!(?msg, "Worker: received message"); 231 231 match msg { 232 232 WorkerInput::Init { 233 233 snapshot,
+66 -6
crates/weaver-app/src/components/editor/writer.rs
··· 416 416 417 417 // Offset mapping tracking - current paragraph 418 418 offset_maps: Vec<OffsetMapping>, 419 - node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 419 + node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 420 + auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value 421 + static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index 422 + current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) 420 423 next_node_id: usize, 421 424 current_node_id: Option<String>, // node ID for current text container 422 425 current_node_char_offset: usize, // UTF-16 offset within current node ··· 498 501 table_start_offset: None, 499 502 offset_maps: Vec::new(), 500 503 node_id_prefix: None, 504 + auto_increment_prefix: None, 505 + static_prefix_override: None, 506 + current_paragraph_index: 0, 501 507 next_node_id: node_id_offset, 502 508 current_node_id: None, 503 509 current_node_char_offset: 0, ··· 554 560 table_start_offset: self.table_start_offset, 555 561 offset_maps: self.offset_maps, 556 562 node_id_prefix: self.node_id_prefix, 563 + auto_increment_prefix: self.auto_increment_prefix, 564 + static_prefix_override: self.static_prefix_override, 565 + current_paragraph_index: self.current_paragraph_index, 557 566 next_node_id: self.next_node_id, 558 567 current_node_id: self.current_node_id, 559 568 current_node_char_offset: self.current_node_char_offset, ··· 581 590 582 591 /// Set a prefix for node IDs (typically the paragraph ID). 583 592 /// This makes node IDs paragraph-scoped and stable across re-renders. 593 + /// Use this for single-paragraph renders where the paragraph ID is known. 584 594 pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { 585 595 self.node_id_prefix = Some(prefix.to_string()); 586 596 self.next_node_id = 0; // Reset counter since each paragraph is independent 587 597 self 588 598 } 589 599 600 + /// Enable auto-incrementing paragraph prefixes for multi-paragraph renders. 601 + /// Each paragraph gets prefix "p-{N}" where N starts at `start_id` and increments. 602 + /// Node IDs reset to 0 for each paragraph, giving "p-{N}-n0", "p-{N}-n1", etc. 603 + pub fn with_auto_incrementing_prefix(mut self, start_id: usize) -> Self { 604 + self.auto_increment_prefix = Some(start_id); 605 + self.node_id_prefix = Some(format!("p-{}", start_id)); 606 + self.next_node_id = 0; 607 + self 608 + } 609 + 610 + /// Get the next paragraph ID that would be assigned (for tracking allocations). 611 + pub fn next_paragraph_id(&self) -> Option<usize> { 612 + self.auto_increment_prefix 613 + } 614 + 615 + /// Override the auto-incrementing prefix for a specific paragraph index. 616 + /// Use this when you need a specific paragraph (e.g., cursor paragraph) to have 617 + /// a stable prefix for DOM/offset_map compatibility. 618 + pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self { 619 + self.static_prefix_override = Some((index, prefix.to_string())); 620 + // If this is for paragraph 0, apply it immediately 621 + if index == 0 { 622 + self.node_id_prefix = Some(prefix.to_string()); 623 + self.next_node_id = 0; 624 + } 625 + self 626 + } 627 + 590 628 /// Finalize the current paragraph: move accumulated items to per-para vectors, 591 629 /// start a new output segment for the next paragraph. 592 630 fn finalize_paragraph(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { ··· 600 638 .push(std::mem::take(&mut self.syntax_spans)); 601 639 self.refs_by_para 602 640 .push(std::mem::take(&mut self.ref_collector.refs)); 641 + 642 + // Advance to next paragraph 643 + self.current_paragraph_index += 1; 644 + 645 + // Determine prefix for next paragraph 646 + if let Some((override_idx, ref override_prefix)) = self.static_prefix_override { 647 + if self.current_paragraph_index == override_idx { 648 + // Use the static override for this paragraph 649 + self.node_id_prefix = Some(override_prefix.clone()); 650 + self.next_node_id = 0; 651 + } else if let Some(ref mut current_id) = self.auto_increment_prefix { 652 + // Use auto-increment (skip the override index to avoid collision) 653 + *current_id += 1; 654 + self.node_id_prefix = Some(format!("p-{}", *current_id)); 655 + self.next_node_id = 0; 656 + } 657 + } else if let Some(ref mut current_id) = self.auto_increment_prefix { 658 + // Normal auto-increment 659 + *current_id += 1; 660 + self.node_id_prefix = Some(format!("p-{}", *current_id)); 661 + self.next_node_id = 0; 662 + } 603 663 604 664 // Start new output segment for next paragraph 605 665 self.writer.new_segment(); ··· 692 752 693 753 escape_html(&mut self.writer, syntax)?; 694 754 755 + // Record offset mapping BEFORE end_node (which clears current_node_id) 756 + self.record_mapping(range.clone(), char_start..char_end); 757 + self.last_char_offset = char_end; 758 + self.last_byte_offset = range.end; 759 + 695 760 if created_node { 696 761 self.write("</span>")?; 697 762 self.end_node(); 698 763 } 699 - 700 - // Record offset mapping but no syntax span info 701 - self.record_mapping(range.clone(), char_start..char_end); 702 - self.last_char_offset = char_end; 703 - self.last_byte_offset = range.end; 704 764 } else { 705 765 // Real syntax - wrap in hideable span 706 766 let syntax_type = classify_syntax(syntax);
+2 -2
crates/weaver-index/src/clickhouse/queries/collab.rs
··· 108 108 record.relayUrl AS relay_url, 109 109 record.createdAt AS created_at, 110 110 record.expiresAt AS expires_at 111 - FROM raw_records FINAL 111 + FROM raw_records 112 112 WHERE collection = 'sh.weaver.collab.session' 113 113 AND is_live = 1 114 114 AND record.resource.uri = ? ··· 116 116 record.expiresAt IS NULL 117 117 OR record.expiresAt > now64(3) 118 118 ) 119 - ORDER BY created_at DESC 119 + ORDER BY record.createdAt.:DateTime64 DESC 120 120 "#; 121 121 122 122 let rows = self