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 // Log raw DOM position for debugging 554 let start_node_name = start_container.node_name(); 555 let start_text = start_container.text_content().unwrap_or_default(); 556 tracing::trace!( 557 start_node_name = %start_node_name, 558 start_offset, 559 - start_text_preview = %start_text.chars().take(20).collect::<String>(), 560 - "get_target_range_from_event: raw DOM position" 561 ); 562 563 let start = dom_position_to_text_offset( ··· 576 paragraphs, 577 None, 578 )?; 579 580 Some(Range::new(start, end)) 581 }
··· 553 // Log raw DOM position for debugging 554 let start_node_name = start_container.node_name(); 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 + 567 tracing::trace!( 568 start_node_name = %start_node_name, 569 start_offset, 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" 578 ); 579 580 let start = dom_position_to_text_offset( ··· 593 paragraphs, 594 None, 595 )?; 596 + 597 + tracing::trace!( 598 + start, 599 + end, 600 + "get_target_range_from_event: computed text offsets" 601 + ); 602 603 Some(Range::new(start, end)) 604 }
+2 -2
crates/weaver-app/src/components/editor/component.rs
··· 465 let resolver = image_resolver(); 466 let resolved = resolved_content(); 467 468 - tracing::debug!( 469 "Rendering with {} pre-resolved embeds", 470 resolved.embed_content.len() 471 ); ··· 780 &snapshot, 781 ); 782 let write_ms = crate::perf::now() - write_start; 783 - tracing::debug!(export_ms, encode_ms, write_ms, "worker autosave complete"); 784 } 785 WorkerOutput::Error { message } => { 786 tracing::error!("Worker error: {}", message);
··· 465 let resolver = image_resolver(); 466 let resolved = resolved_content(); 467 468 + tracing::trace!( 469 "Rendering with {} pre-resolved embeds", 470 resolved.embed_content.len() 471 ); ··· 780 &snapshot, 781 ); 782 let write_ms = crate::perf::now() - write_start; 783 + tracing::trace!(export_ms, encode_ms, write_ms, "worker autosave complete"); 784 } 785 WorkerOutput::Error { message } => { 786 tracing::error!("Worker error: {}", message);
+124 -28
crates/weaver-app/src/components/editor/dom_sync.rs
··· 135 136 // Find the containing element with a node ID (walk up from text node) 137 let mut current_node = node.clone(); 138 let node_id = loop { 139 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 140 if element == editor_element { 141 - // Selection is on the editor container itself (e.g., Cmd+A select all) 142 // Return boundary position based on offset: 143 // offset 0 = start of editor, offset == child count = end of editor 144 let child_count = editor_element.child_element_count() as usize; ··· 158 if let Some(id) = id { 159 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs 160 let is_node_id = id.starts_with('n') || id.contains("-n"); 161 if is_node_id { 162 break Some(id); 163 } 164 } 165 } 166 167 current_node = current_node.parent_node()?; 168 }; 169 ··· 178 // Skip text nodes inside contenteditable="false" elements (like embeds) 179 let mut utf16_offset_in_container = 0; 180 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; 185 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; 191 } 192 } 193 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; 200 } 201 } 202 - } 203 204 - // Skip everything inside non-editable regions 205 - if skip_until_exit.is_some() { 206 - continue; 207 - } 208 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; 214 } 215 216 - if let Some(text) = dom_node.text_content() { 217 - utf16_offset_in_container += text.encode_utf16().count(); 218 } 219 } 220 } ··· 248 { 249 let offset_in_mapping = utf16_offset_in_container - mapping_start; 250 let char_offset = mapping.char_range.start + offset_in_mapping; 251 252 // Check if this position is valid (not on invisible content) 253 if is_valid_cursor_position(&para.offset_map, char_offset) {
··· 135 136 // Find the containing element with a node ID (walk up from text node) 137 let mut current_node = node.clone(); 138 + let mut walked_from: Option<web_sys::Node> = None; // Track the child we walked up from 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 + 150 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 151 if element == editor_element { 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) 187 // Return boundary position based on offset: 188 // offset 0 = start of editor, offset == child count = end of editor 189 let child_count = editor_element.child_element_count() as usize; ··· 203 if let Some(id) = id { 204 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs 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 + ); 213 if is_node_id { 214 break Some(id); 215 } 216 } 217 } 218 219 + walked_from = Some(current_node.clone()); 220 current_node = current_node.parent_node()?; 221 }; 222 ··· 231 // Skip text nodes inside contenteditable="false" elements (like embeds) 232 let mut utf16_offset_in_container = 0; 233 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; 246 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(); 251 } 252 } 253 + } 254 + utf16_offset_in_container = text_counted; 255 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; 275 } 276 } 277 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 + } 287 288 + // Skip everything inside non-editable regions 289 + if skip_until_exit.is_some() { 290 + continue; 291 } 292 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 + } 303 } 304 } 305 } ··· 333 { 334 let offset_in_mapping = utf16_offset_in_container - mapping_start; 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 + ); 347 348 // Check if this position is valid (not on invisible content) 349 if is_valid_cursor_position(&para.offset_map, char_offset) {
+301 -71
crates/weaver-app/src/components/editor/render.rs
··· 224 let current_len = text.len_unicode(); 225 let current_byte_len = text.len_utf8(); 226 227 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 228 229 tracing::debug!( ··· 335 // Adjust ranges based on position relative to edit 336 let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { 337 // Before edit - no change 338 - (cached_para.byte_range.clone(), cached_para.char_range.clone()) 339 } else if cached_para.char_range.start > edit_pos { 340 // After edit - shift by delta 341 ( ··· 347 } else { 348 // Contains edit - expand end 349 ( 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), 352 ) 353 }; 354 ··· 370 &para_text, 371 parser, 372 ) 373 .with_image_resolver(&resolver) 374 .with_embed_provider(resolved_content); 375 ··· 380 let (html, offset_map, syntax_spans, para_refs) = match writer.run() { 381 Ok(result) => { 382 // Adjust offsets to be document-absolute 383 - let mut offset_map = result.offset_maps_by_paragraph.into_iter().next().unwrap_or_default(); 384 for m in &mut offset_map { 385 m.char_range.start += char_range.start; 386 m.char_range.end += char_range.start; 387 m.byte_range.start += byte_range.start; 388 m.byte_range.end += byte_range.start; 389 } 390 - let mut syntax_spans = result.syntax_spans_by_paragraph.into_iter().next().unwrap_or_default(); 391 for s in &mut syntax_spans { 392 s.adjust_positions(char_range.start as isize); 393 } 394 - let para_refs = result.collected_refs_by_paragraph.into_iter().next().unwrap_or_default(); 395 let html = result.html_segments.into_iter().next().unwrap_or_default(); 396 (html, offset_map, syntax_spans, para_refs) 397 } ··· 485 } 486 487 // ============ SLOW PATH ============ 488 - // Full render when boundaries might have changed 489 let render_start = crate::perf::now(); 490 let parser = 491 - Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); 492 493 // Use provided resolver or empty default 494 let resolver = image_resolver.cloned().unwrap_or_default(); 495 496 - // Build writer with all resolvers 497 let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( 498 - &source, 499 - text, 500 parser, 501 ) 502 .with_image_resolver(&resolver) 503 .with_embed_provider(resolved_content); 504 505 if let Some(idx) = entry_index { 506 writer = writer.with_entry_index(idx); 507 } ··· 511 Err(_) => return (Vec::new(), RenderCache::default(), vec![]), 512 }; 513 514 let render_ms = crate::perf::now() - render_start; 515 516 - let paragraph_ranges = writer_result.paragraph_ranges.clone(); 517 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 - ); 532 } 533 534 - // Build paragraphs from full render segments 535 let build_start = crate::perf::now(); 536 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 537 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 538 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); 540 541 // Find which paragraph contains cursor (for stable ID assignment) 542 let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { 543 char_range.start <= cursor_offset && cursor_offset <= char_range.end 544 }); 545 546 - tracing::debug!( 547 cursor_offset, 548 ?cursor_para_idx, 549 edit_char_pos = ?edit.map(|e| e.edit_char_pos), 550 "ID assignment: cursor and edit info" 551 ); 552 ··· 560 let source_hash = hash_source(&para_source); 561 let is_cursor_para = Some(idx) == cursor_para_idx; 562 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) 570 } else { 571 - cursor_offset 572 }; 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 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" 587 ); 588 - cached.id.clone() 589 - } else { 590 - let id = make_paragraph_id(next_para_id); 591 - next_para_id += 1; 592 - id 593 } 594 } 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 - }; 605 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(); 611 612 all_refs.extend(para_refs.clone()); 613 ··· 635 } 636 637 let build_ms = crate::perf::now() - build_start; 638 - tracing::debug!( 639 render_ms, 640 build_ms, 641 paragraphs = paragraph_ranges.len(),
··· 224 let current_len = text.len_unicode(); 225 let current_byte_len = text.len_utf8(); 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 + 262 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 263 264 tracing::debug!( ··· 370 // Adjust ranges based on position relative to edit 371 let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { 372 // Before edit - no change 373 + ( 374 + cached_para.byte_range.clone(), 375 + cached_para.char_range.clone(), 376 + ) 377 } else if cached_para.char_range.start > edit_pos { 378 // After edit - shift by delta 379 ( ··· 385 } else { 386 // Contains edit - expand end 387 ( 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), 392 ) 393 }; 394 ··· 410 &para_text, 411 parser, 412 ) 413 + .with_node_id_prefix(&cached_para.id) 414 .with_image_resolver(&resolver) 415 .with_embed_provider(resolved_content); 416 ··· 421 let (html, offset_map, syntax_spans, para_refs) = match writer.run() { 422 Ok(result) => { 423 // Adjust offsets to be document-absolute 424 + let mut offset_map = result 425 + .offset_maps_by_paragraph 426 + .into_iter() 427 + .next() 428 + .unwrap_or_default(); 429 for m in &mut offset_map { 430 m.char_range.start += char_range.start; 431 m.char_range.end += char_range.start; 432 m.byte_range.start += byte_range.start; 433 m.byte_range.end += byte_range.start; 434 } 435 + let mut syntax_spans = result 436 + .syntax_spans_by_paragraph 437 + .into_iter() 438 + .next() 439 + .unwrap_or_default(); 440 for s in &mut syntax_spans { 441 s.adjust_positions(char_range.start as isize); 442 } 443 + let para_refs = result 444 + .collected_refs_by_paragraph 445 + .into_iter() 446 + .next() 447 + .unwrap_or_default(); 448 let html = result.html_segments.into_iter().next().unwrap_or_default(); 449 (html, offset_map, syntax_spans, para_refs) 450 } ··· 538 } 539 540 // ============ SLOW PATH ============ 541 + // Partial render: reuse cached paragraphs before edit, parse from affected to end 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..]; 598 let parser = 599 + Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); 600 601 // Use provided resolver or empty default 602 let resolver = image_resolver.cloned().unwrap_or_default(); 603 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 656 let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( 657 + parse_slice, 658 + &slice_text, 659 parser, 660 ) 661 + .with_auto_incrementing_prefix(parsed_para_id_start) 662 .with_image_resolver(&resolver) 663 .with_embed_provider(resolved_content); 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 + 670 if let Some(idx) = entry_index { 671 writer = writer.with_entry_index(idx); 672 } ··· 676 Err(_) => return (Vec::new(), RenderCache::default(), vec![]), 677 }; 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 + 682 let render_ms = crate::perf::now() - render_start; 683 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(); 695 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 + } 719 } 720 721 + // Build paragraphs from render results 722 let build_start = crate::perf::now(); 723 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 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(); 729 730 // Find which paragraph contains cursor (for stable ID assignment) 731 let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { 732 char_range.start <= cursor_offset && cursor_offset <= char_range.end 733 }); 734 735 + tracing::trace!( 736 cursor_offset, 737 ?cursor_para_idx, 738 edit_char_pos = ?edit.map(|e| e.edit_char_pos), 739 + reused_count, 740 + parsed_count = parsed_paragraph_ranges.len(), 741 "ID assignment: cursor and edit info" 742 ); 743 ··· 751 let source_hash = hash_source(&para_source); 752 let is_cursor_para = Some(idx) == cursor_para_idx; 753 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 + } 774 } else { 775 + // No override, use auto-incremented ID 776 + make_paragraph_id(parsed_para_id_start + parsed_idx) 777 }; 778 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" 786 ); 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 + ) 802 } else { 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 + } 823 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 + }; 841 842 all_refs.extend(para_refs.clone()); 843 ··· 865 } 866 867 let build_ms = crate::perf::now() - build_start; 868 + tracing::trace!( 869 render_ms, 870 build_ms, 871 paragraphs = paragraph_ranges.len(),
+1 -1
crates/weaver-app/src/components/editor/worker.rs
··· 227 RaceResult::CoordinatorMsg(None) => break, // Coordinator closed 228 RaceResult::CoordinatorMsg(Some(msg)) => { 229 // Fall through to message handling below 230 - tracing::debug!(?msg, "Worker: received message"); 231 match msg { 232 WorkerInput::Init { 233 snapshot,
··· 227 RaceResult::CoordinatorMsg(None) => break, // Coordinator closed 228 RaceResult::CoordinatorMsg(Some(msg)) => { 229 // Fall through to message handling below 230 + tracing::trace!(?msg, "Worker: received message"); 231 match msg { 232 WorkerInput::Init { 233 snapshot,
+66 -6
crates/weaver-app/src/components/editor/writer.rs
··· 416 417 // Offset mapping tracking - current paragraph 418 offset_maps: Vec<OffsetMapping>, 419 - node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 420 next_node_id: usize, 421 current_node_id: Option<String>, // node ID for current text container 422 current_node_char_offset: usize, // UTF-16 offset within current node ··· 498 table_start_offset: None, 499 offset_maps: Vec::new(), 500 node_id_prefix: None, 501 next_node_id: node_id_offset, 502 current_node_id: None, 503 current_node_char_offset: 0, ··· 554 table_start_offset: self.table_start_offset, 555 offset_maps: self.offset_maps, 556 node_id_prefix: self.node_id_prefix, 557 next_node_id: self.next_node_id, 558 current_node_id: self.current_node_id, 559 current_node_char_offset: self.current_node_char_offset, ··· 581 582 /// Set a prefix for node IDs (typically the paragraph ID). 583 /// This makes node IDs paragraph-scoped and stable across re-renders. 584 pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { 585 self.node_id_prefix = Some(prefix.to_string()); 586 self.next_node_id = 0; // Reset counter since each paragraph is independent 587 self 588 } 589 590 /// Finalize the current paragraph: move accumulated items to per-para vectors, 591 /// start a new output segment for the next paragraph. 592 fn finalize_paragraph(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { ··· 600 .push(std::mem::take(&mut self.syntax_spans)); 601 self.refs_by_para 602 .push(std::mem::take(&mut self.ref_collector.refs)); 603 604 // Start new output segment for next paragraph 605 self.writer.new_segment(); ··· 692 693 escape_html(&mut self.writer, syntax)?; 694 695 if created_node { 696 self.write("</span>")?; 697 self.end_node(); 698 } 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 } else { 705 // Real syntax - wrap in hideable span 706 let syntax_type = classify_syntax(syntax);
··· 416 417 // Offset mapping tracking - current paragraph 418 offset_maps: Vec<OffsetMapping>, 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) 423 next_node_id: usize, 424 current_node_id: Option<String>, // node ID for current text container 425 current_node_char_offset: usize, // UTF-16 offset within current node ··· 501 table_start_offset: None, 502 offset_maps: Vec::new(), 503 node_id_prefix: None, 504 + auto_increment_prefix: None, 505 + static_prefix_override: None, 506 + current_paragraph_index: 0, 507 next_node_id: node_id_offset, 508 current_node_id: None, 509 current_node_char_offset: 0, ··· 560 table_start_offset: self.table_start_offset, 561 offset_maps: self.offset_maps, 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, 566 next_node_id: self.next_node_id, 567 current_node_id: self.current_node_id, 568 current_node_char_offset: self.current_node_char_offset, ··· 590 591 /// Set a prefix for node IDs (typically the paragraph ID). 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. 594 pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { 595 self.node_id_prefix = Some(prefix.to_string()); 596 self.next_node_id = 0; // Reset counter since each paragraph is independent 597 self 598 } 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 + 628 /// Finalize the current paragraph: move accumulated items to per-para vectors, 629 /// start a new output segment for the next paragraph. 630 fn finalize_paragraph(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { ··· 638 .push(std::mem::take(&mut self.syntax_spans)); 639 self.refs_by_para 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 + } 663 664 // Start new output segment for next paragraph 665 self.writer.new_segment(); ··· 752 753 escape_html(&mut self.writer, syntax)?; 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 + 760 if created_node { 761 self.write("</span>")?; 762 self.end_node(); 763 } 764 } else { 765 // Real syntax - wrap in hideable span 766 let syntax_type = classify_syntax(syntax);
+2 -2
crates/weaver-index/src/clickhouse/queries/collab.rs
··· 108 record.relayUrl AS relay_url, 109 record.createdAt AS created_at, 110 record.expiresAt AS expires_at 111 - FROM raw_records FINAL 112 WHERE collection = 'sh.weaver.collab.session' 113 AND is_live = 1 114 AND record.resource.uri = ? ··· 116 record.expiresAt IS NULL 117 OR record.expiresAt > now64(3) 118 ) 119 - ORDER BY created_at DESC 120 "#; 121 122 let rows = self
··· 108 record.relayUrl AS relay_url, 109 record.createdAt AS created_at, 110 record.expiresAt AS expires_at 111 + FROM raw_records 112 WHERE collection = 'sh.weaver.collab.session' 113 AND is_live = 1 114 AND record.resource.uri = ? ··· 116 record.expiresAt IS NULL 117 OR record.expiresAt > now64(3) 118 ) 119 + ORDER BY record.createdAt.:DateTime64 DESC 120 "#; 121 122 let rows = self