···135135136136 // Find the containing element with a node ID (walk up from text node)
137137 let mut current_node = node.clone();
138138+ let mut walked_from: Option<web_sys::Node> = None; // Track the child we walked up from
138139 let node_id = loop {
140140+ let node_name = current_node.node_name();
141141+ let node_id_attr = current_node
142142+ .dyn_ref::<web_sys::Element>()
143143+ .and_then(|e| e.get_attribute("id"));
144144+ tracing::trace!(
145145+ node_name = %node_name,
146146+ node_id_attr = ?node_id_attr,
147147+ "dom_position_to_text_offset: walk-up iteration"
148148+ );
149149+139150 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
140151 if element == editor_element {
141141- // Selection is on the editor container itself (e.g., Cmd+A select all)
152152+ // Selection is on the editor container itself
153153+ //
154154+ // IMPORTANT: If we WALKED UP to the editor from a descendant,
155155+ // offset_in_text_node is the offset within that descendant, NOT the
156156+ // child index in the editor. We need to find which paragraph contains
157157+ // the node we walked from.
158158+ if let Some(ref walked_node) = walked_from {
159159+ // We walked up from a descendant - find which paragraph it belongs to
160160+ tracing::debug!(
161161+ walked_from_node_name = %walked_node.node_name(),
162162+ "dom_position_to_text_offset: walked up to editor from descendant"
163163+ );
164164+165165+ // Find paragraph containing this node by checking paragraph wrapper divs
166166+ for (idx, para) in paragraphs.iter().enumerate() {
167167+ if let Some(para_elem) = dom_document.get_element_by_id(¶.id) {
168168+ let para_node: &web_sys::Node = para_elem.as_ref();
169169+ if para_node.contains(Some(walked_node)) {
170170+ // Found the paragraph - return its start
171171+ tracing::trace!(
172172+ para_id = %para.id,
173173+ para_idx = idx,
174174+ char_start = para.char_range.start,
175175+ "dom_position_to_text_offset: found containing paragraph"
176176+ );
177177+ return Some(para.char_range.start);
178178+ }
179179+ }
180180+ }
181181+ // Couldn't find containing paragraph, fall through
182182+ tracing::warn!("dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph");
183183+ break None;
184184+ }
185185+186186+ // Selection is directly on the editor container (e.g., Cmd+A select all)
142187 // Return boundary position based on offset:
143188 // offset 0 = start of editor, offset == child count = end of editor
144189 let child_count = editor_element.child_element_count() as usize;
···158203 if let Some(id) = id {
159204 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs
160205 let is_node_id = id.starts_with('n') || id.contains("-n");
206206+ tracing::trace!(
207207+ id = %id,
208208+ is_node_id,
209209+ starts_with_n = id.starts_with('n'),
210210+ contains_dash_n = id.contains("-n"),
211211+ "dom_position_to_text_offset: checking ID pattern"
212212+ );
161213 if is_node_id {
162214 break Some(id);
163215 }
164216 }
165217 }
166218219219+ walked_from = Some(current_node.clone());
167220 current_node = current_node.parent_node()?;
168221 };
169222···178231 // Skip text nodes inside contenteditable="false" elements (like embeds)
179232 let mut utf16_offset_in_container = 0;
180233181181- // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions
182182- if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) {
183183- // Track the non-editable element we're inside (if any)
184184- let mut skip_until_exit: Option<web_sys::Element> = None;
234234+ // Check if the node IS the container element itself (not a text node descendant)
235235+ // In this case, offset_in_text_node is actually a child index, not a character offset
236236+ let node_is_container = node
237237+ .dyn_ref::<web_sys::Element>()
238238+ .map(|e| e == &container)
239239+ .unwrap_or(false);
240240+241241+ if node_is_container {
242242+ // offset_in_text_node is a child index - count text content up to that child
243243+ let child_index = offset_in_text_node;
244244+ let children = container.child_nodes();
245245+ let mut text_counted = 0usize;
185246186186- while let Ok(Some(dom_node)) = walker.next_node() {
187187- // Check if we've exited the non-editable subtree
188188- if let Some(ref skip_elem) = skip_until_exit {
189189- if !skip_elem.contains(Some(&dom_node)) {
190190- skip_until_exit = None;
247247+ for i in 0..child_index.min(children.length() as usize) {
248248+ if let Some(child) = children.get(i as u32) {
249249+ if let Some(text) = child.text_content() {
250250+ text_counted += text.encode_utf16().count();
191251 }
192252 }
253253+ }
254254+ utf16_offset_in_container = text_counted;
193255194194- // Check if entering a non-editable element
195195- if skip_until_exit.is_none() {
196196- if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
197197- if element.get_attribute("contenteditable").as_deref() == Some("false") {
198198- skip_until_exit = Some(element.clone());
199199- continue;
256256+ tracing::debug!(
257257+ child_index,
258258+ utf16_offset = utf16_offset_in_container,
259259+ "dom_position_to_text_offset: node is container, using child index"
260260+ );
261261+ } else {
262262+ // Normal case: node is a text node, walk to find it
263263+ // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions
264264+ if let Ok(walker) =
265265+ dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF)
266266+ {
267267+ // Track the non-editable element we're inside (if any)
268268+ let mut skip_until_exit: Option<web_sys::Element> = None;
269269+270270+ while let Ok(Some(dom_node)) = walker.next_node() {
271271+ // Check if we've exited the non-editable subtree
272272+ if let Some(ref skip_elem) = skip_until_exit {
273273+ if !skip_elem.contains(Some(&dom_node)) {
274274+ skip_until_exit = None;
200275 }
201276 }
202202- }
203277204204- // Skip everything inside non-editable regions
205205- if skip_until_exit.is_some() {
206206- continue;
207207- }
278278+ // Check if entering a non-editable element
279279+ if skip_until_exit.is_none() {
280280+ if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
281281+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
282282+ skip_until_exit = Some(element.clone());
283283+ continue;
284284+ }
285285+ }
286286+ }
208287209209- // Only process text nodes
210210- if dom_node.node_type() == web_sys::Node::TEXT_NODE {
211211- if &dom_node == node {
212212- utf16_offset_in_container += offset_in_text_node;
213213- break;
288288+ // Skip everything inside non-editable regions
289289+ if skip_until_exit.is_some() {
290290+ continue;
214291 }
215292216216- if let Some(text) = dom_node.text_content() {
217217- utf16_offset_in_container += text.encode_utf16().count();
293293+ // Only process text nodes
294294+ if dom_node.node_type() == web_sys::Node::TEXT_NODE {
295295+ if &dom_node == node {
296296+ utf16_offset_in_container += offset_in_text_node;
297297+ break;
298298+ }
299299+300300+ if let Some(text) = dom_node.text_content() {
301301+ utf16_offset_in_container += text.encode_utf16().count();
302302+ }
218303 }
219304 }
220305 }
···248333 {
249334 let offset_in_mapping = utf16_offset_in_container - mapping_start;
250335 let char_offset = mapping.char_range.start + offset_in_mapping;
336336+337337+ tracing::trace!(
338338+ node_id = %node_id,
339339+ utf16_offset = utf16_offset_in_container,
340340+ mapping_start,
341341+ mapping_end,
342342+ offset_in_mapping,
343343+ char_range_start = mapping.char_range.start,
344344+ char_offset,
345345+ "dom_position_to_text_offset: MATCHED mapping"
346346+ );
251347252348 // Check if this position is valid (not on invisible content)
253349 if is_valid_cursor_position(¶.offset_map, char_offset) {
+301-71
crates/weaver-app/src/components/editor/render.rs
···224224 let current_len = text.len_unicode();
225225 let current_byte_len = text.len_utf8();
226226227227+ // If we have cache but no edit, just return cached data (no re-render needed)
228228+ // This happens on cursor position changes, clicks, etc.
229229+ if let (Some(c), None) = (cache, edit) {
230230+ // Verify cache is still valid (document length matches)
231231+ let cached_len = c.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
232232+ if cached_len == current_len {
233233+ tracing::trace!(
234234+ target: "weaver::render",
235235+ "no edit, returning cached paragraphs"
236236+ );
237237+ let paragraphs: Vec<ParagraphRender> = c
238238+ .paragraphs
239239+ .iter()
240240+ .map(|p| ParagraphRender {
241241+ id: p.id.clone(),
242242+ byte_range: p.byte_range.clone(),
243243+ char_range: p.char_range.clone(),
244244+ html: p.html.clone(),
245245+ offset_map: p.offset_map.clone(),
246246+ syntax_spans: p.syntax_spans.clone(),
247247+ source_hash: p.source_hash,
248248+ })
249249+ .collect();
250250+ let paragraphs = add_gap_paragraphs(paragraphs, text, &source);
251251+ return (
252252+ paragraphs,
253253+ c.clone(),
254254+ c.paragraphs
255255+ .iter()
256256+ .flat_map(|p| p.collected_refs.clone())
257257+ .collect(),
258258+ );
259259+ }
260260+ }
261261+227262 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
228263229264 tracing::debug!(
···335370 // Adjust ranges based on position relative to edit
336371 let (byte_range, char_range) = if cached_para.char_range.end < edit_pos {
337372 // Before edit - no change
338338- (cached_para.byte_range.clone(), cached_para.char_range.clone())
373373+ (
374374+ cached_para.byte_range.clone(),
375375+ cached_para.char_range.clone(),
376376+ )
339377 } else if cached_para.char_range.start > edit_pos {
340378 // After edit - shift by delta
341379 (
···347385 } else {
348386 // Contains edit - expand end
349387 (
350350- cached_para.byte_range.start..apply_delta(cached_para.byte_range.end, byte_delta),
351351- cached_para.char_range.start..apply_delta(cached_para.char_range.end, char_delta),
388388+ cached_para.byte_range.start
389389+ ..apply_delta(cached_para.byte_range.end, byte_delta),
390390+ cached_para.char_range.start
391391+ ..apply_delta(cached_para.char_range.end, char_delta),
352392 )
353393 };
354394···370410 ¶_text,
371411 parser,
372412 )
413413+ .with_node_id_prefix(&cached_para.id)
373414 .with_image_resolver(&resolver)
374415 .with_embed_provider(resolved_content);
375416···380421 let (html, offset_map, syntax_spans, para_refs) = match writer.run() {
381422 Ok(result) => {
382423 // Adjust offsets to be document-absolute
383383- let mut offset_map = result.offset_maps_by_paragraph.into_iter().next().unwrap_or_default();
424424+ let mut offset_map = result
425425+ .offset_maps_by_paragraph
426426+ .into_iter()
427427+ .next()
428428+ .unwrap_or_default();
384429 for m in &mut offset_map {
385430 m.char_range.start += char_range.start;
386431 m.char_range.end += char_range.start;
387432 m.byte_range.start += byte_range.start;
388433 m.byte_range.end += byte_range.start;
389434 }
390390- let mut syntax_spans = result.syntax_spans_by_paragraph.into_iter().next().unwrap_or_default();
435435+ let mut syntax_spans = result
436436+ .syntax_spans_by_paragraph
437437+ .into_iter()
438438+ .next()
439439+ .unwrap_or_default();
391440 for s in &mut syntax_spans {
392441 s.adjust_positions(char_range.start as isize);
393442 }
394394- let para_refs = result.collected_refs_by_paragraph.into_iter().next().unwrap_or_default();
443443+ let para_refs = result
444444+ .collected_refs_by_paragraph
445445+ .into_iter()
446446+ .next()
447447+ .unwrap_or_default();
395448 let html = result.html_segments.into_iter().next().unwrap_or_default();
396449 (html, offset_map, syntax_spans, para_refs)
397450 }
···485538 }
486539487540 // ============ SLOW PATH ============
488488- // Full render when boundaries might have changed
541541+ // Partial render: reuse cached paragraphs before edit, parse from affected to end
489542 let render_start = crate::perf::now();
543543+544544+ // Try partial parse if we have cache and edit info
545545+ let (reused_paragraphs, parse_start_byte, parse_start_char) =
546546+ if let (Some(c), Some(e)) = (cache, edit) {
547547+ // Find the first cached paragraph that contains or is after the edit
548548+ let edit_pos = e.edit_char_pos;
549549+ let affected_idx = c
550550+ .paragraphs
551551+ .iter()
552552+ .position(|p| p.char_range.end >= edit_pos);
553553+554554+ if let Some(mut idx) = affected_idx {
555555+ // If edit is near the start of a paragraph (within first few chars),
556556+ // the previous paragraph is also affected (e.g., backspace to join)
557557+ const BOUNDARY_SLOP: usize = 3;
558558+ let para_start = c.paragraphs[idx].char_range.start;
559559+ if idx > 0 && edit_pos < para_start + BOUNDARY_SLOP {
560560+ idx -= 1;
561561+ }
562562+563563+ if idx > 0 {
564564+ // Reuse paragraphs before the affected one
565565+ let reused: Vec<_> = c.paragraphs[..idx].to_vec();
566566+ let last_reused = &c.paragraphs[idx - 1];
567567+ tracing::trace!(
568568+ reused_count = idx,
569569+ parse_start_byte = last_reused.byte_range.end,
570570+ parse_start_char = last_reused.char_range.end,
571571+ "slow path: partial parse from affected paragraph"
572572+ );
573573+ (
574574+ reused,
575575+ last_reused.byte_range.end,
576576+ last_reused.char_range.end,
577577+ )
578578+ } else {
579579+ // Edit is in first paragraph, parse everything
580580+ (Vec::new(), 0, 0)
581581+ }
582582+ } else {
583583+ // Edit is after all paragraphs (appending), parse from end
584584+ if let Some(last) = c.paragraphs.last() {
585585+ let reused = c.paragraphs.clone();
586586+ (reused, last.byte_range.end, last.char_range.end)
587587+ } else {
588588+ (Vec::new(), 0, 0)
589589+ }
590590+ }
591591+ } else {
592592+ // No cache or no edit info, parse everything
593593+ (Vec::new(), 0, 0)
594594+ };
595595+596596+ // Parse from the start point to end of document
597597+ let parse_slice = &source[parse_start_byte..];
490598 let parser =
491491- Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
599599+ Parser::new_ext(parse_slice, weaver_renderer::default_md_options()).into_offset_iter();
492600493601 // Use provided resolver or empty default
494602 let resolver = image_resolver.cloned().unwrap_or_default();
495603496496- // Build writer with all resolvers
604604+ // Create a temporary LoroText for the slice (needed by writer)
605605+ let slice_doc = loro::LoroDoc::new();
606606+ let slice_text = slice_doc.get_text("content");
607607+ let _ = slice_text.insert(0, parse_slice);
608608+609609+ // Determine starting paragraph ID for freshly parsed paragraphs
610610+ // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML
611611+ let reused_count = reused_paragraphs.len();
612612+613613+ // If reused_count = 0 (full re-render), start from 0 for DOM stability
614614+ // Otherwise, use next_para_id to avoid collisions with reused paragraphs
615615+ let parsed_para_id_start = if reused_count == 0 {
616616+ 0
617617+ } else {
618618+ cache.map(|c| c.next_para_id).unwrap_or(0)
619619+ };
620620+621621+ tracing::trace!(
622622+ parsed_para_id_start,
623623+ reused_count,
624624+ "slow path: paragraph ID allocation"
625625+ );
626626+627627+ // Find if cursor paragraph is being re-parsed (not reused)
628628+ // If so, we want it to keep its cached prefix for DOM/offset_map stability
629629+ let cursor_para_override: Option<(usize, String)> = cache.and_then(|c| {
630630+ // Find cached paragraph containing cursor
631631+ let cached_cursor_idx = c.paragraphs.iter().position(|p| {
632632+ p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end
633633+ })?;
634634+635635+ // If cursor paragraph is reused (not being re-parsed), no override needed
636636+ if cached_cursor_idx < reused_count {
637637+ return None;
638638+ }
639639+640640+ // Cursor paragraph is being re-parsed - use its cached ID
641641+ let cached_para = &c.paragraphs[cached_cursor_idx];
642642+ let parsed_index = cached_cursor_idx - reused_count;
643643+644644+ tracing::trace!(
645645+ cached_cursor_idx,
646646+ reused_count,
647647+ parsed_index,
648648+ cached_id = %cached_para.id,
649649+ "slow path: cursor paragraph override"
650650+ );
651651+652652+ Some((parsed_index, cached_para.id.clone()))
653653+ });
654654+655655+ // Build writer with all resolvers and auto-incrementing paragraph prefixes
497656 let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new(
498498- &source,
499499- text,
657657+ parse_slice,
658658+ &slice_text,
500659 parser,
501660 )
661661+ .with_auto_incrementing_prefix(parsed_para_id_start)
502662 .with_image_resolver(&resolver)
503663 .with_embed_provider(resolved_content);
504664665665+ // Apply cursor paragraph override if needed
666666+ if let Some((idx, ref prefix)) = cursor_para_override {
667667+ writer = writer.with_static_prefix_at_index(idx, prefix);
668668+ }
669669+505670 if let Some(idx) = entry_index {
506671 writer = writer.with_entry_index(idx);
507672 }
···511676 Err(_) => return (Vec::new(), RenderCache::default(), vec![]),
512677 };
513678679679+ // Get the final paragraph ID counter from the writer (accounts for all parsed paragraphs)
680680+ let parsed_para_count = writer_result.paragraph_ranges.len();
681681+514682 let render_ms = crate::perf::now() - render_start;
515683516516- let paragraph_ranges = writer_result.paragraph_ranges.clone();
684684+ // Adjust parsed paragraph ranges to be document-absolute
685685+ let parsed_paragraph_ranges: Vec<_> = writer_result
686686+ .paragraph_ranges
687687+ .iter()
688688+ .map(|(byte_range, char_range)| {
689689+ (
690690+ (byte_range.start + parse_start_byte)..(byte_range.end + parse_start_byte),
691691+ (char_range.start + parse_start_char)..(char_range.end + parse_start_char),
692692+ )
693693+ })
694694+ .collect();
517695518518- // Log discovered paragraphs
519519- for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
520520- let preview: String = text_slice_to_string(text, char_range.clone())
521521- .chars()
522522- .take(30)
523523- .collect();
524524- tracing::trace!(
525525- target: "weaver::render",
526526- para_idx = i,
527527- char_range = ?char_range,
528528- byte_range = ?byte_range,
529529- preview = %preview,
530530- "paragraph boundary"
531531- );
696696+ // Combine reused ranges with parsed ranges
697697+ let paragraph_ranges: Vec<_> = reused_paragraphs
698698+ .iter()
699699+ .map(|p| (p.byte_range.clone(), p.char_range.clone()))
700700+ .chain(parsed_paragraph_ranges.clone())
701701+ .collect();
702702+703703+ // Log discovered paragraphs (only if trace is enabled to avoid wasted work)
704704+ if tracing::enabled!(tracing::Level::TRACE) {
705705+ for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
706706+ let preview: String = text_slice_to_string(text, char_range.clone())
707707+ .chars()
708708+ .take(30)
709709+ .collect();
710710+ tracing::trace!(
711711+ target: "weaver::render",
712712+ para_idx = i,
713713+ char_range = ?char_range,
714714+ byte_range = ?byte_range,
715715+ preview = %preview,
716716+ "paragraph boundary"
717717+ );
718718+ }
532719 }
533720534534- // Build paragraphs from full render segments
721721+ // Build paragraphs from render results
535722 let build_start = crate::perf::now();
536723 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
537724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
538725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new();
539539- let mut next_para_id = cache.map(|c| c.next_para_id).unwrap_or(0);
726726+ // next_para_id must account for all IDs allocated by the writer
727727+ let mut next_para_id = parsed_para_id_start + parsed_para_count;
728728+ let reused_count = reused_paragraphs.len();
540729541730 // Find which paragraph contains cursor (for stable ID assignment)
542731 let cursor_para_idx = paragraph_ranges.iter().position(|(_, char_range)| {
543732 char_range.start <= cursor_offset && cursor_offset <= char_range.end
544733 });
545734546546- tracing::debug!(
735735+ tracing::trace!(
547736 cursor_offset,
548737 ?cursor_para_idx,
549738 edit_char_pos = ?edit.map(|e| e.edit_char_pos),
739739+ reused_count,
740740+ parsed_count = parsed_paragraph_ranges.len(),
550741 "ID assignment: cursor and edit info"
551742 );
552743···560751 let source_hash = hash_source(¶_source);
561752 let is_cursor_para = Some(idx) == cursor_para_idx;
562753563563- // ID assignment: cursor paragraph matches by edit position, others match by hash
564564- let para_id = if is_cursor_para {
565565- let edit_in_this_para = edit
566566- .map(|e| char_range.start <= e.edit_char_pos && e.edit_char_pos <= char_range.end)
567567- .unwrap_or(false);
568568- let lookup_pos = if edit_in_this_para {
569569- edit.map(|e| e.edit_char_pos).unwrap_or(cursor_offset)
754754+ // Check if this is a reused paragraph or a freshly parsed one
755755+ let is_reused = idx < reused_count;
756756+757757+ // ID assignment depends on whether this is reused or freshly parsed
758758+ let para_id = if is_reused {
759759+ // Reused paragraph: keep its existing ID (HTML already has matching prefixes)
760760+ reused_paragraphs[idx].id.clone()
761761+ } else {
762762+ // Freshly parsed: ID MUST match what the writer used for node ID prefixes
763763+ let parsed_idx = idx - reused_count;
764764+765765+ // Check if this is the cursor paragraph with an override
766766+ let id = if let Some((override_idx, ref override_prefix)) = cursor_para_override {
767767+ if parsed_idx == override_idx {
768768+ // Use the override prefix (matches what writer used)
769769+ override_prefix.clone()
770770+ } else {
771771+ // Use auto-incremented ID (matches what writer used)
772772+ make_paragraph_id(parsed_para_id_start + parsed_idx)
773773+ }
570774 } else {
571571- cursor_offset
775775+ // No override, use auto-incremented ID
776776+ make_paragraph_id(parsed_para_id_start + parsed_idx)
572777 };
573573- let found_cached = cache.and_then(|c| {
574574- c.paragraphs
575575- .iter()
576576- .find(|p| p.char_range.start <= lookup_pos && lookup_pos <= p.char_range.end)
577577- });
578778579579- if let Some(cached) = found_cached {
580580- tracing::debug!(
581581- lookup_pos,
582582- edit_in_this_para,
583583- cursor_offset,
584584- cached_id = %cached.id,
585585- cached_range = ?cached.char_range,
586586- "cursor para: reusing cached ID"
779779+ if idx < 3 || is_cursor_para {
780780+ tracing::trace!(
781781+ idx,
782782+ parsed_idx,
783783+ is_cursor_para,
784784+ para_id = %id,
785785+ "slow path: assigned paragraph ID"
587786 );
588588- cached.id.clone()
589589- } else {
590590- let id = make_paragraph_id(next_para_id);
591591- next_para_id += 1;
592592- id
593787 }
788788+789789+ id
790790+ };
791791+792792+ // Get data either from reused cache or from fresh parse
793793+ let (html, offset_map, syntax_spans, para_refs) = if is_reused {
794794+ // Reused from cache - take directly
795795+ let reused = &reused_paragraphs[idx];
796796+ (
797797+ reused.html.clone(),
798798+ reused.offset_map.clone(),
799799+ reused.syntax_spans.clone(),
800800+ reused.collected_refs.clone(),
801801+ )
594802 } else {
595595- // Non-cursor: match by content hash
596596- cached_by_hash
597597- .get(&source_hash)
598598- .map(|p| p.id.clone())
599599- .unwrap_or_else(|| {
600600- let id = make_paragraph_id(next_para_id);
601601- next_para_id += 1;
602602- id
603603- })
604604- };
803803+ // Freshly parsed - get from writer_result with offset adjustment
804804+ let parsed_idx = idx - reused_count;
805805+ let html = writer_result
806806+ .html_segments
807807+ .get(parsed_idx)
808808+ .cloned()
809809+ .unwrap_or_default();
810810+811811+ // Adjust offset maps to document-absolute positions
812812+ let mut offset_map = writer_result
813813+ .offset_maps_by_paragraph
814814+ .get(parsed_idx)
815815+ .cloned()
816816+ .unwrap_or_default();
817817+ for m in &mut offset_map {
818818+ m.char_range.start += parse_start_char;
819819+ m.char_range.end += parse_start_char;
820820+ m.byte_range.start += parse_start_byte;
821821+ m.byte_range.end += parse_start_byte;
822822+ }
605823606606- // Get data from full render segments
607607- let html = writer_result.html_segments.get(idx).cloned().unwrap_or_default();
608608- let offset_map = writer_result.offset_maps_by_paragraph.get(idx).cloned().unwrap_or_default();
609609- let syntax_spans = writer_result.syntax_spans_by_paragraph.get(idx).cloned().unwrap_or_default();
610610- let para_refs = writer_result.collected_refs_by_paragraph.get(idx).cloned().unwrap_or_default();
824824+ // Adjust syntax spans to document-absolute positions
825825+ let mut syntax_spans = writer_result
826826+ .syntax_spans_by_paragraph
827827+ .get(parsed_idx)
828828+ .cloned()
829829+ .unwrap_or_default();
830830+ for s in &mut syntax_spans {
831831+ s.adjust_positions(parse_start_char as isize);
832832+ }
833833+834834+ let para_refs = writer_result
835835+ .collected_refs_by_paragraph
836836+ .get(parsed_idx)
837837+ .cloned()
838838+ .unwrap_or_default();
839839+ (html, offset_map, syntax_spans, para_refs)
840840+ };
611841612842 all_refs.extend(para_refs.clone());
613843···635865 }
636866637867 let build_ms = crate::perf::now() - build_start;
638638- tracing::debug!(
868868+ tracing::trace!(
639869 render_ms,
640870 build_ms,
641871 paragraphs = paragraph_ranges.len(),
+1-1
crates/weaver-app/src/components/editor/worker.rs
···227227 RaceResult::CoordinatorMsg(None) => break, // Coordinator closed
228228 RaceResult::CoordinatorMsg(Some(msg)) => {
229229 // Fall through to message handling below
230230- tracing::debug!(?msg, "Worker: received message");
230230+ tracing::trace!(?msg, "Worker: received message");
231231 match msg {
232232 WorkerInput::Init {
233233 snapshot,
+66-6
crates/weaver-app/src/components/editor/writer.rs
···416416417417 // Offset mapping tracking - current paragraph
418418 offset_maps: Vec<OffsetMapping>,
419419- node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
419419+ node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
420420+ auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value
421421+ static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index
422422+ current_paragraph_index: usize, // which paragraph we're currently building (0-indexed)
420423 next_node_id: usize,
421424 current_node_id: Option<String>, // node ID for current text container
422425 current_node_char_offset: usize, // UTF-16 offset within current node
···498501 table_start_offset: None,
499502 offset_maps: Vec::new(),
500503 node_id_prefix: None,
504504+ auto_increment_prefix: None,
505505+ static_prefix_override: None,
506506+ current_paragraph_index: 0,
501507 next_node_id: node_id_offset,
502508 current_node_id: None,
503509 current_node_char_offset: 0,
···554560 table_start_offset: self.table_start_offset,
555561 offset_maps: self.offset_maps,
556562 node_id_prefix: self.node_id_prefix,
563563+ auto_increment_prefix: self.auto_increment_prefix,
564564+ static_prefix_override: self.static_prefix_override,
565565+ current_paragraph_index: self.current_paragraph_index,
557566 next_node_id: self.next_node_id,
558567 current_node_id: self.current_node_id,
559568 current_node_char_offset: self.current_node_char_offset,
···581590582591 /// Set a prefix for node IDs (typically the paragraph ID).
583592 /// This makes node IDs paragraph-scoped and stable across re-renders.
593593+ /// Use this for single-paragraph renders where the paragraph ID is known.
584594 pub fn with_node_id_prefix(mut self, prefix: &str) -> Self {
585595 self.node_id_prefix = Some(prefix.to_string());
586596 self.next_node_id = 0; // Reset counter since each paragraph is independent
587597 self
588598 }
589599600600+ /// Enable auto-incrementing paragraph prefixes for multi-paragraph renders.
601601+ /// Each paragraph gets prefix "p-{N}" where N starts at `start_id` and increments.
602602+ /// Node IDs reset to 0 for each paragraph, giving "p-{N}-n0", "p-{N}-n1", etc.
603603+ pub fn with_auto_incrementing_prefix(mut self, start_id: usize) -> Self {
604604+ self.auto_increment_prefix = Some(start_id);
605605+ self.node_id_prefix = Some(format!("p-{}", start_id));
606606+ self.next_node_id = 0;
607607+ self
608608+ }
609609+610610+ /// Get the next paragraph ID that would be assigned (for tracking allocations).
611611+ pub fn next_paragraph_id(&self) -> Option<usize> {
612612+ self.auto_increment_prefix
613613+ }
614614+615615+ /// Override the auto-incrementing prefix for a specific paragraph index.
616616+ /// Use this when you need a specific paragraph (e.g., cursor paragraph) to have
617617+ /// a stable prefix for DOM/offset_map compatibility.
618618+ pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self {
619619+ self.static_prefix_override = Some((index, prefix.to_string()));
620620+ // If this is for paragraph 0, apply it immediately
621621+ if index == 0 {
622622+ self.node_id_prefix = Some(prefix.to_string());
623623+ self.next_node_id = 0;
624624+ }
625625+ self
626626+ }
627627+590628 /// Finalize the current paragraph: move accumulated items to per-para vectors,
591629 /// start a new output segment for the next paragraph.
592630 fn finalize_paragraph(&mut self, byte_range: Range<usize>, char_range: Range<usize>) {
···600638 .push(std::mem::take(&mut self.syntax_spans));
601639 self.refs_by_para
602640 .push(std::mem::take(&mut self.ref_collector.refs));
641641+642642+ // Advance to next paragraph
643643+ self.current_paragraph_index += 1;
644644+645645+ // Determine prefix for next paragraph
646646+ if let Some((override_idx, ref override_prefix)) = self.static_prefix_override {
647647+ if self.current_paragraph_index == override_idx {
648648+ // Use the static override for this paragraph
649649+ self.node_id_prefix = Some(override_prefix.clone());
650650+ self.next_node_id = 0;
651651+ } else if let Some(ref mut current_id) = self.auto_increment_prefix {
652652+ // Use auto-increment (skip the override index to avoid collision)
653653+ *current_id += 1;
654654+ self.node_id_prefix = Some(format!("p-{}", *current_id));
655655+ self.next_node_id = 0;
656656+ }
657657+ } else if let Some(ref mut current_id) = self.auto_increment_prefix {
658658+ // Normal auto-increment
659659+ *current_id += 1;
660660+ self.node_id_prefix = Some(format!("p-{}", *current_id));
661661+ self.next_node_id = 0;
662662+ }
603663604664 // Start new output segment for next paragraph
605665 self.writer.new_segment();
···692752693753 escape_html(&mut self.writer, syntax)?;
694754755755+ // Record offset mapping BEFORE end_node (which clears current_node_id)
756756+ self.record_mapping(range.clone(), char_start..char_end);
757757+ self.last_char_offset = char_end;
758758+ self.last_byte_offset = range.end;
759759+695760 if created_node {
696761 self.write("</span>")?;
697762 self.end_node();
698763 }
699699-700700- // Record offset mapping but no syntax span info
701701- self.record_mapping(range.clone(), char_start..char_end);
702702- self.last_char_offset = char_end;
703703- self.last_byte_offset = range.end;
704764 } else {
705765 // Real syntax - wrap in hideable span
706766 let syntax_type = classify_syntax(syntax);
···108108 record.relayUrl AS relay_url,
109109 record.createdAt AS created_at,
110110 record.expiresAt AS expires_at
111111- FROM raw_records FINAL
111111+ FROM raw_records
112112 WHERE collection = 'sh.weaver.collab.session'
113113 AND is_live = 1
114114 AND record.resource.uri = ?
···116116 record.expiresAt IS NULL
117117 OR record.expiresAt > now64(3)
118118 )
119119- ORDER BY created_at DESC
119119+ ORDER BY record.createdAt.:DateTime64 DESC
120120 "#;
121121122122 let rows = self