···135136 // Find the containing element with a node ID (walk up from text node)
137 let mut current_node = node.clone();
0138 let node_id = loop {
0000000000139 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)
0000000000000000000000000000000000142 // 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");
0000000161 if is_node_id {
162 break Some(id);
163 }
164 }
165 }
1660167 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;
180181- // 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;
00000000185186- 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 }
00193194- // 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;
0000000000000200 }
201 }
202- }
203204- // Skip everything inside non-editable regions
205- if skip_until_exit.is_some() {
206- continue;
207- }
00000208209- // 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 }
215216- if let Some(text) = dom_node.text_content() {
217- utf16_offset_in_container += text.encode_utf16().count();
00000000218 }
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;
00000000000251252 // Check if this position is valid (not on invisible content)
253 if is_valid_cursor_position(¶.offset_map, char_offset) {
···135136 // 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(¶.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 }
218219+ 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;
233234+ // 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;
246247+ 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();
0251 }
252 }
253+ }
254+ utf16_offset_in_container = text_counted;
255256+ 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 }
0277278+ // 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+ }
287288+ // Skip everything inside non-editable regions
289+ if skip_until_exit.is_some() {
290+ continue;
00291 }
292293+ // 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+ );
347348 // Check if this position is valid (not on invisible content)
349 if is_valid_cursor_position(¶.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();
22600000000000000000000000000000000000227 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
228229 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())
000339 } 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),
00352 )
353 };
354···370 ¶_text,
371 parser,
372 )
0373 .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();
0000384 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();
0000391 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();
0000395 let html = result.html_segments.into_iter().next().unwrap_or_default();
396 (html, offset_map, syntax_spans, para_refs)
397 }
···485 }
486487 // ============ SLOW PATH ============
488- // Full render when boundaries might have changed
489 let render_start = crate::perf::now();
0000000000000000000000000000000000000000000000000000000490 let parser =
491- Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
492493 // Use provided resolver or empty default
494 let resolver = image_resolver.cloned().unwrap_or_default();
495496- // Build writer with all resolvers
000000000000000000000000000000000000000000000000000497 let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new(
498- &source,
499- text,
500 parser,
501 )
0502 .with_image_resolver(&resolver)
503 .with_embed_provider(resolved_content);
50400000505 if let Some(idx) = entry_index {
506 writer = writer.with_entry_index(idx);
507 }
···511 Err(_) => return (Vec::new(), RenderCache::default(), vec![]),
512 };
513000514 let render_ms = crate::perf::now() - render_start;
515516- let paragraph_ranges = writer_result.paragraph_ranges.clone();
0000000000517518- // 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- );
000000000532 }
533534- // 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);
00540541 // 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 });
545546- tracing::debug!(
547 cursor_offset,
548 ?cursor_para_idx,
549 edit_char_pos = ?edit.map(|e| e.edit_char_pos),
00550 "ID assignment: cursor and edit info"
551 );
552···560 let source_hash = hash_source(¶_source);
561 let is_cursor_para = Some(idx) == cursor_para_idx;
562563- // 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)
0000000000000570 } else {
571- cursor_offset
0572 };
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- });
578579- 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 }
00000000000000594 } 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- };
0000000000605606- // 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();
000000000000611612 all_refs.extend(para_refs.clone());
613···635 }
636637 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();
226227+ // 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());
263264 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 ¶_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 }
539540 // ============ 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();
600601 // Use provided resolver or empty default
602 let resolver = image_resolver.cloned().unwrap_or_default();
603604+ // 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);
664665+ // 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 };
678679+ // 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;
683684+ // 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();
695696+ // 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 }
720721+ // 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();
729730 // 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 });
734735+ 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(¶_source);
752 let is_cursor_para = Some(idx) == cursor_para_idx;
753754+ // 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 };
00000778779+ 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"
0786 );
00000787 }
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+ }
823824+ // 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+ };
841842 all_refs.extend(para_refs.clone());
843···865 }
866867 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
···416417 // Offset mapping tracking - current paragraph
418 offset_maps: Vec<OffsetMapping>,
419- node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
000420 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,
000501 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,
000557 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,
···581582 /// Set a prefix for node IDs (typically the paragraph ID).
583 /// This makes node IDs paragraph-scoped and stable across re-renders.
0584 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 }
5890000000000000000000000000000590 /// 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));
0000000000000000000000603604 // Start new output segment for next paragraph
605 self.writer.new_segment();
···692693 escape_html(&mut self.writer, syntax)?;
69400000695 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);
···416417 // 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,
···590591 /// 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 }
599600+ /// 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+ }
663664 // Start new output segment for next paragraph
665 self.writer.new_segment();
···752753 escape_html(&mut self.writer, syntax)?;
754755+ // 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 }
00000764 } else {
765 // Real syntax - wrap in hideable span
766 let syntax_type = classify_syntax(syntax);
···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 "#;
121122 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 "#;
121122 let rows = self