diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs index 09ff587..a94104e 100644 --- a/crates/weaver-app/src/components/editor/beforeinput.rs +++ b/crates/weaver-app/src/components/editor/beforeinput.rs @@ -553,11 +553,28 @@ pub fn get_target_range_from_event( // Log raw DOM position for debugging let start_node_name = start_container.node_name(); let start_text = start_container.text_content().unwrap_or_default(); + let end_node_name = end_container.node_name(); + let end_text = end_container.text_content().unwrap_or_default(); + + // Check if containers are the editor element itself + let start_is_editor = start_container.dyn_ref::() + .map(|e| e == &editor_element) + .unwrap_or(false); + let end_is_editor = end_container.dyn_ref::() + .map(|e| e == &editor_element) + .unwrap_or(false); + tracing::trace!( start_node_name = %start_node_name, start_offset, - start_text_preview = %start_text.chars().take(20).collect::(), - "get_target_range_from_event: raw DOM position" + start_is_editor, + start_text_preview = %start_text.chars().take(30).collect::(), + end_node_name = %end_node_name, + end_offset, + end_is_editor, + end_text_preview = %end_text.chars().take(30).collect::(), + collapsed = static_range.collapsed(), + "get_target_range_from_event: raw StaticRange from browser" ); let start = dom_position_to_text_offset( @@ -577,6 +594,12 @@ pub fn get_target_range_from_event( None, )?; + tracing::trace!( + start, + end, + "get_target_range_from_event: computed text offsets" + ); + Some(Range::new(start, end)) } diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 093331a..1ace9f7 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -465,7 +465,7 @@ fn MarkdownEditorInner( let resolver = image_resolver(); let resolved = resolved_content(); - tracing::debug!( + tracing::trace!( "Rendering with {} pre-resolved embeds", resolved.embed_content.len() ); @@ -780,7 +780,7 @@ fn MarkdownEditorInner( &snapshot, ); let write_ms = crate::perf::now() - write_start; - tracing::debug!(export_ms, encode_ms, write_ms, "worker autosave complete"); + tracing::trace!(export_ms, encode_ms, write_ms, "worker autosave complete"); } WorkerOutput::Error { message } => { tracing::error!("Worker error: {}", message); diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 5a27d07..8110047 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -135,10 +135,55 @@ pub fn dom_position_to_text_offset( // Find the containing element with a node ID (walk up from text node) let mut current_node = node.clone(); + let mut walked_from: Option = None; // Track the child we walked up from let node_id = loop { + let node_name = current_node.node_name(); + let node_id_attr = current_node + .dyn_ref::() + .and_then(|e| e.get_attribute("id")); + tracing::trace!( + node_name = %node_name, + node_id_attr = ?node_id_attr, + "dom_position_to_text_offset: walk-up iteration" + ); + if let Some(element) = current_node.dyn_ref::() { if element == editor_element { - // Selection is on the editor container itself (e.g., Cmd+A select all) + // Selection is on the editor container itself + // + // IMPORTANT: If we WALKED UP to the editor from a descendant, + // offset_in_text_node is the offset within that descendant, NOT the + // child index in the editor. We need to find which paragraph contains + // the node we walked from. + if let Some(ref walked_node) = walked_from { + // We walked up from a descendant - find which paragraph it belongs to + tracing::debug!( + walked_from_node_name = %walked_node.node_name(), + "dom_position_to_text_offset: walked up to editor from descendant" + ); + + // Find paragraph containing this node by checking paragraph wrapper divs + for (idx, para) in paragraphs.iter().enumerate() { + if let Some(para_elem) = dom_document.get_element_by_id(¶.id) { + let para_node: &web_sys::Node = para_elem.as_ref(); + if para_node.contains(Some(walked_node)) { + // Found the paragraph - return its start + tracing::trace!( + para_id = %para.id, + para_idx = idx, + char_start = para.char_range.start, + "dom_position_to_text_offset: found containing paragraph" + ); + return Some(para.char_range.start); + } + } + } + // Couldn't find containing paragraph, fall through + tracing::warn!("dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"); + break None; + } + + // Selection is directly on the editor container (e.g., Cmd+A select all) // Return boundary position based on offset: // offset 0 = start of editor, offset == child count = end of editor let child_count = editor_element.child_element_count() as usize; @@ -158,12 +203,20 @@ pub fn dom_position_to_text_offset( if let Some(id) = id { // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs let is_node_id = id.starts_with('n') || id.contains("-n"); + tracing::trace!( + id = %id, + is_node_id, + starts_with_n = id.starts_with('n'), + contains_dash_n = id.contains("-n"), + "dom_position_to_text_offset: checking ID pattern" + ); if is_node_id { break Some(id); } } } + walked_from = Some(current_node.clone()); current_node = current_node.parent_node()?; }; @@ -178,43 +231,75 @@ pub fn dom_position_to_text_offset( // Skip text nodes inside contenteditable="false" elements (like embeds) let mut utf16_offset_in_container = 0; - // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) { - // Track the non-editable element we're inside (if any) - let mut skip_until_exit: Option = None; - - while let Ok(Some(dom_node)) = walker.next_node() { - // Check if we've exited the non-editable subtree - if let Some(ref skip_elem) = skip_until_exit { - if !skip_elem.contains(Some(&dom_node)) { - skip_until_exit = None; + // Check if the node IS the container element itself (not a text node descendant) + // In this case, offset_in_text_node is actually a child index, not a character offset + let node_is_container = node + .dyn_ref::() + .map(|e| e == &container) + .unwrap_or(false); + + if node_is_container { + // offset_in_text_node is a child index - count text content up to that child + let child_index = offset_in_text_node; + let children = container.child_nodes(); + let mut text_counted = 0usize; + + for i in 0..child_index.min(children.length() as usize) { + if let Some(child) = children.get(i as u32) { + if let Some(text) = child.text_content() { + text_counted += text.encode_utf16().count(); } } - - // Check if entering a non-editable element - if skip_until_exit.is_none() { - if let Some(element) = dom_node.dyn_ref::() { - if element.get_attribute("contenteditable").as_deref() == Some("false") { - skip_until_exit = Some(element.clone()); - continue; + } + utf16_offset_in_container = text_counted; + + tracing::debug!( + child_index, + utf16_offset = utf16_offset_in_container, + "dom_position_to_text_offset: node is container, using child index" + ); + } else { + // Normal case: node is a text node, walk to find it + // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions + if let Ok(walker) = + dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) + { + // Track the non-editable element we're inside (if any) + let mut skip_until_exit: Option = None; + + while let Ok(Some(dom_node)) = walker.next_node() { + // Check if we've exited the non-editable subtree + if let Some(ref skip_elem) = skip_until_exit { + if !skip_elem.contains(Some(&dom_node)) { + skip_until_exit = None; } } - } - // Skip everything inside non-editable regions - if skip_until_exit.is_some() { - continue; - } + // Check if entering a non-editable element + if skip_until_exit.is_none() { + if let Some(element) = dom_node.dyn_ref::() { + if element.get_attribute("contenteditable").as_deref() == Some("false") { + skip_until_exit = Some(element.clone()); + continue; + } + } + } - // Only process text nodes - if dom_node.node_type() == web_sys::Node::TEXT_NODE { - if &dom_node == node { - utf16_offset_in_container += offset_in_text_node; - break; + // Skip everything inside non-editable regions + if skip_until_exit.is_some() { + continue; } - if let Some(text) = dom_node.text_content() { - utf16_offset_in_container += text.encode_utf16().count(); + // Only process text nodes + if dom_node.node_type() == web_sys::Node::TEXT_NODE { + if &dom_node == node { + utf16_offset_in_container += offset_in_text_node; + break; + } + + if let Some(text) = dom_node.text_content() { + utf16_offset_in_container += text.encode_utf16().count(); + } } } } @@ -249,6 +334,17 @@ pub fn dom_position_to_text_offset( let offset_in_mapping = utf16_offset_in_container - mapping_start; let char_offset = mapping.char_range.start + offset_in_mapping; + tracing::trace!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + mapping_start, + mapping_end, + offset_in_mapping, + char_range_start = mapping.char_range.start, + char_offset, + "dom_position_to_text_offset: MATCHED mapping" + ); + // Check if this position is valid (not on invisible content) if is_valid_cursor_position(¶.offset_map, char_offset) { return Some(char_offset); diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 83ac3b8..e6280e2 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -224,6 +224,41 @@ pub fn render_paragraphs_incremental( let current_len = text.len_unicode(); let current_byte_len = text.len_utf8(); + // If we have cache but no edit, just return cached data (no re-render needed) + // This happens on cursor position changes, clicks, etc. + if let (Some(c), None) = (cache, edit) { + // Verify cache is still valid (document length matches) + let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); + if cached_len == current_len { + tracing::trace!( + target: "weaver::render", + "no edit, returning cached paragraphs" + ); + let paragraphs: Vec = c + .paragraphs + .iter() + .map(|p| ParagraphRender { + id: p.id.clone(), + byte_range: p.byte_range.clone(), + char_range: p.char_range.clone(), + html: p.html.clone(), + offset_map: p.offset_map.clone(), + syntax_spans: p.syntax_spans.clone(), + source_hash: p.source_hash, + }) + .collect(); + let paragraphs = add_gap_paragraphs(paragraphs, text, &source); + return ( + paragraphs, + c.clone(), + c.paragraphs + .iter() + .flat_map(|p| p.collected_refs.clone()) + .collect(), + ); + } + } + let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); tracing::debug!( @@ -335,7 +370,10 @@ pub fn render_paragraphs_incremental( // Adjust ranges based on position relative to edit let (byte_range, char_range) = if cached_para.char_range.end < edit_pos { // Before edit - no change - (cached_para.byte_range.clone(), cached_para.char_range.clone()) + ( + cached_para.byte_range.clone(), + cached_para.char_range.clone(), + ) } else if cached_para.char_range.start > edit_pos { // After edit - shift by delta ( @@ -347,8 +385,10 @@ pub fn render_paragraphs_incremental( } else { // Contains edit - expand end ( - cached_para.byte_range.start..apply_delta(cached_para.byte_range.end, byte_delta), - cached_para.char_range.start..apply_delta(cached_para.char_range.end, char_delta), + cached_para.byte_range.start + ..apply_delta(cached_para.byte_range.end, byte_delta), + cached_para.char_range.start + ..apply_delta(cached_para.char_range.end, char_delta), ) }; @@ -370,6 +410,7 @@ pub fn render_paragraphs_incremental( ¶_text, parser, ) + .with_node_id_prefix(&cached_para.id) .with_image_resolver(&resolver) .with_embed_provider(resolved_content); @@ -380,18 +421,30 @@ pub fn render_paragraphs_incremental( let (html, offset_map, syntax_spans, para_refs) = match writer.run() { Ok(result) => { // Adjust offsets to be document-absolute - let mut offset_map = result.offset_maps_by_paragraph.into_iter().next().unwrap_or_default(); + let mut offset_map = result + .offset_maps_by_paragraph + .into_iter() + .next() + .unwrap_or_default(); for m in &mut offset_map { m.char_range.start += char_range.start; m.char_range.end += char_range.start; m.byte_range.start += byte_range.start; m.byte_range.end += byte_range.start; } - let mut syntax_spans = result.syntax_spans_by_paragraph.into_iter().next().unwrap_or_default(); + let mut syntax_spans = result + .syntax_spans_by_paragraph + .into_iter() + .next() + .unwrap_or_default(); for s in &mut syntax_spans { s.adjust_positions(char_range.start as isize); } - let para_refs = result.collected_refs_by_paragraph.into_iter().next().unwrap_or_default(); + let para_refs = result + .collected_refs_by_paragraph + .into_iter() + .next() + .unwrap_or_default(); let html = result.html_segments.into_iter().next().unwrap_or_default(); (html, offset_map, syntax_spans, para_refs) } @@ -485,23 +538,135 @@ pub fn render_paragraphs_incremental( } // ============ SLOW PATH ============ - // Full render when boundaries might have changed + // Partial render: reuse cached paragraphs before edit, parse from affected to end let render_start = crate::perf::now(); + + // Try partial parse if we have cache and edit info + let (reused_paragraphs, parse_start_byte, parse_start_char) = + if let (Some(c), Some(e)) = (cache, edit) { + // Find the first cached paragraph that contains or is after the edit + let edit_pos = e.edit_char_pos; + let affected_idx = c + .paragraphs + .iter() + .position(|p| p.char_range.end >= edit_pos); + + if let Some(mut idx) = affected_idx { + // If edit is near the start of a paragraph (within first few chars), + // the previous paragraph is also affected (e.g., backspace to join) + const BOUNDARY_SLOP: usize = 3; + let para_start = c.paragraphs[idx].char_range.start; + if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP { + idx -= 1; + } + + if idx > 0 { + // Reuse paragraphs before the affected one + let reused: Vec<_> = c.paragraphs[..idx].to_vec(); + let last_reused = &c.paragraphs[idx - 1]; + tracing::trace!( + reused_count = idx, + parse_start_byte = last_reused.byte_range.end, + parse_start_char = last_reused.char_range.end, + "slow path: partial parse from affected paragraph" + ); + ( + reused, + last_reused.byte_range.end, + last_reused.char_range.end, + ) + } else { + // Edit is in first paragraph, parse everything + (Vec::new(), 0, 0) + } + } else { + // Edit is after all paragraphs (appending), parse from end + if let Some(last) = c.paragraphs.last() { + let reused = c.paragraphs.clone(); + (reused, last.byte_range.end, last.char_range.end) + } else { + (Vec::new(), 0, 0) + } + } + } else { + // No cache or no edit info, parse everything + (Vec::new(), 0, 0) + }; + + // Parse from the start point to end of document + let parse_slice = &source[parse_start_byte..]; let parser = - Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); + Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter(); // Use provided resolver or empty default let resolver = image_resolver.cloned().unwrap_or_default(); - // Build writer with all resolvers + // Create a temporary LoroText for the slice (needed by writer) + let slice_doc = loro::LoroDoc::new(); + let slice_text = slice_doc.get_text("content"); + let _ = slice_text.insert(0, parse_slice); + + // Determine starting paragraph ID for freshly parsed paragraphs + // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML + let reused_count = reused_paragraphs.len(); + + // If reused_count = 0 (full re-render), start from 0 for DOM stability + // Otherwise, use next_para_id to avoid collisions with reused paragraphs + let parsed_para_id_start = if reused_count == 0 { + 0 + } else { + cache.map(|c| c.next_para_id).unwrap_or(0) + }; + + tracing::trace!( + parsed_para_id_start, + reused_count, + "slow path: paragraph ID allocation" + ); + + // Find if cursor paragraph is being re-parsed (not reused) + // If so, we want it to keep its cached prefix for DOM/offset_map stability + let cursor_para_override: Option<(usize, String)> = cache.and_then(|c| { + // Find cached paragraph containing cursor + let cached_cursor_idx = c.paragraphs.iter().position(|p| { + p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end + })?; + + // If cursor paragraph is reused (not being re-parsed), no override needed + if cached_cursor_idx < reused_count { + return None; + } + + // Cursor paragraph is being re-parsed - use its cached ID + let cached_para = &c.paragraphs[cached_cursor_idx]; + let parsed_index = cached_cursor_idx - reused_count; + + tracing::trace!( + cached_cursor_idx, + reused_count, + parsed_index, + cached_id = %cached_para.id, + "slow path: cursor paragraph override" + ); + + Some((parsed_index, cached_para.id.clone())) + }); + + // Build writer with all resolvers and auto-incrementing paragraph prefixes let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( - &source, - text, + parse_slice, + &slice_text, parser, ) + .with_auto_incrementing_prefix(parsed_para_id_start) .with_image_resolver(&resolver) .with_embed_provider(resolved_content); + // Apply cursor paragraph override if needed + if let Some((idx, ref prefix)) = cursor_para_override { + writer = writer.with_static_prefix_at_index(idx, prefix); + } + if let Some(idx) = entry_index { writer = writer.with_entry_index(idx); } @@ -511,42 +676,68 @@ pub fn render_paragraphs_incremental( Err(_) => return (Vec::new(), RenderCache::default(), vec![]), }; - let render_ms = crate::perf::now() - render_start; + // Get the final paragraph ID counter from the writer (accounts for all parsed paragraphs) + let parsed_para_count = writer_result.paragraph_ranges.len(); - let paragraph_ranges = writer_result.paragraph_ranges.clone(); + let render_ms = crate::perf::now() - render_start; - // Log discovered paragraphs - for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { - let preview: String = text_slice_to_string(text, char_range.clone()) - .chars() - .take(30) - .collect(); - tracing::trace!( - target: "weaver::render", - para_idx = i, - char_range = ?char_range, - byte_range = ?byte_range, - preview = %preview, - "paragraph boundary" - ); + // Adjust parsed paragraph ranges to be document-absolute + let parsed_paragraph_ranges: Vec<_> = writer_result + .paragraph_ranges + .iter() + .map(|(byte_range, char_range)| { + ( + (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte), + (char_range.start + parse_start_char)..(char_range.end + parse_start_char), + ) + }) + .collect(); + + // Combine reused ranges with parsed ranges + let paragraph_ranges: Vec<_> = reused_paragraphs + .iter() + .map(|p| (p.byte_range.clone(), p.char_range.clone())) + .chain(parsed_paragraph_ranges.clone()) + .collect(); + + // Log discovered paragraphs (only if trace is enabled to avoid wasted work) + if tracing::enabled!(tracing::Level::TRACE) { + for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { + let preview: String = text_slice_to_string(text, char_range.clone()) + .chars() + .take(30) + .collect(); + tracing::trace!( + target: "weaver::render", + para_idx = i, + char_range = ?char_range, + byte_range = ?byte_range, + preview = %preview, + "paragraph boundary" + ); + } } - // Build paragraphs from full render segments + // Build paragraphs from render results let build_start = crate::perf::now(); let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); let mut all_refs: Vec = Vec::new(); - let mut next_para_id = cache.map(|c| c.next_para_id).unwrap_or(0); + // next_para_id must account for all IDs allocated by the writer + let mut next_para_id = parsed_para_id_start + parsed_para_count; + let reused_count = reused_paragraphs.len(); // Find which paragraph contains cursor (for stable ID assignment) let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| { char_range.start <= cursor_offset && cursor_offset <= char_range.end }); - tracing::debug!( + tracing::trace!( cursor_offset, ?cursor_para_idx, edit_char_pos = ?edit.map(|e| e.edit_char_pos), + reused_count, + parsed_count = parsed_paragraph_ranges.len(), "ID assignment: cursor and edit info" ); @@ -560,54 +751,93 @@ pub fn render_paragraphs_incremental( let source_hash = hash_source(¶_source); let is_cursor_para = Some(idx) == cursor_para_idx; - // ID assignment: cursor paragraph matches by edit position, others match by hash - let para_id = if is_cursor_para { - let edit_in_this_para = edit - .map(|e| char_range.start <= e.edit_char_pos && e.edit_char_pos <= char_range.end) - .unwrap_or(false); - let lookup_pos = if edit_in_this_para { - edit.map(|e| e.edit_char_pos).unwrap_or(cursor_offset) + // Check if this is a reused paragraph or a freshly parsed one + let is_reused = idx < reused_count; + + // ID assignment depends on whether this is reused or freshly parsed + let para_id = if is_reused { + // Reused paragraph: keep its existing ID (HTML already has matching prefixes) + reused_paragraphs[idx].id.clone() + } else { + // Freshly parsed: ID MUST match what the writer used for node ID prefixes + let parsed_idx = idx - reused_count; + + // Check if this is the cursor paragraph with an override + let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override { + if parsed_idx == override_idx { + // Use the override prefix (matches what writer used) + override_prefix.clone() + } else { + // Use auto-incremented ID (matches what writer used) + make_paragraph_id(parsed_para_id_start + parsed_idx) + } } else { - cursor_offset + // No override, use auto-incremented ID + make_paragraph_id(parsed_para_id_start + parsed_idx) }; - let found_cached = cache.and_then(|c| { - c.paragraphs - .iter() - .find(|p| p.char_range.start <= lookup_pos && lookup_pos <= p.char_range.end) - }); - if let Some(cached) = found_cached { - tracing::debug!( - lookup_pos, - edit_in_this_para, - cursor_offset, - cached_id = %cached.id, - cached_range = ?cached.char_range, - "cursor para: reusing cached ID" + if idx < 3 || is_cursor_para { + tracing::trace!( + idx, + parsed_idx, + is_cursor_para, + para_id = %id, + "slow path: assigned paragraph ID" ); - cached.id.clone() - } else { - let id = make_paragraph_id(next_para_id); - next_para_id += 1; - id } - } else { - // Non-cursor: match by content hash - cached_by_hash - .get(&source_hash) - .map(|p| p.id.clone()) - .unwrap_or_else(|| { - let id = make_paragraph_id(next_para_id); - next_para_id += 1; - id - }) + + id }; - // Get data from full render segments - let html = writer_result.html_segments.get(idx).cloned().unwrap_or_default(); - let offset_map = writer_result.offset_maps_by_paragraph.get(idx).cloned().unwrap_or_default(); - let syntax_spans = writer_result.syntax_spans_by_paragraph.get(idx).cloned().unwrap_or_default(); - let para_refs = writer_result.collected_refs_by_paragraph.get(idx).cloned().unwrap_or_default(); + // Get data either from reused cache or from fresh parse + let (html, offset_map, syntax_spans, para_refs) = if is_reused { + // Reused from cache - take directly + let reused = &reused_paragraphs[idx]; + ( + reused.html.clone(), + reused.offset_map.clone(), + reused.syntax_spans.clone(), + reused.collected_refs.clone(), + ) + } else { + // Freshly parsed - get from writer_result with offset adjustment + let parsed_idx = idx - reused_count; + let html = writer_result + .html_segments + .get(parsed_idx) + .cloned() + .unwrap_or_default(); + + // Adjust offset maps to document-absolute positions + let mut offset_map = writer_result + .offset_maps_by_paragraph + .get(parsed_idx) + .cloned() + .unwrap_or_default(); + for m in &mut offset_map { + m.char_range.start += parse_start_char; + m.char_range.end += parse_start_char; + m.byte_range.start += parse_start_byte; + m.byte_range.end += parse_start_byte; + } + + // Adjust syntax spans to document-absolute positions + let mut syntax_spans = writer_result + .syntax_spans_by_paragraph + .get(parsed_idx) + .cloned() + .unwrap_or_default(); + for s in &mut syntax_spans { + s.adjust_positions(parse_start_char as isize); + } + + let para_refs = writer_result + .collected_refs_by_paragraph + .get(parsed_idx) + .cloned() + .unwrap_or_default(); + (html, offset_map, syntax_spans, para_refs) + }; all_refs.extend(para_refs.clone()); @@ -635,7 +865,7 @@ pub fn render_paragraphs_incremental( } let build_ms = crate::perf::now() - build_start; - tracing::debug!( + tracing::trace!( render_ms, build_ms, paragraphs = paragraph_ranges.len(), diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-app/src/components/editor/worker.rs index b7beab9..1aa36b2 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-app/src/components/editor/worker.rs @@ -227,7 +227,7 @@ mod worker_impl { RaceResult::CoordinatorMsg(None) => break, // Coordinator closed RaceResult::CoordinatorMsg(Some(msg)) => { // Fall through to message handling below - tracing::debug!(?msg, "Worker: received message"); + tracing::trace!(?msg, "Worker: received message"); match msg { WorkerInput::Init { snapshot, diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index af74817..d4caece 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -416,7 +416,10 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, E = ( // Offset mapping tracking - current paragraph offset_maps: Vec, - node_id_prefix: Option, // paragraph ID prefix for stable node IDs + node_id_prefix: Option, // paragraph ID prefix for stable node IDs + auto_increment_prefix: Option, // if set, auto-increment prefix per paragraph from this value + static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index + current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) next_node_id: usize, current_node_id: Option, // node ID for current text container current_node_char_offset: usize, // UTF-16 offset within current node @@ -498,6 +501,9 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, table_start_offset: None, offset_maps: Vec::new(), node_id_prefix: None, + auto_increment_prefix: None, + static_prefix_override: None, + current_paragraph_index: 0, next_node_id: node_id_offset, current_node_id: None, current_node_char_offset: 0, @@ -554,6 +560,9 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, table_start_offset: self.table_start_offset, offset_maps: self.offset_maps, node_id_prefix: self.node_id_prefix, + auto_increment_prefix: self.auto_increment_prefix, + static_prefix_override: self.static_prefix_override, + current_paragraph_index: self.current_paragraph_index, next_node_id: self.next_node_id, current_node_id: self.current_node_id, current_node_char_offset: self.current_node_char_offset, @@ -581,12 +590,41 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, /// Set a prefix for node IDs (typically the paragraph ID). /// This makes node IDs paragraph-scoped and stable across re-renders. + /// Use this for single-paragraph renders where the paragraph ID is known. pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { self.node_id_prefix = Some(prefix.to_string()); self.next_node_id = 0; // Reset counter since each paragraph is independent self } + /// Enable auto-incrementing paragraph prefixes for multi-paragraph renders. + /// Each paragraph gets prefix "p-{N}" where N starts at `start_id` and increments. + /// Node IDs reset to 0 for each paragraph, giving "p-{N}-n0", "p-{N}-n1", etc. + pub fn with_auto_incrementing_prefix(mut self, start_id: usize) -> Self { + self.auto_increment_prefix = Some(start_id); + self.node_id_prefix = Some(format!("p-{}", start_id)); + self.next_node_id = 0; + self + } + + /// Get the next paragraph ID that would be assigned (for tracking allocations). + pub fn next_paragraph_id(&self) -> Option { + self.auto_increment_prefix + } + + /// Override the auto-incrementing prefix for a specific paragraph index. + /// Use this when you need a specific paragraph (e.g., cursor paragraph) to have + /// a stable prefix for DOM/offset_map compatibility. + pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self { + self.static_prefix_override = Some((index, prefix.to_string())); + // If this is for paragraph 0, apply it immediately + if index == 0 { + self.node_id_prefix = Some(prefix.to_string()); + self.next_node_id = 0; + } + self + } + /// Finalize the current paragraph: move accumulated items to per-para vectors, /// start a new output segment for the next paragraph. fn finalize_paragraph(&mut self, byte_range: Range, char_range: Range) { @@ -601,6 +639,28 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, self.refs_by_para .push(std::mem::take(&mut self.ref_collector.refs)); + // Advance to next paragraph + self.current_paragraph_index += 1; + + // Determine prefix for next paragraph + if let Some((override_idx, ref override_prefix)) = self.static_prefix_override { + if self.current_paragraph_index == override_idx { + // Use the static override for this paragraph + self.node_id_prefix = Some(override_prefix.clone()); + self.next_node_id = 0; + } else if let Some(ref mut current_id) = self.auto_increment_prefix { + // Use auto-increment (skip the override index to avoid collision) + *current_id += 1; + self.node_id_prefix = Some(format!("p-{}", *current_id)); + self.next_node_id = 0; + } + } else if let Some(ref mut current_id) = self.auto_increment_prefix { + // Normal auto-increment + *current_id += 1; + self.node_id_prefix = Some(format!("p-{}", *current_id)); + self.next_node_id = 0; + } + // Start new output segment for next paragraph self.writer.new_segment(); } @@ -692,15 +752,15 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, escape_html(&mut self.writer, syntax)?; + // Record offset mapping BEFORE end_node (which clears current_node_id) + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + if created_node { self.write("")?; self.end_node(); } - - // Record offset mapping but no syntax span info - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; - self.last_byte_offset = range.end; } else { // Real syntax - wrap in hideable span let syntax_type = classify_syntax(syntax); diff --git a/crates/weaver-index/src/clickhouse/queries/collab.rs b/crates/weaver-index/src/clickhouse/queries/collab.rs index 0f43591..3f3f0de 100644 --- a/crates/weaver-index/src/clickhouse/queries/collab.rs +++ b/crates/weaver-index/src/clickhouse/queries/collab.rs @@ -108,7 +108,7 @@ impl Client { record.relayUrl AS relay_url, record.createdAt AS created_at, record.expiresAt AS expires_at - FROM raw_records FINAL + FROM raw_records WHERE collection = 'sh.weaver.collab.session' AND is_live = 1 AND record.resource.uri = ? @@ -116,7 +116,7 @@ impl Client { record.expiresAt IS NULL OR record.expiresAt > now64(3) ) - ORDER BY created_at DESC + ORDER BY record.createdAt.:DateTime64 DESC "#; let rows = self