From 6483830ab6850e16fdd99467d6f05da559ac2281 Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 17 Dec 2025 22:12:40 -0500 Subject: [PATCH] bunch of bug fixes + refactor in editor renderer --- .../src/components/editor/beforeinput.rs | 89 ++- .../src/components/editor/component.rs | 68 +- .../src/components/editor/document.rs | 36 +- .../src/components/editor/dom_sync.rs | 235 +++++-- .../src/components/editor/paragraph.rs | 9 + .../src/components/editor/render.rs | 631 +++++++++++------- .../weaver-app/src/components/editor/tests.rs | 12 +- .../src/components/editor/visibility.rs | 1 + .../src/components/editor/writer.rs | 529 ++++++++------- 9 files changed, 1040 insertions(+), 570 deletions(-) diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs index 61f1ca1..09ff587 100644 --- a/crates/weaver-app/src/components/editor/beforeinput.rs +++ b/crates/weaver-app/src/components/editor/beforeinput.rs @@ -283,9 +283,23 @@ pub fn handle_beforeinput( // === Insertion === InputType::InsertText => { if let Some(text) = ctx.data { - let action = EditorAction::Insert { text, range }; + // Simple text insert - update model, let browser handle DOM + // This mirrors the simple delete handling: we track in model, + // browser handles visual update, DOM sync skips innerHTML for + // cursor paragraph when syntax is unchanged + let action = EditorAction::Insert { + text: text.clone(), + range, + }; execute_action(doc, &action); - BeforeInputResult::Handled + tracing::trace!( + text_len = text.len(), + range_start = range.start, + range_end = range.end, + cursor_after = doc.cursor.read().offset, + "insertText: updated model, PassThrough to browser" + ); + BeforeInputResult::PassThrough } else { BeforeInputResult::PassThrough } @@ -338,15 +352,64 @@ pub fn handle_beforeinput( }; } - let action = EditorAction::DeleteBackward { range }; - execute_action(doc, &action); - BeforeInputResult::Handled + // Check if this delete requires special handling (newlines, zero-width chars) + // If not, let browser handle DOM while we just track in model + let needs_special_handling = if !range.is_caret() { + // Selection delete - we handle to ensure consistency + true + } else if range.start == 0 { + // At start of document, nothing to delete + false + } else { + // Check what char we're deleting + let prev_char = super::input::get_char_at(doc.loro_text(), range.start - 1); + matches!(prev_char, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) + }; + + if needs_special_handling { + // Complex delete - we handle everything, prevent browser default + let action = EditorAction::DeleteBackward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } else { + // Simple single-char delete - track in model, let browser handle DOM + tracing::debug!( + range_start = range.start, + "deleteContentBackward: simple delete, will PassThrough to browser" + ); + if range.start > 0 { + let _ = doc.remove_tracked(range.start - 1, 1); + doc.cursor.write().offset = range.start - 1; + doc.selection.set(None); + } + tracing::debug!("deleteContentBackward: after model update, returning PassThrough"); + BeforeInputResult::PassThrough + } } InputType::DeleteContentForward => { - let action = EditorAction::DeleteForward { range }; - execute_action(doc, &action); - BeforeInputResult::Handled + // Check if this delete requires special handling + let needs_special_handling = if !range.is_caret() { + true + } else if range.start >= doc.len_chars() { + false + } else { + let next_char = super::input::get_char_at(doc.loro_text(), range.start); + matches!(next_char, Some('\n') | Some('\u{200C}') | Some('\u{200B}')) + }; + + if needs_special_handling { + let action = EditorAction::DeleteForward { range }; + execute_action(doc, &action); + BeforeInputResult::Handled + } else { + // Simple single-char delete - track in model, let browser handle DOM + if range.start < doc.len_chars() { + let _ = doc.remove_tracked(range.start, 1); + doc.selection.set(None); + } + BeforeInputResult::PassThrough + } } InputType::DeleteWordBackward | InputType::DeleteEntireWordBackward => { @@ -487,6 +550,16 @@ pub fn get_target_range_from_event( let end_container = static_range.endContainer(); let end_offset = static_range.endOffset() as usize; + // Log raw DOM position for debugging + let start_node_name = start_container.node_name(); + let start_text = start_container.text_content().unwrap_or_default(); + 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" + ); + let start = dom_position_to_text_offset( &dom_document, &editor_element, diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index e4b6d97..093331a 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -101,9 +101,7 @@ pub fn MarkdownEditor( if let Some(did) = fetcher.current_did().await { let ident = jacquard::types::ident::AtIdentifier::Did(did); match fetcher.get_notebook(ident, title.clone()).await { - Ok(Some(notebook_data)) => { - Some(notebook_data.0.uri.to_smolstr()) - } + Ok(Some(notebook_data)) => Some(notebook_data.0.uri.to_smolstr()), Ok(None) | Err(_) => { tracing::debug!("Could not resolve notebook '{}' to URI", title); None @@ -472,9 +470,11 @@ fn MarkdownEditorInner( resolved.embed_content.len() ); + let cursor_offset = doc_for_memo.cursor.read().offset; let (paras, new_cache, refs) = render::render_paragraphs_incremental( doc_for_memo.loro_text(), Some(&cache), + cursor_offset, edit.as_ref(), Some(&resolver), entry_index_for_memo.as_ref(), @@ -677,30 +677,30 @@ fn MarkdownEditorInner( update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset, false); // Only restore cursor if we actually re-rendered the paragraph it's in - if cursor_para_updated { - use wasm_bindgen::JsCast; - use wasm_bindgen::prelude::*; - - // Read and consume pending snap direction - let snap_direction = doc_for_dom.pending_snap.write().take(); - - // Use requestAnimationFrame to wait for browser paint - if let Some(window) = web_sys::window() { - let closure = Closure::once(move || { - if let Err(e) = super::cursor::restore_cursor_position( - cursor_offset, - &map, - editor_id, - snap_direction, - ) { - tracing::warn!("Cursor restoration failed: {:?}", e); - } - }); - - let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); - closure.forget(); - } - } + // if cursor_para_updated { + // use wasm_bindgen::JsCast; + // use wasm_bindgen::prelude::*; + + // // Read and consume pending snap direction + // let snap_direction = doc_for_dom.pending_snap.write().take(); + + // // Use requestAnimationFrame to wait for browser paint + // if let Some(window) = web_sys::window() { + // let closure = Closure::once(move || { + // if let Err(e) = super::cursor::restore_cursor_position( + // cursor_offset, + // &map, + // editor_id, + // snap_direction, + // ) { + // tracing::warn!("Cursor restoration failed: {:?}", e); + // } + // }); + + // let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); + // closure.forget(); + // } + // } // Store for next comparison AND for event handlers (write-only, no reactive read) cached_paragraphs.set(new_paras.clone()); @@ -1314,6 +1314,12 @@ fn MarkdownEditorInner( && matches!(evt.key(), Key::Character(ref c) if c == "a"); if navigation || select_all { + tracing::debug!( + key = ?evt.key(), + navigation, + select_all, + "onkeyup navigation - syncing cursor from DOM" + ); let paras = cached_paragraphs(); if let Some(dir) = direction_hint { sync_cursor_from_dom_with_direction(&mut doc, editor_id, ¶s, Some(dir)); @@ -1336,7 +1342,7 @@ fn MarkdownEditorInner( onselect: { let mut doc = document.clone(); move |_evt| { - tracing::trace!("onselect fired"); + tracing::debug!("onselect fired - syncing cursor from DOM"); let paras = cached_paragraphs(); sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); @@ -1354,7 +1360,7 @@ fn MarkdownEditorInner( onselectstart: { let mut doc = document.clone(); move |_evt| { - tracing::trace!("onselectstart fired"); + tracing::debug!("onselectstart fired - syncing cursor from DOM"); let paras = cached_paragraphs(); sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); @@ -1372,7 +1378,7 @@ fn MarkdownEditorInner( onselectionchange: { let mut doc = document.clone(); move |_evt| { - tracing::trace!("onselectionchange fired"); + tracing::debug!("onselectionchange fired - syncing cursor from DOM"); let paras = cached_paragraphs(); sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); @@ -1390,7 +1396,7 @@ fn MarkdownEditorInner( onclick: { let mut doc = document.clone(); move |evt| { - tracing::trace!("onclick fired"); + tracing::debug!("onclick fired - syncing cursor from DOM"); let paras = cached_paragraphs(); // Check if click target is a math-clickable element diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 228819e..e98ce76 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -844,6 +844,7 @@ impl EditorDocument { /// Call this after OUR edits where we know the new cursor position. pub fn sync_loro_cursor(&mut self) { let offset = self.cursor.read().offset; + tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); self.loro_cursor = self.content.get_cursor(offset, Side::default()); } @@ -853,7 +854,18 @@ impl EditorDocument { pub fn sync_cursor_from_loro(&mut self) -> Option { let loro_cursor = self.loro_cursor.as_ref()?; let result = self.doc.get_cursor_pos(loro_cursor).ok()?; + let old_offset = self.cursor.read().offset; let new_offset = result.current.pos.min(self.len_chars()); + let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset }; + if jump > 100 { + tracing::warn!( + old_offset, + new_offset, + jump, + "sync_cursor_from_loro: LARGE CURSOR JUMP detected" + ); + } + tracing::debug!(old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro"); self.cursor.with_mut(|c| c.offset = new_offset); Some(new_offset) } @@ -865,6 +877,7 @@ impl EditorDocument { /// Set the Loro cursor (used when restoring from storage). pub fn set_loro_cursor(&mut self, cursor: Option) { + tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); self.loro_cursor = cursor; // Sync cursor.offset from the restored Loro cursor if self.loro_cursor.is_some() { @@ -997,9 +1010,28 @@ impl EditorDocument { /// Import updates from a PDS diff blob. /// Used when loading edit history from the PDS. pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { + let len_before = self.content.len_unicode(); + let vv_before = self.doc.oplog_vv(); + self.doc.import(updates)?; - // Trigger re-render after importing remote changes - self.last_edit.set(None); + + let len_after = self.content.len_unicode(); + let vv_after = self.doc.oplog_vv(); + let vv_changed = vv_before != vv_after; + let len_changed = len_before != len_after; + + tracing::debug!( + len_before, + len_after, + len_changed, + vv_changed, + "import_updates: merge result" + ); + + // Only trigger re-render if something actually changed + if vv_changed { + self.last_edit.set(None); + } Ok(()) } diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 8a32b45..5a27d07 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -5,6 +5,7 @@ use dioxus::prelude::*; +use super::cursor::restore_cursor_position; use super::document::{EditorDocument, Selection}; use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; use super::paragraph::ParagraphRender; @@ -90,6 +91,17 @@ pub fn sync_cursor_from_dom_with_direction( match (anchor_rope, focus_rope) { (Some(anchor), Some(focus)) => { + let old_offset = doc.cursor.read().offset; + // Warn if cursor is jumping a large distance - likely a bug + let jump = if focus > old_offset { focus - old_offset } else { old_offset - focus }; + if jump > 100 { + tracing::warn!( + old_offset, + new_offset = focus, + jump, + "sync_cursor_from_dom: LARGE CURSOR JUMP detected" + ); + } doc.cursor.write().offset = focus; if anchor != focus { doc.selection.set(Some(Selection { @@ -144,7 +156,9 @@ pub fn dom_position_to_text_offset( .or_else(|| element.get_attribute("data-node-id")); if let Some(id) = id { - if id.starts_with('n') && id[1..].parse::().is_ok() { + // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs + let is_node_id = id.starts_with('n') || id.contains("-n"); + if is_node_id { break Some(id); } } @@ -206,12 +220,29 @@ pub fn dom_position_to_text_offset( } } + // Log what we're looking for + tracing::trace!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + num_paragraphs = paragraphs.len(), + "dom_position_to_text_offset: looking up mapping" + ); + for para in paragraphs { for mapping in ¶.offset_map { if mapping.node_id == node_id { let mapping_start = mapping.char_offset_in_node; let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + tracing::trace!( + mapping_node_id = %mapping.node_id, + mapping_start, + mapping_end, + char_range_start = mapping.char_range.start, + char_range_end = mapping.char_range.end, + "dom_position_to_text_offset: found matching node_id" + ); + if utf16_offset_in_container >= mapping_start && utf16_offset_in_container <= mapping_end { @@ -267,10 +298,13 @@ pub fn sync_cursor_from_dom_with_direction( ) { } -/// Update paragraph DOM elements incrementally. +/// Update paragraph DOM elements incrementally using pool-based surgical diffing. /// -/// Only modifies paragraphs that changed (by comparing source_hash). -/// Browser preserves cursor naturally in unchanged paragraphs. +/// Uses stable content-based paragraph IDs for efficient DOM reconciliation: +/// - Unchanged paragraphs (same ID + hash) are not touched +/// - Changed paragraphs (same ID, different hash) get innerHTML updated +/// - New paragraphs get created and inserted at correct position +/// - Removed paragraphs get deleted /// /// Returns true if the paragraph containing the cursor was updated. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -281,6 +315,7 @@ pub fn update_paragraph_dom( cursor_offset: usize, force: bool, ) -> bool { + use std::collections::HashMap; use wasm_bindgen::JsCast; let window = match web_sys::window() { @@ -298,64 +333,180 @@ pub fn update_paragraph_dom( None => return false, }; - // Find which paragraph contains cursor - // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) - // This handles typing at end of paragraph, which is the common case - // The empty paragraph at document end catches any trailing cursor positions - let cursor_para_idx = new_paragraphs + let mut cursor_para_updated = false; + + // Build lookup for old paragraphs by ID (for syntax span comparison) + let old_para_map: HashMap<&str, &ParagraphRender> = old_paragraphs .iter() - .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); + .map(|p| (p.id.as_str(), p)) + .collect(); + + // Build pool of existing DOM elements by ID + let mut old_elements: HashMap = HashMap::new(); + let mut child_opt = editor.first_element_child(); + while let Some(child) = child_opt { + if let Some(id) = child.get_attribute("id") { + let next = child.next_element_sibling(); + old_elements.insert(id, child); + child_opt = next; + } else { + child_opt = child.next_element_sibling(); + } + } - let mut cursor_para_updated = false; + // Track position for insertBefore - starts at first element child + // (use first_element_child to skip any stray text nodes) + let mut cursor_node: Option = + editor.first_element_child().map(|e| e.into()); + + // Single pass through new paragraphs + for new_para in new_paragraphs.iter() { + let para_id = &new_para.id; + let new_hash = format!("{:x}", new_para.source_hash); + let is_cursor_para = + new_para.char_range.start <= cursor_offset && cursor_offset <= new_para.char_range.end; + + if let Some(existing_elem) = old_elements.remove(para_id) { + // Element exists - check if it needs updating + let old_hash = existing_elem.get_attribute("data-hash").unwrap_or_default(); + let needs_update = force || old_hash != new_hash; + + // Check if element is at correct position (compare as nodes) + let existing_as_node: &web_sys::Node = existing_elem.as_ref(); + let at_correct_position = cursor_node + .as_ref() + .map(|c| c == existing_as_node) + .unwrap_or(false); + + if !at_correct_position { + tracing::warn!( + para_id, + is_cursor_para, + "update_paragraph_dom: element not at correct position, moving" + ); + let _ = editor.insert_before(existing_as_node, cursor_node.as_ref()); + if is_cursor_para { + cursor_para_updated = true; + } + } else { + // Use next_element_sibling to skip any stray text nodes + cursor_node = existing_elem.next_element_sibling().map(|e| e.into()); + } - for (idx, new_para) in new_paragraphs.iter().enumerate() { - let para_id = format!("para-{}", idx); - - if let Some(old_para) = old_paragraphs.get(idx) { - if force || new_para.source_hash != old_para.source_hash { - // Changed - clear and update innerHTML - // We clear first to ensure any browser-added content (from IME composition, - // contenteditable quirks, etc.) is fully removed before setting new content - if let Some(elem) = document.get_element_by_id(¶_id) { - if force && cursor_para_idx.is_some() { - // skip re-rendering where the cursor is if we're forcing it - // we don't want to fuck up what the user is doing + if needs_update { + // TESTING: Force innerHTML update to measure timing cost + // TODO: Remove this flag after benchmarking + const FORCE_INNERHTML_UPDATE: bool = true; + + // For cursor paragraph: only update if syntax/formatting changed + // This prevents destroying browser selection during fast typing + // + // HOWEVER: we must verify browser actually updated the DOM. + // PassThrough assumes browser handles edit, but sometimes it doesn't. + let should_skip_cursor_update = !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && { + let old_para = old_para_map.get(para_id.as_str()); + let syntax_unchanged = old_para + .map(|old| old.syntax_spans == new_para.syntax_spans) + .unwrap_or(false); + + // Verify DOM content length matches expected - if not, browser didn't handle it + // NOTE: Get inner element (the

) not outer div, to avoid counting + // the newline from

