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("")?;
write!(&mut self.writer, "{}", level)?;
- 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()];