\n in the HTML + let dom_matches_expected = if syntax_unchanged { + let inner_elem = existing_elem.first_element_child(); + let dom_text = inner_elem + .as_ref() + .and_then(|e| e.text_content()) + .unwrap_or_default(); + let expected_len = new_para.byte_range.end - new_para.byte_range.start; + let dom_len = dom_text.len(); + let matches = dom_len == expected_len; + // Always log for debugging + tracing::debug!( + para_id = %para_id, + dom_len, + expected_len, + matches, + dom_text = %dom_text, + "DOM sync check" + ); + matches } else { - elem.set_text_content(None); // Clear completely - elem.set_inner_html(&new_para.html); + false + }; + + syntax_unchanged && dom_matches_expected + }; + + if should_skip_cursor_update { + tracing::trace!( + para_id, + "update_paragraph_dom: skipping cursor para innerHTML (syntax unchanged, DOM verified)" + ); + // Update hash - browser native editing has the correct content + let _ = existing_elem.set_attribute("data-hash", &new_hash); + } else { + // Timing instrumentation for innerHTML update cost + let start = web_sys::window() + .and_then(|w| w.performance()) + .map(|p| p.now()); + + existing_elem.set_inner_html(&new_para.html); + let _ = existing_elem.set_attribute("data-hash", &new_hash); + + if let Some(start_time) = start { + if let Some(end_time) = web_sys::window() + .and_then(|w| w.performance()) + .map(|p| p.now()) + { + let elapsed_ms = end_time - start_time; + tracing::debug!( + para_id, + is_cursor_para, + elapsed_ms, + html_len = new_para.html.len(), + old_hash = %old_hash, + new_hash = %new_hash, + "update_paragraph_dom: innerHTML update timing" + ); + } } - } - if !force { - if Some(idx) == cursor_para_idx { + if is_cursor_para { + // Restore cursor synchronously - don't wait for rAF + // This prevents race conditions with fast typing + if let Err(e) = restore_cursor_position( + cursor_offset, + &new_para.offset_map, + editor_id, + None, + ) { + tracing::warn!("Synchronous cursor restore failed: {:?}", e); + } cursor_para_updated = true; } } } } else { + // New element - create and insert at current position if let Ok(div) = document.create_element("div") { - div.set_id(¶_id); + div.set_id(para_id); div.set_inner_html(&new_para.html); - let _ = editor.append_child(&div); + let _ = div.set_attribute("data-hash", &new_hash); + let div_node: &web_sys::Node = div.as_ref(); + let _ = editor.insert_before(div_node, cursor_node.as_ref()); } - if Some(idx) == cursor_para_idx { + if is_cursor_para { cursor_para_updated = true; } } } - // Remove extra paragraphs if document got shorter - // Also mark cursor as needing restoration since structure changed - if new_paragraphs.len() < old_paragraphs.len() { - cursor_para_updated = true; - } - // TODO: i think this is the cause of a number of bits of cursor jank - for idx in new_paragraphs.len()..old_paragraphs.len() { - let para_id = format!("para-{}", idx); - if let Some(elem) = document.get_element_by_id(¶_id) { - let _ = elem.remove(); - } + // Remove stale elements (still in pool = not in new paragraphs) + for (_, elem) in old_elements { + let _ = elem.remove(); + cursor_para_updated = true; // Structure changed, cursor may need restoration } cursor_para_updated diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs index b7bd920..964f676 100644 --- a/crates/weaver-app/src/components/editor/paragraph.rs +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -11,6 +11,9 @@ use std::ops::Range; /// A rendered paragraph with its source range and offset mappings. #[derive(Debug, Clone, PartialEq)] pub struct ParagraphRender { + /// Stable content-based ID for DOM diffing (format: `p-{hash_prefix}-{collision_idx}`) + pub id: String, + /// Source byte range in the rope pub byte_range: Range, @@ -40,6 +43,12 @@ pub fn hash_source(text: &str) -> u64 { hasher.finish() } +/// Generate a paragraph ID from monotonic counter. +/// IDs are stable across content changes - only position/cursor determines identity. +pub fn make_paragraph_id(id: usize) -> String { + format!("p-{}", id) +} + /// Extract substring from LoroText as String pub fn text_slice_to_string(text: &LoroText, range: Range) -> String { text.slice(range.start, range.end).unwrap_or_default() diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 57d94ac..83ac3b8 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -6,10 +6,11 @@ use super::document::EditInfo; use super::offset_map::{OffsetMapping, RenderResult}; -use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; +use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; use loro::LoroText; use markdown_weaver::Parser; +use std::collections::HashMap; use std::ops::Range; use weaver_common::{EntryIndex, ResolvedContent}; @@ -23,11 +24,15 @@ pub struct RenderCache { pub next_node_id: usize, /// Next available syntax span ID for fresh renders pub next_syn_id: usize, + /// Next available paragraph ID (monotonic counter) + pub next_para_id: usize, } /// A cached paragraph render that can be reused if source hasn't changed. #[derive(Clone, Debug)] pub struct CachedParagraph { + /// Stable monotonic ID for DOM element identity + pub id: String, /// Hash of paragraph source text for change detection pub source_hash: u64, /// Byte range in source document @@ -72,12 +77,92 @@ fn apply_delta(val: usize, delta: isize) -> usize { } } +/// Insert gap paragraphs for extra whitespace between blocks. +fn add_gap_paragraphs( + paragraphs: Vec, + text: &LoroText, + source: &str, +) -> Vec { + const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n + + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); + let mut prev_end_char = 0usize; + let mut prev_end_byte = 0usize; + + for para in paragraphs { + let gap_size = para.char_range.start.saturating_sub(prev_end_char); + if gap_size > MIN_PARAGRAPH_BREAK_INCR { + let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; + let gap_end_char = para.char_range.start; + let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; + let gap_end_byte = para.byte_range.start; + + let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); + let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + id: gap_node_id.clone(), + byte_range: prev_end_byte..gap_end_byte, + char_range: prev_end_char..gap_end_char, + html: gap_html, + offset_map: vec![OffsetMapping { + byte_range: prev_end_byte..gap_end_byte, + char_range: prev_end_char..gap_end_char, + node_id: gap_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, + }], + syntax_spans: vec![], + source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)), + }); + } + + prev_end_char = para.char_range.end; + prev_end_byte = para.byte_range.end; + paragraphs_with_gaps.push(para); + } + + // Add trailing gap if needed + let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); + if has_trailing_newlines { + let doc_end_char = text.len_unicode(); + let doc_end_byte = text.len_utf8(); + + if doc_end_char > prev_end_char { + let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); + let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + id: trailing_node_id.clone(), + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + html: trailing_html, + offset_map: vec![OffsetMapping { + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + node_id: trailing_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, + }], + syntax_spans: vec![], + source_hash: 0, + }); + } + } + + paragraphs_with_gaps +} + /// Render markdown with incremental caching. /// /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. -/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. /// /// # Parameters +/// - `cursor_offset`: Current cursor position (for finding which NEW paragraph is the cursor para) +/// - `edit`: Edit info for stable ID assignment. Uses `edit_char_pos` to find which OLD cached +/// paragraph to reuse the ID from (since cursor may have moved after the edit). /// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes) /// - `resolved_content`: Pre-resolved embed content for sync rendering /// @@ -86,6 +171,7 @@ fn apply_delta(val: usize, delta: isize) -> usize { pub fn render_paragraphs_incremental( text: &LoroText, cache: Option<&RenderCache>, + cursor_offset: usize, edit: Option<&EditInfo>, image_resolver: Option<&EditorImageResolver>, entry_index: Option<&EntryIndex>, @@ -102,8 +188,10 @@ pub fn render_paragraphs_incremental( if source.is_empty() { let empty_node_id = "n0".to_string(); let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); + let para_id = make_paragraph_id(0); let para = ParagraphRender { + id: para_id.clone(), byte_range: 0..0, char_range: 0..0, html: empty_html.clone(), @@ -114,6 +202,7 @@ pub fn render_paragraphs_incremental( let new_cache = RenderCache { paragraphs: vec![CachedParagraph { + id: para_id, source_hash: 0, byte_range: 0..0, char_range: 0..0, @@ -124,6 +213,7 @@ pub fn render_paragraphs_incremental( }], next_node_id: 1, next_syn_id: 0, + next_para_id: 1, }; return (vec![para], new_cache, vec![]); @@ -132,6 +222,7 @@ pub fn render_paragraphs_incremental( // Determine if we can use fast path (skip boundary discovery) // Need cache and non-boundary-affecting edit info (for edit position) let current_len = text.len_unicode(); + let current_byte_len = text.len_utf8(); let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); @@ -156,12 +247,13 @@ pub fn render_paragraphs_incremental( // Compute delta from actual length difference, not edit info // This handles stale edits gracefully (delta = 0 if lengths match) - let cached_len = cache + let (cached_len, cached_byte_len) = cache .paragraphs .last() - .map(|p| p.char_range.end) - .unwrap_or(0); + .map(|p| (p.char_range.end, p.byte_range.end)) + .unwrap_or((0, 0)); let char_delta = current_len as isize - cached_len as isize; + let byte_delta = current_byte_len as isize - cached_byte_len as isize; // Adjust each cached paragraph's range cache @@ -173,9 +265,6 @@ pub fn render_paragraphs_incremental( (p.byte_range.clone(), p.char_range.clone()) } else if p.char_range.start > edit_pos { // After edit - shift by delta (edit is strictly before this paragraph) - // Calculate byte delta (approximation: assume 1 byte per char for ASCII) - // This is imprecise but boundaries are rediscovered on slow path anyway - let byte_delta = char_delta; // TODO: proper byte calculation ( apply_delta(p.byte_range.start, byte_delta) ..apply_delta(p.byte_range.end, byte_delta), @@ -185,7 +274,7 @@ pub fn render_paragraphs_incremental( } else { // Edit is at or within this paragraph - expand its end ( - p.byte_range.start..apply_delta(p.byte_range.end, char_delta), + p.byte_range.start..apply_delta(p.byte_range.end, byte_delta), p.char_range.start..apply_delta(p.char_range.end, char_delta), ) } @@ -196,48 +285,236 @@ pub fn render_paragraphs_incremental( }; // Validate fast path results - if any ranges are invalid, use slow path - let paragraph_ranges = if !paragraph_ranges.is_empty() { + let use_fast_path = if !paragraph_ranges.is_empty() { let all_valid = paragraph_ranges .iter() .all(|(_, char_range)| char_range.start <= char_range.end); - if all_valid { - paragraph_ranges - } else { + if !all_valid { tracing::debug!( target: "weaver::render", "fast path produced invalid ranges, falling back to slow path" ); - vec![] // Trigger slow path + false + } else { + true } } else { - paragraph_ranges + false }; - // Slow path: run boundary-only pass to discover paragraph boundaries - let paragraph_ranges = if paragraph_ranges.is_empty() { - let boundary_start = crate::perf::now(); - let parser = - Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); - let mut scratch_output = String::new(); - - let result = match EditorWriter::<_, _, ()>::new_boundary_only( - &source, - text, - parser, - &mut scratch_output, - ) - .run() - { - Ok(result) => result.paragraph_ranges, - Err(_) => return (Vec::new(), RenderCache::default(), vec![]), + // ============ FAST PATH ============ + // Reuse cached paragraphs with offset adjustment, only re-render cursor paragraph + if use_fast_path { + let fast_start = crate::perf::now(); + let cache = cache.unwrap(); + let edit = edit.unwrap(); + let edit_pos = edit.edit_char_pos; + + // Compute deltas + let (cached_len, cached_byte_len) = cache + .paragraphs + .last() + .map(|p| (p.char_range.end, p.byte_range.end)) + .unwrap_or((0, 0)); + let char_delta = current_len as isize - cached_len as isize; + let byte_delta = current_byte_len as isize - cached_byte_len as isize; + + // Find cursor paragraph index + let cursor_para_idx = cache + .paragraphs + .iter() + .position(|p| p.char_range.start <= edit_pos && edit_pos <= p.char_range.end); + + let mut paragraphs = Vec::with_capacity(cache.paragraphs.len()); + let mut new_cached = Vec::with_capacity(cache.paragraphs.len()); + let mut all_refs: Vec = Vec::new(); + + for (idx, cached_para) in cache.paragraphs.iter().enumerate() { + let is_cursor_para = Some(idx) == cursor_para_idx; + + // 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()) + } else if cached_para.char_range.start > edit_pos { + // After edit - shift by delta + ( + apply_delta(cached_para.byte_range.start, byte_delta) + ..apply_delta(cached_para.byte_range.end, byte_delta), + apply_delta(cached_para.char_range.start, char_delta) + ..apply_delta(cached_para.char_range.end, char_delta), + ) + } 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), + ) + }; + + let para_source = text_slice_to_string(text, char_range.clone()); + let source_hash = hash_source(¶_source); + + if is_cursor_para { + // Re-render cursor paragraph for fresh syntax detection + let resolver = image_resolver.cloned().unwrap_or_default(); + let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options()) + .into_offset_iter(); + + let para_doc = loro::LoroDoc::new(); + let para_text = para_doc.get_text("content"); + let _ = para_text.insert(0, ¶_source); + + let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( + ¶_source, + ¶_text, + parser, + ) + .with_image_resolver(&resolver) + .with_embed_provider(resolved_content); + + if let Some(idx) = entry_index { + writer = writer.with_entry_index(idx); + } + + 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(); + 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(); + 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 html = result.html_segments.into_iter().next().unwrap_or_default(); + (html, offset_map, syntax_spans, para_refs) + } + Err(_) => (String::new(), Vec::new(), Vec::new(), Vec::new()), + }; + + all_refs.extend(para_refs.clone()); + + new_cached.push(CachedParagraph { + id: cached_para.id.clone(), + source_hash, + byte_range: byte_range.clone(), + char_range: char_range.clone(), + html: html.clone(), + offset_map: offset_map.clone(), + syntax_spans: syntax_spans.clone(), + collected_refs: para_refs.clone(), + }); + + paragraphs.push(ParagraphRender { + id: cached_para.id.clone(), + byte_range, + char_range, + html, + offset_map, + syntax_spans, + source_hash, + }); + } else { + // Reuse cached with adjusted offsets + let mut offset_map = cached_para.offset_map.clone(); + let mut syntax_spans = cached_para.syntax_spans.clone(); + + if cached_para.char_range.start > edit_pos { + // After edit - adjust offsets + for m in &mut offset_map { + m.char_range.start = apply_delta(m.char_range.start, char_delta); + m.char_range.end = apply_delta(m.char_range.end, char_delta); + m.byte_range.start = apply_delta(m.byte_range.start, byte_delta); + m.byte_range.end = apply_delta(m.byte_range.end, byte_delta); + } + for s in &mut syntax_spans { + s.adjust_positions(char_delta); + } + } + + all_refs.extend(cached_para.collected_refs.clone()); + + new_cached.push(CachedParagraph { + id: cached_para.id.clone(), + source_hash, + byte_range: byte_range.clone(), + char_range: char_range.clone(), + html: cached_para.html.clone(), + offset_map: offset_map.clone(), + syntax_spans: syntax_spans.clone(), + collected_refs: cached_para.collected_refs.clone(), + }); + + paragraphs.push(ParagraphRender { + id: cached_para.id.clone(), + byte_range, + char_range, + html: cached_para.html.clone(), + offset_map, + syntax_spans, + source_hash, + }); + } + } + + // Add gaps (reuse gap logic from below) + let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source); + + let new_cache = RenderCache { + paragraphs: new_cached, + next_node_id: 0, + next_syn_id: 0, + next_para_id: cache.next_para_id, }; - let boundary_ms = crate::perf::now() - boundary_start; - tracing::debug!(boundary_ms, paragraphs = result.len(), "boundary discovery (slow path)"); - result - } else { - paragraph_ranges + + let fast_ms = crate::perf::now() - fast_start; + tracing::debug!( + fast_ms, + paragraphs = paragraphs_with_gaps.len(), + cursor_para_idx, + "fast path render timing" + ); + + return (paragraphs_with_gaps, new_cache, all_refs); + } + + // ============ SLOW PATH ============ + // Full render when boundaries might have changed + let render_start = crate::perf::now(); + let parser = + Parser::new_ext(&source, 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 + let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( + &source, + text, + parser, + ) + .with_image_resolver(&resolver) + .with_embed_provider(resolved_content); + + if let Some(idx) = entry_index { + writer = writer.with_entry_index(idx); + } + + let writer_result = match writer.run() { + Ok(result) => result, + Err(_) => return (Vec::new(), RenderCache::default(), vec![]), }; + let render_ms = crate::perf::now() - render_start; + + let paragraph_ranges = writer_result.paragraph_ranges.clone(); + // 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()) @@ -254,145 +531,100 @@ pub fn render_paragraphs_incremental( ); } - // Render paragraphs, reusing cache where possible - let render_loop_start = crate::perf::now(); + // Build paragraphs from full render segments + 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 node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); - let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); - let mut cache_hits = 0usize; - let mut cache_misses = 0usize; - let mut fresh_render_ms = 0.0f64; + let mut next_para_id = cache.map(|c| c.next_para_id).unwrap_or(0); + + // 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!( + cursor_offset, + ?cursor_para_idx, + edit_char_pos = ?edit.map(|e| e.edit_char_pos), + "ID assignment: cursor and edit info" + ); + + // Build hash->cached_para lookup for non-cursor matching + let cached_by_hash: HashMap = cache + .map(|c| c.paragraphs.iter().map(|p| (p.source_hash, p)).collect()) + .unwrap_or_default(); for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { let para_source = text_slice_to_string(text, char_range.clone()); 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) + } else { + cursor_offset + }; + let found_cached = cache.and_then(|c| { + c.paragraphs + .iter() + .find(|p| p.char_range.start <= lookup_pos && lookup_pos <= p.char_range.end) + }); - // Check if we have a cached render with matching hash - let cached_match = - cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); - - let (html, offset_map, syntax_spans, para_refs) = if let Some(cached) = cached_match { - cache_hits += 1; - // Reuse cached HTML, offset map, and syntax spans (adjusted for position) - let char_delta = char_range.start as isize - cached.char_range.start as isize; - let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; - - let mut adjusted_map = cached.offset_map.clone(); - for mapping in &mut adjusted_map { - mapping.char_range.start = - (mapping.char_range.start as isize + char_delta) as usize; - mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; - mapping.byte_range.start = - (mapping.byte_range.start as isize + byte_delta) as usize; - mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; - } - - let mut adjusted_syntax = cached.syntax_spans.clone(); - for span in &mut adjusted_syntax { - span.adjust_positions(char_delta); + 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" + ); + cached.id.clone() + } else { + let id = make_paragraph_id(next_para_id); + next_para_id += 1; + id } - - // Include cached refs in all_refs - all_refs.extend(cached.collected_refs.clone()); - - ( - cached.html.clone(), - adjusted_map, - adjusted_syntax, - cached.collected_refs.clone(), - ) } else { - cache_misses += 1; - let para_render_start = crate::perf::now(); - // Fresh render needed - create detached LoroDoc for this paragraph - let para_doc = loro::LoroDoc::new(); - let para_text = para_doc.get_text("content"); - let _ = para_text.insert(0, ¶_source); - - let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options()) - .into_offset_iter(); - let mut output = String::new(); - - // Use provided resolver or empty default - let resolver = image_resolver.cloned().unwrap_or_default(); - - // Build writer with optional entry index for wikilink validation - // Pass paragraph's document-level offsets so all embedded char/byte positions are absolute - let mut writer = - EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver>::new_with_all_offsets( - ¶_source, - ¶_text, - parser, - &mut output, - node_id_offset, - syn_id_offset, - char_range.start, - byte_range.start, - ) - .with_image_resolver(&resolver) - .with_embed_provider(resolved_content); + // 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 + }) + }; - if let Some(idx) = entry_index { - writer = writer.with_entry_index(idx); - } + // 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(); - let (mut offset_map, mut syntax_spans, para_refs) = match writer.run() { - Ok(result) => { - // Update node ID offset - let max_node_id = result - .offset_maps - .iter() - .filter_map(|m| { - m.node_id - .strip_prefix("n") - .and_then(|s| s.parse::().ok()) - }) - .max() - .unwrap_or(node_id_offset); - node_id_offset = max_node_id + 1; - - // Update syn ID offset - let max_syn_id = result - .syntax_spans - .iter() - .filter_map(|s| { - s.syn_id - .strip_prefix("s") - .and_then(|id| id.parse::().ok()) - }) - .max() - .unwrap_or(syn_id_offset.saturating_sub(1)); - syn_id_offset = max_syn_id + 1; - - // Collect refs from this paragraph - let para_refs = result.collected_refs; - all_refs.extend(para_refs.clone()); - - (result.offset_maps, result.syntax_spans, para_refs) - } - Err(_) => (Vec::new(), Vec::new(), Vec::new()), - }; - - // Offsets are already document-absolute since we pass char_range.start/byte_range.start - // to the writer constructor - fresh_render_ms += crate::perf::now() - para_render_start; - (output, offset_map, syntax_spans, para_refs) - }; + all_refs.extend(para_refs.clone()); // Store in cache new_cached.push(CachedParagraph { + id: para_id.clone(), source_hash, byte_range: byte_range.clone(), char_range: char_range.clone(), html: html.clone(), offset_map: offset_map.clone(), syntax_spans: syntax_spans.clone(), - collected_refs: para_refs, + collected_refs: para_refs.clone(), }); paragraphs.push(ParagraphRender { + id: para_id, byte_range: byte_range.clone(), char_range: char_range.clone(), html, @@ -402,98 +634,29 @@ pub fn render_paragraphs_incremental( }); } - // Insert gap paragraphs for EXTRA whitespace between blocks. - // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. - // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. - const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n - - let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); - let mut prev_end_char = 0usize; - let mut prev_end_byte = 0usize; - - for para in paragraphs { - // Check for gap before this paragraph - only if MORE than minimum break - let gap_size = para.char_range.start.saturating_sub(prev_end_char); - if gap_size > MIN_PARAGRAPH_BREAK_INCR { - // Visible gap element covers EXTRA whitespace beyond minimum break - let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; - let gap_end_char = para.char_range.start; - let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; - let gap_end_byte = para.byte_range.start; - - // Position-based ID: deterministic, stable across cache states - let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); - let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); - - // Gap paragraph covers ALL whitespace (like trailing gaps do) - // so cursor anywhere in the inter-paragraph zone triggers restoration - paragraphs_with_gaps.push(ParagraphRender { - byte_range: prev_end_byte..gap_end_byte, - char_range: prev_end_char..gap_end_char, - html: gap_html, - offset_map: vec![OffsetMapping { - byte_range: prev_end_byte..gap_end_byte, - char_range: prev_end_char..gap_end_char, - node_id: gap_node_id, - char_offset_in_node: 0, - child_index: None, - utf16_len: 1, - }], - syntax_spans: vec![], - source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)), - }); - } - - prev_end_char = para.char_range.end; - prev_end_byte = para.byte_range.end; - paragraphs_with_gaps.push(para); - } - - // Add trailing gap if needed - let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); - if has_trailing_newlines { - let doc_end_char = text.len_unicode(); - let doc_end_byte = text.len_utf8(); + let build_ms = crate::perf::now() - build_start; + tracing::debug!( + render_ms, + build_ms, + paragraphs = paragraph_ranges.len(), + "single-pass render timing" + ); - if doc_end_char > prev_end_char { - // Position-based ID for trailing gap - let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); - let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}'); - - paragraphs_with_gaps.push(ParagraphRender { - byte_range: prev_end_byte..doc_end_byte, - char_range: prev_end_char..doc_end_char, - html: trailing_html, - offset_map: vec![OffsetMapping { - byte_range: prev_end_byte..doc_end_byte, - char_range: prev_end_char..doc_end_char, - node_id: trailing_node_id, - char_offset_in_node: 0, - child_index: None, - utf16_len: 1, - }], - syntax_spans: vec![], - source_hash: 0, - }); - } - } + let paragraphs_with_gaps = add_gap_paragraphs(paragraphs, text, &source); let new_cache = RenderCache { paragraphs: new_cached, - next_node_id: node_id_offset, - next_syn_id: syn_id_offset, + next_node_id: 0, // Not used in single-pass mode + next_syn_id: 0, // Not used in single-pass mode + next_para_id, }; - let render_loop_ms = crate::perf::now() - render_loop_start; let total_ms = crate::perf::now() - fn_start; tracing::debug!( total_ms, - render_loop_ms, - fresh_render_ms, - cache_hits, - cache_misses, + render_ms, + build_ms, paragraphs = paragraphs_with_gaps.len(), - use_fast_path, "render_paragraphs_incremental timing" ); diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 86dcc2f..de32131 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -59,7 +59,7 @@ fn render_test(input: &str) -> Vec { let text = doc.get_text("content"); text.insert(0, input).unwrap(); let (paragraphs, _cache, _refs) = - render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); paragraphs.iter().map(TestParagraph::from).collect() } @@ -648,7 +648,7 @@ fn test_heading_to_non_heading_transition() { // Initial state: "#" is a valid empty heading text.insert(0, "#").unwrap(); let (paras1, cache1, _refs1) = - render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); eprintln!("State 1 ('#'): {}", paras1[0].html); assert!(paras1[0].html.contains(") -> ParagraphRender { ParagraphRender { + id: format!("test-{}-{}", start, end), byte_range: start..end, char_range: start..end, html: String::new(), diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 2a049f1..af74817 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -17,24 +17,91 @@ use markdown_weaver_escape::{ escape_html_body_text_with_char_count, }; use std::collections::HashMap; +use std::fmt; use std::ops::Range; use weaver_common::{EntryIndex, ResolvedContent}; +/// Writer that segments output by paragraph boundaries. +/// +/// Each paragraph's HTML is written to a separate String in the segments Vec. +/// Call `new_segment()` at paragraph boundaries to start a new segment. +#[derive(Debug, Clone, Default)] +pub struct SegmentedWriter { + segments: Vec, +} + +impl SegmentedWriter { + pub fn new() -> Self { + Self { + segments: vec![String::new()], + } + } + + /// Start a new segment for the next paragraph. + pub fn new_segment(&mut self) { + self.segments.push(String::new()); + } + + /// Get the completed segments. + pub fn into_segments(self) -> Vec { + self.segments + } + + /// Get current segment count. + pub fn segment_count(&self) -> usize { + self.segments.len() + } +} + +impl StrWrite for SegmentedWriter { + type Error = fmt::Error; + + #[inline] + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { + if let Some(segment) = self.segments.last_mut() { + segment.push_str(s); + } + Ok(()) + } + + #[inline] + fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { + if let Some(segment) = self.segments.last_mut() { + fmt::Write::write_fmt(segment, args)?; + } + Ok(()) + } +} + +impl fmt::Write for SegmentedWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + ::write_str(self, s) + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + ::write_fmt(self, args) + } +} + /// Result of rendering with the EditorWriter. #[derive(Debug, Clone)] pub struct WriterResult { - /// Offset mappings from source to DOM positions - pub offset_maps: Vec, + /// HTML segments, one per paragraph (parallel to paragraph_ranges) + pub html_segments: Vec, + + /// Offset mappings from source to DOM positions, grouped by paragraph + /// Each inner Vec corresponds to a paragraph in html_segments + pub offset_maps_by_paragraph: Vec>, /// Paragraph boundaries in source: (byte_range, char_range) /// These are extracted during rendering by tracking Tag::Paragraph events pub paragraph_ranges: Vec<(Range, Range)>, - /// Syntax spans that can be conditionally hidden - pub syntax_spans: Vec, + /// Syntax spans that can be conditionally hidden, grouped by paragraph + pub syntax_spans_by_paragraph: Vec>, - /// Refs (wikilinks, AT embeds) collected during this render pass - pub collected_refs: Vec, + /// Refs (wikilinks, AT embeds) collected during this render pass, grouped by paragraph + pub collected_refs_by_paragraph: Vec>, } /// Classification of markdown syntax characters @@ -312,17 +379,14 @@ impl ImageResolver for &EditorImageResolver { /// /// This writer processes offset-iter events to detect gaps (consumed formatting) /// and emits them as styled spans for visibility in the editor. -pub struct EditorWriter< - 'a, - I: Iterator, Range)>, - W: StrWrite, - E = (), - R = (), -> { +/// +/// Output is segmented by paragraph boundaries - each paragraph's HTML goes into +/// a separate String in the output segments Vec. +pub struct EditorWriter<'a, I: Iterator, Range)>, E = (), R = ()> { source: &'a str, source_text: &'a LoroText, events: I, - writer: W, + writer: SegmentedWriter, last_byte_offset: usize, last_char_offset: usize, @@ -350,8 +414,9 @@ pub struct EditorWriter< render_tables_as_markdown: bool, table_start_offset: Option, // track start of table for markdown rendering - // Offset mapping tracking + // Offset mapping tracking - current paragraph offset_maps: Vec, + node_id_prefix: Option, // paragraph ID prefix for stable node IDs 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 @@ -367,20 +432,21 @@ pub struct EditorWriter< current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists - /// When true, skip HTML generation and only track paragraph boundaries. - /// Used for fast boundary discovery in incremental rendering. - boundary_only: bool, - - // Syntax span tracking for conditional visibility + // Syntax span tracking for conditional visibility - current paragraph syntax_spans: Vec, next_syn_id: usize, /// Stack of pending inline formats: (syn_id of opening span, char start of region) /// Used to set formatted_range when closing paired inline markers pending_inline_formats: Vec<(String, usize)>, - /// Collected refs (wikilinks, AT embeds, AT links) during this render pass + /// Collected refs (wikilinks, AT embeds, AT links) for current paragraph ref_collector: weaver_common::RefCollector, + // Per-paragraph accumulated results (completed paragraphs) + offset_maps_by_para: Vec>, + syntax_spans_by_para: Vec>, + refs_by_para: Vec>, + _phantom: std::marker::PhantomData<&'a ()>, } @@ -390,53 +456,17 @@ enum TableState { Body, } -impl< - 'a, - I: Iterator, Range)>, - W: StrWrite, - E: EmbedContentProvider, - R: ImageResolver, -> EditorWriter<'a, I, W, E, R> +impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> + EditorWriter<'a, I, E, R> { - pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { - Self::new_with_node_offset(source, source_text, events, writer, 0) - } - - pub fn new_with_node_offset( - source: &'a str, - source_text: &'a LoroText, - events: I, - writer: W, - node_id_offset: usize, - ) -> Self { - Self::new_with_offsets(source, source_text, events, writer, node_id_offset, 0) - } - - pub fn new_with_offsets( - source: &'a str, - source_text: &'a LoroText, - events: I, - writer: W, - node_id_offset: usize, - syn_id_offset: usize, - ) -> Self { - Self::new_with_all_offsets( - source, - source_text, - events, - writer, - node_id_offset, - syn_id_offset, - 0, - 0, - ) + pub fn new(source: &'a str, source_text: &'a LoroText, events: I) -> Self { + Self::new_with_all_offsets(source, source_text, events, 0, 0, 0, 0) } pub fn new_with_all_offsets( source: &'a str, source_text: &'a LoroText, events: I, - writer: W, node_id_offset: usize, syn_id_offset: usize, char_offset_base: usize, @@ -446,7 +476,7 @@ impl< source, source_text, events, - writer, + writer: SegmentedWriter::new(), last_byte_offset: byte_offset_base, last_char_offset: char_offset_base, end_newline: true, @@ -464,9 +494,10 @@ impl< code_block_char_start: None, code_block_opening_span_idx: None, pending_blockquote_range: None, - render_tables_as_markdown: true, // Default to markdown rendering + render_tables_as_markdown: true, table_start_offset: None, offset_maps: Vec::new(), + node_id_prefix: None, next_node_id: node_id_offset, current_node_id: None, current_node_char_offset: 0, @@ -475,114 +506,28 @@ impl< paragraph_ranges: Vec::new(), current_paragraph_start: None, list_depth: 0, - boundary_only: false, syntax_spans: Vec::new(), next_syn_id: syn_id_offset, pending_inline_formats: Vec::new(), ref_collector: weaver_common::RefCollector::new(), - _phantom: std::marker::PhantomData, - } - } - - /// Create a writer that only tracks paragraph boundaries without generating HTML. - /// Used for fast boundary discovery in incremental rendering. - pub fn new_boundary_only( - source: &'a str, - source_text: &'a LoroText, - events: I, - writer: W, - ) -> Self { - Self { - source, - source_text, - events, - writer, - last_byte_offset: 0, - last_char_offset: 0, - end_newline: true, - in_non_writing_block: false, - table_state: TableState::Head, - table_alignments: vec![], - table_cell_index: 0, - numbers: HashMap::new(), - embed_provider: None, - image_resolver: None, - entry_index: None, - code_buffer: None, - code_buffer_byte_range: None, - code_buffer_char_range: None, - code_block_char_start: None, - code_block_opening_span_idx: None, - pending_blockquote_range: None, - render_tables_as_markdown: true, - table_start_offset: None, - offset_maps: Vec::new(), - next_node_id: 0, - current_node_id: None, - current_node_char_offset: 0, - current_node_child_count: 0, - utf16_checkpoints: vec![(0, 0)], - syntax_spans: Vec::new(), - next_syn_id: 0, - pending_inline_formats: Vec::new(), - ref_collector: weaver_common::RefCollector::new(), - paragraph_ranges: Vec::new(), - current_paragraph_start: None, - list_depth: 0, - boundary_only: true, + offset_maps_by_para: Vec::new(), + syntax_spans_by_para: Vec::new(), + refs_by_para: Vec::new(), _phantom: std::marker::PhantomData, } } /// Add an embed content provider - pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E, R> { - EditorWriter { - source: self.source, - source_text: self.source_text, - events: self.events, - writer: self.writer, - last_byte_offset: self.last_byte_offset, - last_char_offset: self.last_char_offset, - end_newline: self.end_newline, - in_non_writing_block: self.in_non_writing_block, - table_state: self.table_state, - table_alignments: self.table_alignments, - table_cell_index: self.table_cell_index, - numbers: self.numbers, - embed_provider: Some(provider), - image_resolver: self.image_resolver, - entry_index: self.entry_index, - code_buffer: self.code_buffer, - code_buffer_byte_range: self.code_buffer_byte_range, - code_buffer_char_range: self.code_buffer_char_range, - code_block_char_start: self.code_block_char_start, - code_block_opening_span_idx: self.code_block_opening_span_idx, - pending_blockquote_range: self.pending_blockquote_range, - render_tables_as_markdown: self.render_tables_as_markdown, - table_start_offset: self.table_start_offset, - offset_maps: self.offset_maps, - next_node_id: self.next_node_id, - current_node_id: self.current_node_id, - current_node_char_offset: self.current_node_char_offset, - current_node_child_count: self.current_node_child_count, - utf16_checkpoints: self.utf16_checkpoints, - paragraph_ranges: self.paragraph_ranges, - current_paragraph_start: self.current_paragraph_start, - list_depth: self.list_depth, - boundary_only: self.boundary_only, - syntax_spans: self.syntax_spans, - next_syn_id: self.next_syn_id, - pending_inline_formats: self.pending_inline_formats, - ref_collector: self.ref_collector, - _phantom: std::marker::PhantomData, - } + pub fn with_embed_provider(mut self, provider: E) -> EditorWriter<'a, I, E, R> { + self.embed_provider = Some(provider); + self } /// Add an image resolver for mapping markdown image URLs to CDN URLs pub fn with_image_resolver( self, resolver: R2, - ) -> EditorWriter<'a, I, W, E, R2> { + ) -> EditorWriter<'a, I, E, R2> { EditorWriter { source: self.source, source_text: self.source_text, @@ -608,6 +553,7 @@ impl< render_tables_as_markdown: self.render_tables_as_markdown, table_start_offset: self.table_start_offset, offset_maps: self.offset_maps, + node_id_prefix: self.node_id_prefix, next_node_id: self.next_node_id, current_node_id: self.current_node_id, current_node_char_offset: self.current_node_char_offset, @@ -616,11 +562,13 @@ impl< paragraph_ranges: self.paragraph_ranges, current_paragraph_start: self.current_paragraph_start, list_depth: self.list_depth, - boundary_only: self.boundary_only, syntax_spans: self.syntax_spans, next_syn_id: self.next_syn_id, pending_inline_formats: self.pending_inline_formats, ref_collector: self.ref_collector, + offset_maps_by_para: self.offset_maps_by_para, + syntax_spans_by_para: self.syntax_spans_by_para, + refs_by_para: self.refs_by_para, _phantom: std::marker::PhantomData, } } @@ -631,23 +579,43 @@ impl< self } + /// Set a prefix for node IDs (typically the paragraph ID). + /// This makes node IDs paragraph-scoped and stable across re-renders. + 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 + } + + /// 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) { + // Record paragraph boundary + self.paragraph_ranges.push((byte_range, char_range)); + + // Move current paragraph's data to per-para vectors + self.offset_maps_by_para + .push(std::mem::take(&mut self.offset_maps)); + self.syntax_spans_by_para + .push(std::mem::take(&mut self.syntax_spans)); + self.refs_by_para + .push(std::mem::take(&mut self.ref_collector.refs)); + + // Start new output segment for next paragraph + self.writer.new_segment(); + } + #[inline] - fn write_newline(&mut self) -> Result<(), W::Error> { + fn write_newline(&mut self) -> fmt::Result { self.end_newline = true; - if self.boundary_only { - return Ok(()); - } self.writer.write_str("\n") } #[inline] - fn write(&mut self, s: &str) -> Result<(), W::Error> { + fn write(&mut self, s: &str) -> fmt::Result { if !s.is_empty() { self.end_newline = s.ends_with('\n'); } - if self.boundary_only { - return Ok(()); - } self.writer.write_str(s) } @@ -691,7 +659,7 @@ impl< } /// Emit syntax span for a given range and record offset mapping - fn emit_syntax(&mut self, range: Range) -> Result<(), W::Error> { + fn emit_syntax(&mut self, range: Range) -> Result<(), fmt::Error> { if range.start < range.end { let syntax = &self.source[range.clone()]; if !syntax.is_empty() { @@ -707,13 +675,6 @@ impl< "emit_syntax" ); - // In boundary_only mode, just update offsets without HTML - if self.boundary_only { - self.last_char_offset = char_end; - self.last_byte_offset = range.end; - return Ok(()); - } - // Whitespace-only content (trailing spaces, newlines) should be emitted // as plain text, not wrapped in a hideable syntax span let is_whitespace_only = syntax.trim().is_empty(); @@ -811,7 +772,7 @@ impl< syntax: &str, byte_start: usize, syntax_type: SyntaxType, - ) -> Result<(), W::Error> { + ) -> Result<(), fmt::Error> { if syntax.is_empty() { return Ok(()); } @@ -821,13 +782,6 @@ impl< let char_end = char_start + syntax_char_len; let byte_end = byte_start + syntax.len(); - // In boundary_only mode, just update offsets - if self.boundary_only { - self.last_char_offset = char_end; - self.last_byte_offset = byte_end; - return Ok(()); - } - let class_str = match syntax_type { SyntaxType::Inline => "md-syntax-inline", SyntaxType::Block => "md-syntax-block", @@ -862,7 +816,7 @@ impl< } /// Emit any gap between last position and next offset - fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), W::Error> { + fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { // Skip gap emission if we're inside a table being rendered as markdown if self.table_start_offset.is_some() && self.render_tables_as_markdown { return Ok(()); @@ -880,9 +834,15 @@ impl< Ok(()) } - /// Generate a unique node ID + /// Generate a unique node ID. + /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. + /// Otherwise produces `n{counter}` for backwards compatibility. fn gen_node_id(&mut self) -> String { - let id = format!("n{}", self.next_node_id); + let id = if let Some(ref prefix) = self.node_id_prefix { + format!("{}-n{}", prefix, self.next_node_id) + } else { + format!("n{}", self.next_node_id) + }; self.next_node_id += 1; id } @@ -954,7 +914,7 @@ impl< /// /// Returns offset mappings and paragraph boundaries. The HTML is written /// to the writer passed in the constructor. - pub fn run(mut self) -> Result { + pub fn run(mut self) -> Result { while let Some((event, range)) = self.events.next() { tracing::trace!( target: "weaver::writer", @@ -1062,16 +1022,31 @@ impl< } } + // Add any remaining accumulated data for the last paragraph + // (content that wasn't followed by a paragraph boundary) + if !self.offset_maps.is_empty() + || !self.syntax_spans.is_empty() + || !self.ref_collector.refs.is_empty() + { + self.offset_maps_by_para.push(self.offset_maps); + self.syntax_spans_by_para.push(self.syntax_spans); + self.refs_by_para.push(self.ref_collector.refs); + } + + // Get HTML segments from writer + let html_segments = self.writer.into_segments(); + Ok(WriterResult { - offset_maps: self.offset_maps, + html_segments, + offset_maps_by_paragraph: self.offset_maps_by_para, paragraph_ranges: self.paragraph_ranges, - syntax_spans: self.syntax_spans, - collected_refs: self.ref_collector.take(), + syntax_spans_by_paragraph: self.syntax_spans_by_para, + collected_refs_by_paragraph: self.refs_by_para, }) } // Consume raw text events until end tag, for alt attributes - fn raw_text(&mut self) -> Result<(), W::Error> { + fn raw_text(&mut self) -> Result<(), fmt::Error> { use Event::*; let mut nest = 0; while let Some((event, _range)) = self.events.next() { @@ -1135,7 +1110,7 @@ impl< } } - fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), W::Error> { + fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), fmt::Error> { use Event::*; match event { @@ -1650,7 +1625,7 @@ impl< Ok(()) } - fn start_tag(&mut self, tag: Tag<'_>, range: Range) -> Result<(), W::Error> { + fn start_tag(&mut self, tag: Tag<'_>, range: Range) -> Result<(), fmt::Error> { // Check if this is a block-level tag that should have syntax inside let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); @@ -1768,13 +1743,13 @@ impl< if self.end_newline { write!( &mut self.writer, - r#"

"#, + r#"

"#, node_id )?; } else { write!( &mut self.writer, - r#"\n

"#, + r#"\n

"#, node_id )?; } @@ -2500,51 +2475,86 @@ impl< &mut self, tag: markdown_weaver::TagEnd, range: Range, - ) -> Result<(), W::Error> { + ) -> Result<(), fmt::Error> { use markdown_weaver::TagEnd; // Emit tag HTML first let result = match tag { TagEnd::HtmlBlock => { - // Record paragraph end for boundary tracking - // BUT skip if inside a list - list owns the paragraph boundary - if self.list_depth == 0 { - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); - } - } + // Capture paragraph boundary info BEFORE writing closing HTML + // Skip if inside a list - list owns the paragraph boundary + let para_boundary = if self.list_depth == 0 { + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }) + } else { + None + }; + // Write closing HTML to current segment self.end_node(); - self.write("

\n") + self.write("

\n")?; + + // Now finalize paragraph (starts new segment) + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) } TagEnd::Paragraph => { - // Record paragraph end for boundary tracking - // BUT skip if inside a list - list owns the paragraph boundary - if self.list_depth == 0 { - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); - } - } + // Capture paragraph boundary info BEFORE writing closing HTML + // Skip if inside a list - list owns the paragraph boundary + let para_boundary = if self.list_depth == 0 { + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }) + } else { + None + }; + // Write closing HTML to current segment self.end_node(); - self.write("

\n") + self.write("

\n")?; + + // Now finalize paragraph (starts new segment) + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) } TagEnd::Heading(level) => { - // Record paragraph end for boundary tracking - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); - } + // Capture paragraph boundary info BEFORE writing closing HTML + let para_boundary = + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + // Write closing HTML to current segment self.end_node(); self.write("\n") + self.write(">\n")?; + + // Now finalize paragraph (starts new segment) + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) } TagEnd::Table => { if self.render_tables_as_markdown { @@ -2594,6 +2604,7 @@ impl< TagEnd::BlockQuote(_) => { // If pending_blockquote_range is still set, the blockquote was empty // (no paragraph inside). Emit the > as its own minimal paragraph. + let mut para_boundary = None; if let Some(bq_range) = self.pending_blockquote_range.take() { if bq_range.start < bq_range.end { let raw_text = &self.source[bq_range.clone()]; @@ -2604,9 +2615,8 @@ impl< // Create a minimal paragraph for the empty blockquote let node_id = self.gen_node_id(); write!(&mut self.writer, "
\n")?; self.end_node(); - // Record paragraph boundary for incremental rendering + // Capture paragraph boundary for later finalization let byte_range = para_byte_start..bq_range.end; let char_range = para_char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); + para_boundary = Some((byte_range, char_range)); } } } - self.write("\n") + self.write("\n")?; + + // Now finalize paragraph if we had one + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) } TagEnd::CodeBlock => { use std::sync::LazyLock; @@ -2758,34 +2774,56 @@ impl< } } - // Record code block end for paragraph boundary tracking + // Finalize code block paragraph if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { let byte_range = byte_start..self.last_byte_offset; let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); + self.finalize_paragraph(byte_range, char_range); } Ok(()) } TagEnd::List(true) => { self.list_depth = self.list_depth.saturating_sub(1); - // Record list end for paragraph boundary tracking - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("\n")?; + + // Finalize paragraph after closing HTML + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); } - self.write("\n") + Ok(()) } TagEnd::List(false) => { self.list_depth = self.list_depth.saturating_sub(1); - // Record list end for paragraph boundary tracking - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.paragraph_ranges.push((byte_range, char_range)); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("\n")?; + + // Finalize paragraph after closing HTML + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); } - self.write("\n") + Ok(()) } TagEnd::Item => { self.end_node(); @@ -2878,13 +2916,8 @@ impl< } } -impl< - 'a, - I: Iterator, Range)>, - W: StrWrite, - E: EmbedContentProvider, - R: ImageResolver, -> EditorWriter<'a, I, W, E, R> +impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> + EditorWriter<'a, I, E, R> { fn write_embed( &mut self, @@ -2894,7 +2927,7 @@ impl< title: CowStr<'_>, id: CowStr<'_>, attrs: Option>, - ) -> Result<(), W::Error> { + ) -> Result<(), fmt::Error> { // Embed rendering: all syntax elements share one syn_id for visibility toggling // Structure: ![[ url-as-link ]] let raw_text = &self.source[range.clone()]; -- knotmirror 0.0.1