diff --git a/Cargo.lock b/Cargo.lock index 8de1e23..9123762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4989,16 +4989,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jumprope" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829c74fe88dda0d2a5425b022b44921574a65c4eb78e6e39a61b40eb416a4ef8" -dependencies = [ - "rand 0.8.5", - "str_indices", -] - [[package]] name = "k256" version = "0.13.4" @@ -8348,12 +8338,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "str_indices" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" - [[package]] name = "string_cache" version = "0.8.9" @@ -9963,7 +9947,6 @@ dependencies = [ "jacquard-identity", "jacquard-lexicon", "js-sys", - "jumprope", "lol_alloc", "loro", "markdown-weaver", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index c201ce8..b076d4d 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -49,7 +49,6 @@ tokio = { version = "1.28", features = ["sync"] } serde_html_form = "0.2.8" tracing.workspace = true serde_ipld_dagcbor = { version = "0.6" } -jumprope = { version = "1.1", features = ["wchar_conversion"] } loro = "1.9.1" markdown-weaver-escape = { workspace = true } diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index b734972..6e16ed8 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -8,7 +8,6 @@ //! 4. Setting cursor with web_sys Selection API use super::offset_map::{find_mapping_for_char, OffsetMapping}; -use jumprope::JumpRopeBuf; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use wasm_bindgen::JsCast; @@ -16,8 +15,7 @@ use wasm_bindgen::JsCast; /// Restore cursor position in the DOM after re-render. /// /// # Arguments -/// - `rope`: The document content (for length bounds checking) -/// - `char_offset`: Cursor position as char offset in rope +/// - `char_offset`: Cursor position as char offset in document /// - `offset_map`: Mappings from source to DOM positions /// - `editor_id`: DOM ID of the contenteditable element /// @@ -28,19 +26,20 @@ use wasm_bindgen::JsCast; /// 4. Set cursor with Selection API #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn restore_cursor_position( - rope: &JumpRopeBuf, char_offset: usize, offset_map: &[OffsetMapping], editor_id: &str, ) -> Result<(), wasm_bindgen::JsValue> { - // Bounds check - let max_offset = rope.len_chars(); - if char_offset > max_offset { - return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into()); + // Empty document - no cursor to restore + if offset_map.is_empty() { + return Ok(()); } - // Empty document - no cursor to restore - if offset_map.is_empty() || max_offset == 0 { + // Bounds check using offset map + let max_offset = offset_map.iter().map(|m| m.char_range.end).max().unwrap_or(0); + if char_offset > max_offset { + tracing::warn!("cursor offset {} > max mapping offset {}", char_offset, max_offset); + // Don't error, just skip restoration - this can happen during edits return Ok(()); } @@ -159,7 +158,6 @@ fn find_text_node_at_offset( /// Non-WASM stub for testing #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub fn restore_cursor_position( - _rope: &JumpRopeBuf, _char_offset: usize, _offset_map: &[OffsetMapping], _editor_id: &str, diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 4f71d09..5914413 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -1,15 +1,25 @@ //! Core data structures for the markdown editor. +//! +//! Uses Loro CRDT for text storage with built-in undo/redo support. -use jumprope::JumpRopeBuf; +use loro::{LoroDoc, LoroResult, LoroText, UndoManager}; /// Single source of truth for editor state. /// -/// Contains the document text, cursor position, selection, and IME composition state. -#[derive(Clone, Debug)] +/// Contains the document text (backed by Loro CRDT), cursor position, +/// selection, and IME composition state. +#[derive(Debug)] pub struct EditorDocument { - /// The rope storing document text (uses char offsets, not bytes). - /// Uses JumpRopeBuf to batch consecutive edits for performance. - pub rope: JumpRopeBuf, + /// The Loro document containing all editor state. + /// Using full LoroDoc (not just LoroText) to support future + /// expansion to blobs, metadata, etc. + doc: LoroDoc, + + /// Handle to the text container within the doc. + text: LoroText, + + /// Undo manager for the document. + undo_mgr: UndoManager, /// Current cursor position (char offset) pub cursor: CursorState, @@ -28,7 +38,7 @@ pub struct EditorDocument { /// Cursor state including position and affinity. #[derive(Clone, Debug, Copy)] pub struct CursorState { - /// Character offset in rope (NOT byte offset!) + /// Character offset in text (NOT byte offset!) pub offset: usize, /// Prefer left/right when at boundary (for vertical cursor movement) @@ -85,19 +95,18 @@ impl EditorDocument { return true; } - // Find distance from previous newline by scanning forward and tracking last newline - let rope = self.rope.borrow(); + let content = self.text.to_string(); let mut last_newline_pos: Option = None; - for (i, c) in rope.slice_chars(0..pos).enumerate() { + for (i, c) in content.chars().take(pos).enumerate() { if c == '\n' { last_newline_pos = Some(i); } } let chars_from_line_start = match last_newline_pos { - Some(nl_pos) => pos - nl_pos - 1, // -1 because newline itself is not part of current line - None => pos, // No newline found, distance is from document start + Some(nl_pos) => pos - nl_pos - 1, + None => pos, }; chars_from_line_start <= BLOCK_SYNTAX_ZONE @@ -105,8 +114,23 @@ impl EditorDocument { /// Create a new editor document with the given content. pub fn new(content: String) -> Self { + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + + // Insert initial content if any + if !content.is_empty() { + text.insert(0, &content).expect("failed to insert initial content"); + } + + // Set up undo manager with merge interval for batching keystrokes + let mut undo_mgr = UndoManager::new(&doc); + undo_mgr.set_merge_interval(300); // 300ms merge window + undo_mgr.set_max_undo_steps(100); + Self { - rope: JumpRopeBuf::from(content.as_str()), + doc, + text, + undo_mgr, cursor: CursorState { offset: 0, affinity: Affinity::Before, @@ -117,23 +141,38 @@ impl EditorDocument { } } + /// Get the underlying LoroText for read operations. + pub fn loro_text(&self) -> &LoroText { + &self.text + } + /// Convert the document to a string. pub fn to_string(&self) -> String { - self.rope.to_string() + self.text.to_string() } /// Get the length of the document in characters. pub fn len_chars(&self) -> usize { - self.rope.len_chars() + self.text.len_unicode() + } + + /// Get the length of the document in UTF-8 bytes. + pub fn len_bytes(&self) -> usize { + self.text.len_utf8() + } + + /// Get the length of the document in UTF-16 code units. + pub fn len_utf16(&self) -> usize { + self.text.len_utf16() } /// Check if the document is empty. pub fn is_empty(&self) -> bool { - self.rope.len_chars() == 0 + self.text.len_unicode() == 0 } /// Insert text and record edit info for incremental rendering. - pub fn insert_tracked(&mut self, pos: usize, text: &str) { + pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); self.last_edit = Some(EditInfo { edit_char_pos: pos, @@ -142,36 +181,94 @@ impl EditorDocument { contains_newline: text.contains('\n'), in_block_syntax_zone, }); - self.rope.insert(pos, text); + self.text.insert(pos, text) } /// Remove text range and record edit info for incremental rendering. - pub fn remove_tracked(&mut self, range: std::ops::Range) { - // Check if deleted region contains newline - borrow inner JumpRope - let contains_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); - let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { + let content = self.text.to_string(); + let end = start + len; + let contains_newline = content + .chars() + .skip(start) + .take(len) + .any(|c| c == '\n'); + let in_block_syntax_zone = self.is_in_block_syntax_zone(start); + self.last_edit = Some(EditInfo { - edit_char_pos: range.start, + edit_char_pos: start, inserted_len: 0, - deleted_len: range.end - range.start, + deleted_len: len, contains_newline, in_block_syntax_zone, }); - self.rope.remove(range); + self.text.delete(start, len) } /// Replace text (delete then insert) and record combined edit info. - pub fn replace_tracked(&mut self, range: std::ops::Range, text: &str) { - let delete_has_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); - let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { + let content = self.text.to_string(); + let delete_has_newline = content + .chars() + .skip(start) + .take(len) + .any(|c| c == '\n'); + let in_block_syntax_zone = self.is_in_block_syntax_zone(start); + self.last_edit = Some(EditInfo { - edit_char_pos: range.start, + edit_char_pos: start, inserted_len: text.chars().count(), - deleted_len: range.end - range.start, + deleted_len: len, contains_newline: delete_has_newline || text.contains('\n'), in_block_syntax_zone, }); - self.rope.remove(range); - self.rope.insert(self.last_edit.as_ref().unwrap().edit_char_pos, text); + + // Use splice for atomic replace + self.text.splice(start, len, text)?; + Ok(()) + } + + /// Undo the last operation. + /// Returns true if an undo was performed. + pub fn undo(&mut self) -> LoroResult { + self.undo_mgr.undo() + } + + /// Redo the last undone operation. + /// Returns true if a redo was performed. + pub fn redo(&mut self) -> LoroResult { + self.undo_mgr.redo() + } + + /// Check if undo is available. + pub fn can_undo(&self) -> bool { + self.undo_mgr.can_undo() + } + + /// Check if redo is available. + pub fn can_redo(&self) -> bool { + self.undo_mgr.can_redo() + } + + /// Get a slice of the document text. + /// Returns None if the range is invalid. + pub fn slice(&self, start: usize, end: usize) -> Option { + self.text.slice(start, end).ok() + } +} + +// EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. +// This is intentional - the document should be the single source of truth. + +impl Clone for EditorDocument { + fn clone(&self) -> Self { + // Create a new document with the same content + let content = self.to_string(); + let mut new_doc = Self::new(content); + new_doc.cursor = self.cursor; + new_doc.selection = self.selection; + new_doc.composition = self.composition.clone(); + new_doc.last_edit = self.last_edit.clone(); + new_doc } } diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs index a9a62b8..52fb005 100644 --- a/crates/weaver-app/src/components/editor/formatting.rs +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -21,32 +21,32 @@ pub enum FormatAction { /// /// Expands to whitespace boundaries. Used when applying formatting /// without a selection. -pub fn find_word_boundaries(rope: &jumprope::JumpRopeBuf, offset: usize) -> (usize, usize) { - let rope = rope.borrow(); - let mut start = 0; - let mut end = rope.len_chars(); +pub fn find_word_boundaries(text: &loro::LoroText, offset: usize) -> (usize, usize) { + let len = text.len_unicode(); - // Find start by scanning backwards - let mut char_pos = 0; - for substr in rope.slice_substrings(0..offset) { - for c in substr.chars() { - if c.is_whitespace() { - start = char_pos + 1; + // Find start by scanning backwards using char_at + let mut start = 0; + for i in (0..offset).rev() { + match text.char_at(i) { + Ok(c) if c.is_whitespace() => { + start = i + 1; + break; } - char_pos += 1; + Ok(_) => continue, + Err(_) => break, } } - // Find end by scanning forwards - char_pos = offset; - let byte_len = rope.len_bytes(); - for substr in rope.slice_substrings(offset..byte_len) { - for c in substr.chars() { - if c.is_whitespace() { - end = char_pos; - return (start, end); + // Find end by scanning forwards using char_at + let mut end = len; + for i in offset..len { + match text.char_at(i) { + Ok(c) if c.is_whitespace() => { + end = i; + break; } - char_pos += 1; + Ok(_) => continue, + Err(_) => break, } } @@ -62,91 +62,96 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) } else { // Expand to word - find_word_boundaries(&doc.rope, doc.cursor.offset) + find_word_boundaries(doc.loro_text(), doc.cursor.offset) }; match action { FormatAction::Bold => { - doc.rope.insert(end, "**"); - doc.rope.insert(start, "**"); + // Insert end marker first so start position stays valid + let _ = doc.insert_tracked(end, "**"); + let _ = doc.insert_tracked(start, "**"); doc.cursor.offset = end + 4; doc.selection = None; } FormatAction::Italic => { - doc.rope.insert(end, "*"); - doc.rope.insert(start, "*"); + let _ = doc.insert_tracked(end, "*"); + let _ = doc.insert_tracked(start, "*"); doc.cursor.offset = end + 2; doc.selection = None; } FormatAction::Strikethrough => { - doc.rope.insert(end, "~~"); - doc.rope.insert(start, "~~"); + let _ = doc.insert_tracked(end, "~~"); + let _ = doc.insert_tracked(start, "~~"); doc.cursor.offset = end + 4; doc.selection = None; } FormatAction::Code => { - doc.rope.insert(end, "`"); - doc.rope.insert(start, "`"); + let _ = doc.insert_tracked(end, "`"); + let _ = doc.insert_tracked(start, "`"); doc.cursor.offset = end + 2; doc.selection = None; } FormatAction::Link => { // Insert [selected text](url) - doc.rope.insert(end, "](url)"); - doc.rope.insert(start, "["); + let _ = doc.insert_tracked(end, "](url)"); + let _ = doc.insert_tracked(start, "["); doc.cursor.offset = end + 8; // Position cursor after ](url) doc.selection = None; } FormatAction::Image => { // Insert ![alt text](url) - doc.rope.insert(end, "](url)"); - doc.rope.insert(start, "!["); + let _ = doc.insert_tracked(end, "](url)"); + let _ = doc.insert_tracked(start, "!["); doc.cursor.offset = end + 9; doc.selection = None; } FormatAction::Heading(level) => { // Find start of current line - let line_start = find_line_start(&doc.rope, doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); let prefix = "#".repeat(level as usize) + " "; - doc.rope.insert(line_start, &prefix); + let _ = doc.insert_tracked(line_start, &prefix); doc.cursor.offset += prefix.len(); doc.selection = None; } FormatAction::BulletList => { - let line_start = find_line_start(&doc.rope, doc.cursor.offset); - doc.rope.insert(line_start, "- "); + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let _ = doc.insert_tracked(line_start, "- "); doc.cursor.offset += 2; doc.selection = None; } FormatAction::NumberedList => { - let line_start = find_line_start(&doc.rope, doc.cursor.offset); - doc.rope.insert(line_start, "1. "); + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let _ = doc.insert_tracked(line_start, "1. "); doc.cursor.offset += 3; doc.selection = None; } FormatAction::Quote => { - let line_start = find_line_start(&doc.rope, doc.cursor.offset); - doc.rope.insert(line_start, "> "); + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let _ = doc.insert_tracked(line_start, "> "); doc.cursor.offset += 2; doc.selection = None; } } } -/// Find start of line containing offset (same as in mod.rs) -fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { - let mut char_pos = 0; - let mut last_newline_pos = None; - - let rope = rope.borrow(); - for substr in rope.slice_substrings(0..offset) { - for c in substr.chars() { - if c == '\n' { - last_newline_pos = Some(char_pos); - } - char_pos += 1; - } +/// Find start of line containing offset +fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { + if offset == 0 { + return 0; } - last_newline_pos.map(|pos| pos + 1).unwrap_or(0) + // Get text up to offset + let prefix = match text.slice(0, offset) { + Ok(s) => s, + Err(_) => return 0, + }; + + // Find last newline + prefix + .chars() + .enumerate() + .filter(|(_, c)| *c == '\n') + .last() + .map(|(pos, _)| pos + 1) + .unwrap_or(0) } diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 615d9a1..432768e 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -10,7 +10,6 @@ mod formatting; mod offset_map; mod paragraph; mod render; -mod rope_writer; mod storage; mod toolbar; mod visibility; @@ -24,7 +23,6 @@ pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; pub use paragraph::ParagraphRender; pub use render::{RenderCache, render_paragraphs_incremental}; -pub use rope_writer::RopeWriter; pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; pub use toolbar::EditorToolbar; pub use visibility::VisibilityState; @@ -38,7 +36,7 @@ use dioxus::prelude::*; /// - `initial_content`: Optional initial markdown content /// /// # Features -/// - JumpRope-based text storage for efficient editing +/// - Loro CRDT-based text storage with undo/redo support /// - Event interception for full control over editing operations /// - Toolbar formatting buttons /// - LocalStorage auto-save with debouncing @@ -75,7 +73,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let edit = doc.last_edit.as_ref(); let (paras, new_cache) = - render::render_paragraphs_incremental(&doc.rope, Some(&cache), edit); + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); // Update cache for next render (write-only via spawn to avoid reactive loop) dioxus::prelude::spawn(async move { @@ -107,8 +105,15 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Update DOM when paragraphs change (incremental rendering) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { + // Read document once to avoid multiple borrows + let doc = document(); + let cursor_offset = doc.cursor.offset; + let selection = doc.selection; + drop(doc); // Release borrow before other operations + let new_paras = paragraphs(); - let cursor_offset = document().cursor.offset; + let map = offset_map(); + let spans = syntax_spans(); // Use peek() to avoid creating reactive dependency on cached_paragraphs let prev = cached_paragraphs.peek().clone(); @@ -120,14 +125,11 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; - let rope = document().rope.clone(); - let map = offset_map(); - // Use requestAnimationFrame to wait for browser paint if let Some(window) = web_sys::window() { let closure = Closure::once(move || { if let Err(e) = - cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) + cursor::restore_cursor_position(cursor_offset, &map, editor_id) { tracing::warn!("Cursor restoration failed: {:?}", e); } @@ -142,11 +144,18 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { cached_paragraphs.set(new_paras.clone()); // Update syntax visibility after DOM changes - let doc = document(); - let spans = syntax_spans(); + // Debug: log what syntax spans we have + for span in spans.iter() { + tracing::debug!( + "[VISIBILITY_INPUT] span {} char_range {:?} formatted_range {:?}", + span.syn_id, + span.char_range, + span.formatted_range + ); + } update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), + cursor_offset, + selection.as_ref(), &spans, &new_paras, ); @@ -155,11 +164,15 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Auto-save with debounce #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { + // Read document once and extract what we need let doc = document(); + let content = doc.to_string(); + let cursor = doc.cursor.offset; + drop(doc); // Save after 500ms of no typing let timer = gloo_timers::callback::Timeout::new(500, move || { - let _ = storage::save_to_storage(&doc.to_string(), doc.cursor.offset); + let _ = storage::save_to_storage(&content, cursor); }); timer.forget(); }); @@ -213,22 +226,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { }, onclick: move |_evt| { - // After mouse click, sync cursor from DOM - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - // Update syntax visibility after cursor sync - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - }, - - onmouseup: move |_evt| { - // After drag selection, sync cursor/selection from DOM + // After mouse click or drag selection, sync cursor from DOM + // (click fires after mouseup, so this handles both cases) let paras = cached_paragraphs(); sync_cursor_from_dom(&mut document, editor_id, ¶s); // Update syntax visibility after cursor sync @@ -528,13 +527,13 @@ fn handle_paste(evt: Event, document: &mut Signal // Delete selection if present if let Some(sel) = doc.selection { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - doc.rope.remove(start..end); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); doc.cursor.offset = start; doc.selection = None; } // Insert pasted text - doc.rope.insert(doc.cursor.offset, &text); + let _ = doc.insert_tracked(doc.cursor.offset, &text); doc.cursor.offset += text.chars().count(); }); } @@ -545,7 +544,7 @@ fn handle_paste(evt: Event, document: &mut Signal } } -/// Handle cut events - extract text, write to clipboard, then delete from rope +/// Handle cut events - extract text, write to clipboard, then delete fn handle_cut(evt: Event, document: &mut Signal) { tracing::info!("[CUT] handle_cut called"); @@ -560,8 +559,8 @@ fn handle_cut(evt: Event, document: &mut Signal) if let Some(sel) = doc.selection { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); if start != end { - // Extract text from rope - let selected_text = extract_rope_slice(&doc.rope, start, end); + // Extract text + let selected_text = doc.slice(start, end).unwrap_or_default(); tracing::info!( "[CUT] Extracted {} chars: {:?}", selected_text.len(), @@ -575,8 +574,8 @@ fn handle_cut(evt: Event, document: &mut Signal) } } - // Now delete from rope - doc.rope.remove(start..end); + // Now delete + let _ = doc.remove_tracked(start, end.saturating_sub(start)); doc.cursor.offset = start; doc.selection = None; } @@ -591,7 +590,7 @@ fn handle_cut(evt: Event, document: &mut Signal) } } -/// Handle copy events - extract text from rope, clean it up, write to clipboard +/// Handle copy events - extract text, clean it up, write to clipboard fn handle_copy(evt: Event, document: &Signal) { tracing::info!("[COPY] handle_copy called"); @@ -606,8 +605,8 @@ fn handle_copy(evt: Event, document: &Signal) { if let Some(sel) = doc.selection { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); if start != end { - // Extract text from rope - let selected_text = extract_rope_slice(&doc.rope, start, end); + // Extract text + let selected_text = doc.slice(start, end).unwrap_or_default(); // Strip zero-width chars used for gap handling let clean_text = selected_text @@ -640,14 +639,9 @@ fn handle_copy(evt: Event, document: &Signal) { } } -/// Extract a slice of text from the rope as a String -fn extract_rope_slice(rope: &jumprope::JumpRopeBuf, start: usize, end: usize) -> String { - let mut result = String::new(); - let rope_ref = rope.borrow(); - for substr in rope_ref.slice_substrings(start..end) { - result.push_str(substr); - } - result +/// Extract a slice of text from a string by char indices +fn extract_text_slice(text: &str, start: usize, end: usize) -> String { + text.chars().skip(start).take(end.saturating_sub(start)).collect() } /// Handle keyboard events and update document state @@ -675,30 +669,27 @@ fn handle_keydown(evt: Event, document: &mut Signal { if let Some(sel) = doc.selection { // Delete selection let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - doc.rope.remove(start..end); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); doc.cursor.offset = start; doc.selection = None; } else if doc.cursor.offset > 0 { // Check if we're about to delete a newline - let prev_char = get_char_at(&doc.rope, doc.cursor.offset - 1); + let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); if prev_char == Some('\n') { let newline_pos = doc.cursor.offset - 1; @@ -708,7 +699,7 @@ fn handle_keydown(evt: Event, document: &mut Signal 0 { - let prev_prev_char = get_char_at(&doc.rope, newline_pos - 1); + let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); if prev_prev_char == Some('\n') { // Empty paragraph case: delete both newlines delete_start = newline_pos - 1; @@ -716,7 +707,7 @@ fn handle_keydown(evt: Event, document: &mut Signal, document: &mut Signal 0 { - let ch = get_char_at(&doc.rope, delete_start - 1); + let ch = get_char_at(doc.loro_text(), delete_start - 1); match ch { Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => { delete_start -= 1; @@ -735,27 +726,26 @@ fn handle_keydown(evt: Event, document: &mut Signal { - if let Some(sel) = doc.selection { + if let Some(sel) = doc.selection.take() { // Delete selection let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - doc.rope.remove(start..end); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); doc.cursor.offset = start; - doc.selection = None; } else if doc.cursor.offset < doc.len_chars() { // Delete next char - doc.rope.remove(doc.cursor.offset..doc.cursor.offset + 1); + let _ = doc.remove_tracked(doc.cursor.offset, 1); } } @@ -765,44 +755,37 @@ fn handle_keydown(evt: Event, document: &mut Signal { - if doc.selection.is_some() { - let sel = doc.selection.unwrap(); + if let Some(sel) = doc.selection.take() { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - doc.rope.remove(start..end); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); doc.cursor.offset = start; - doc.selection = None; } if mods.shift() { // Shift+Enter: hard line break (soft break) - doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); doc.cursor.offset += 3; - } else if let Some(ctx) = detect_list_context(&doc.rope, doc.cursor.offset) { + } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { // We're in a list item tracing::debug!("[ENTER] List context detected: {:?}", ctx); tracing::debug!( - "[ENTER] Cursor at {}, rope len {}", + "[ENTER] Cursor at {}, doc len {}", doc.cursor.offset, - doc.rope.len_chars() + doc.len_chars() ); - if is_list_item_empty(&doc.rope, doc.cursor.offset, &ctx) { + if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { tracing::debug!("[ENTER] Item is empty, exiting list"); // Empty item - exit list by removing marker and inserting paragraph break - let line_start = find_line_start(&doc.rope, doc.cursor.offset); - let line_end = find_line_end(&doc.rope, doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); // Delete the empty list item line INCLUDING its trailing newline // line_end points to the newline, so +1 to include it - let delete_end = (line_end + 1).min(doc.rope.len_chars()); + let delete_end = (line_end + 1).min(doc.len_chars()); - doc.rope.remove(line_start..delete_end); - doc.cursor.offset = line_start; - - // Insert two newlines, a zero-width whitespace character, and then another - // newline to properly split the list (TODO: clean up the weird whitespace - // char once that new paragraph has content) - doc.rope.insert(doc.cursor.offset, "\n\n\u{200C}\n"); - doc.cursor.offset += 2; + // Use replace_tracked to atomically delete line and insert paragraph break + let _ = doc.replace_tracked(line_start, delete_end.saturating_sub(line_start), "\n\n\u{200C}\n"); + doc.cursor.offset = line_start + 2; } else { // Non-empty item - continue list let continuation = match ctx { @@ -814,12 +797,12 @@ fn handle_keydown(evt: Event, document: &mut Signal Option { +fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option { // Find start of current line - let line_start = find_line_start(rope, cursor_offset); + let line_start = find_line_start(text, cursor_offset); // Get the line content from start to cursor - let line_end = find_line_end(rope, cursor_offset); + let line_end = find_line_end(text, cursor_offset); if line_start >= line_end { return None; } // Extract line text - let mut line = String::new(); - let rope_ref = rope.borrow(); - for substr in rope_ref.slice_substrings(line_start..line_end) { - line.push_str(substr); - } + let line = text.slice(line_start, line_end).ok()?; // Parse indentation let indent: String = line @@ -901,19 +880,18 @@ fn detect_list_context(rope: &jumprope::JumpRopeBuf, cursor_offset: usize) -> Op /// /// Used to determine whether Enter should continue the list or exit it. fn is_list_item_empty( - rope: &jumprope::JumpRopeBuf, + text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext, ) -> bool { - let line_start = find_line_start(rope, cursor_offset); - let line_end = find_line_end(rope, cursor_offset); + let line_start = find_line_start(text, cursor_offset); + let line_end = find_line_end(text, cursor_offset); // Get line content - let mut line = String::new(); - let rope_ref = rope.borrow(); - for substr in rope_ref.slice_substrings(line_start..line_end) { - line.push_str(substr); - } + let line = match text.slice(line_start, line_end) { + Ok(s) => s, + Err(_) => return false, + }; // Calculate expected marker length let marker_len = match ctx { @@ -935,63 +913,47 @@ fn is_list_item_empty( line.len() <= marker_len } -/// Get character at the given offset in the rope -fn get_char_at(rope: &jumprope::JumpRopeBuf, offset: usize) -> Option { - if offset >= rope.len_chars() { - return None; - } - - let rope = rope.borrow(); - let mut current = 0; - for substr in rope.slice_substrings(offset..offset + 1) { - for c in substr.chars() { - if current == 0 { - return Some(c); - } - current += 1; - } - } - None +/// Get character at the given offset in LoroText +fn get_char_at(text: &loro::LoroText, offset: usize) -> Option { + text.char_at(offset).ok() } /// Find start of line containing offset -fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { - // Search backwards from cursor for newline - let mut char_pos = 0; - let mut last_newline_pos = None; - - let rope = rope.borrow(); - for substr in rope.slice_substrings(0..offset) { - // TODO: make more efficient - for c in substr.chars() { - if c == '\n' { - last_newline_pos = Some(char_pos); - } - char_pos += 1; - } +fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { + if offset == 0 { + return 0; } - - last_newline_pos.map(|pos| pos + 1).unwrap_or(0) + // Only slice the portion before cursor + let prefix = match text.slice(0, offset) { + Ok(s) => s, + Err(_) => return 0, + }; + prefix + .chars() + .enumerate() + .filter(|(_, c)| *c == '\n') + .last() + .map(|(pos, _)| pos + 1) + .unwrap_or(0) } /// Find end of line containing offset -fn find_line_end(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { - // Search forwards from cursor for newline - let mut char_pos = offset; - - let rope = rope.borrow(); - let byte_len = rope.len_bytes() - 1; - for substr in rope.slice_substrings(offset..byte_len) { - // TODO: make more efficient - for c in substr.chars() { - if c == '\n' { - return char_pos; - } - char_pos += 1; - } +fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { + let char_len = text.len_unicode(); + if offset >= char_len { + return char_len; } - - rope.len_chars() + // Only slice from cursor to end + let suffix = match text.slice(offset, char_len) { + Ok(s) => s, + Err(_) => return char_len, + }; + suffix + .chars() + .enumerate() + .find(|(_, c)| *c == '\n') + .map(|(i, _)| offset + i) + .unwrap_or(char_len) } /// Update paragraph DOM elements incrementally. diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs index dceafeb..67b27ce 100644 --- a/crates/weaver-app/src/components/editor/paragraph.rs +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -5,7 +5,7 @@ use super::offset_map::OffsetMapping; use super::writer::SyntaxSpanInfo; -use jumprope::JumpRopeBuf; +use loro::LoroText; use std::ops::Range; /// A rendered paragraph with its source range and offset mappings. @@ -40,15 +40,8 @@ pub fn hash_source(text: &str) -> u64 { hasher.finish() } -/// Extract substring from rope as String -pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range) -> String { - let rope_borrow = rope.borrow(); - let mut result = String::new(); - - for substr in rope_borrow.slice_substrings(range) { - result.push_str(substr); - } - - result +/// 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 52a7fb6..411f487 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -6,9 +6,9 @@ use super::document::EditInfo; use super::offset_map::{OffsetMapping, RenderResult}; -use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; +use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; use super::writer::{EditorWriter, SyntaxSpanInfo}; -use jumprope::JumpRopeBuf; +use loro::LoroText; use markdown_weaver::Parser; use std::ops::Range; @@ -105,11 +105,11 @@ fn adjust_paragraph_positions( /// # Returns /// Tuple of (rendered paragraphs, updated cache) pub fn render_paragraphs_incremental( - rope: &JumpRopeBuf, + text: &LoroText, cache: Option<&RenderCache>, edit: Option<&EditInfo>, ) -> (Vec, RenderCache) { - let source = rope.to_string(); + let source = text.to_string(); // Handle empty document if source.is_empty() { @@ -190,7 +190,7 @@ pub fn render_paragraphs_incremental( match EditorWriter::<_, _, ()>::new_boundary_only( &source, - rope, + text, parser, &mut scratch_output, ) @@ -213,7 +213,7 @@ pub fn render_paragraphs_incremental( let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { - let para_source = rope_slice_to_string(rope, char_range.clone()); + let para_source = text_slice_to_string(text, char_range.clone()); let source_hash = hash_source(¶_source); tracing::debug!( @@ -250,8 +250,11 @@ pub fn render_paragraphs_incremental( (cached.html.clone(), adjusted_map, adjusted_syntax) } else { - // Fresh render needed - let para_rope = JumpRopeBuf::from(para_source.as_str()); + // 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(); @@ -259,7 +262,7 @@ pub fn render_paragraphs_incremental( let (mut offset_map, mut syntax_spans) = match EditorWriter::<_, _, ()>::new_with_offsets( ¶_source, - ¶_rope, + ¶_text, parser, &mut output, node_id_offset, @@ -374,7 +377,7 @@ pub fn render_paragraphs_incremental( utf16_len: 1, }], syntax_spans: vec![], - source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), + source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)), }); } @@ -386,8 +389,8 @@ pub fn render_paragraphs_incremental( // 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 = rope.len_chars(); - let doc_end_byte = rope.len_bytes(); + let doc_end_char = text.len_unicode(); + let doc_end_byte = text.len_utf8(); if doc_end_char > prev_end_char { // Position-based ID for trailing gap diff --git a/crates/weaver-app/src/components/editor/rope_writer.rs b/crates/weaver-app/src/components/editor/rope_writer.rs deleted file mode 100644 index e0ee68e..0000000 --- a/crates/weaver-app/src/components/editor/rope_writer.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! StrWrite wrapper for JumpRopeBuf to enable efficient HTML rendering. - -use jumprope::JumpRopeBuf; -use markdown_weaver_escape::StrWrite; - -/// Wrapper around JumpRopeBuf that implements StrWrite. -/// -/// This allows rendering HTML directly into a rope structure, enabling: -/// - O(log n) insertions instead of O(n) string reallocation -/// - Efficient splicing for incremental rendering -/// - Fast paragraph replacement in cached output -pub struct RopeWriter { - rope: JumpRopeBuf, -} - -impl RopeWriter { - pub fn new() -> Self { - Self { - rope: JumpRopeBuf::new(), - } - } - - pub fn from_rope(rope: JumpRopeBuf) -> Self { - Self { rope } - } - - pub fn into_rope(self) -> JumpRopeBuf { - self.rope - } - - pub fn as_rope(&self) -> &JumpRopeBuf { - &self.rope - } - - pub fn to_string(&self) -> String { - self.rope.to_string() - } -} - -impl Default for RopeWriter { - fn default() -> Self { - Self::new() - } -} - -impl StrWrite for RopeWriter { - type Error = std::convert::Infallible; - - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { - let offset = self.rope.len_chars(); - self.rope.insert(offset, s); - Ok(()) - } - - fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> Result<(), Self::Error> { - let mut temp = String::new(); - std::fmt::Write::write_fmt(&mut temp, args).unwrap(); - self.write_str(&temp) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rope_writer_basic() { - let mut writer = RopeWriter::new(); - writer.write_str("hello ").unwrap(); - writer.write_str("world").unwrap(); - assert_eq!(writer.to_string(), "hello world"); - } - - #[test] - fn test_rope_writer_fmt() { - use std::fmt::Write; - let mut writer = RopeWriter::new(); - write!(&mut writer, "number: {}", 42).unwrap(); - assert_eq!(writer.to_string(), "number: 42"); - } -} diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap index 3b40ef4..c14d540 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some **bold** text

\n" + html: "

Some **bold** text

\n" offset_map: - byte_range: - 0 @@ -30,6 +30,16 @@ expression: result char_offset_in_node: 0 child_index: ~ utf16_len: 5 + - byte_range: + - 5 + - 7 + char_range: + - 5 + - 7 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 2 - byte_range: - 7 - 11 @@ -37,7 +47,7 @@ expression: result - 7 - 11 node_id: n0 - char_offset_in_node: 5 + char_offset_in_node: 7 child_index: ~ utf16_len: 4 - byte_range: @@ -47,7 +57,7 @@ expression: result - 11 - 13 node_id: n0 - char_offset_in_node: 9 + char_offset_in_node: 11 child_index: ~ utf16_len: 2 - byte_range: @@ -57,7 +67,7 @@ expression: result - 13 - 18 node_id: n0 - char_offset_in_node: 11 + char_offset_in_node: 13 child_index: ~ utf16_len: 5 source_hash: 3007541947422346271 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap index b57b724..3f66287 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "

Some ***bold italic*** text

\n" + html: "

Some ***bold italic*** text

\n" offset_map: - byte_range: - 0 @@ -30,6 +30,26 @@ expression: result char_offset_in_node: 0 child_index: ~ utf16_len: 5 + - byte_range: + - 5 + - 6 + char_range: + - 5 + - 6 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 + - byte_range: + - 6 + - 8 + char_range: + - 6 + - 8 + node_id: n0 + char_offset_in_node: 6 + child_index: ~ + utf16_len: 2 - byte_range: - 8 - 19 @@ -37,7 +57,7 @@ expression: result - 8 - 19 node_id: n0 - char_offset_in_node: 5 + char_offset_in_node: 8 child_index: ~ utf16_len: 11 - byte_range: @@ -47,7 +67,7 @@ expression: result - 19 - 21 node_id: n0 - char_offset_in_node: 16 + char_offset_in_node: 19 child_index: ~ utf16_len: 2 - byte_range: @@ -57,7 +77,7 @@ expression: result - 21 - 22 node_id: n0 - char_offset_in_node: 18 + char_offset_in_node: 21 child_index: ~ utf16_len: 1 - byte_range: @@ -67,7 +87,7 @@ expression: result - 22 - 27 node_id: n0 - char_offset_in_node: 19 + char_offset_in_node: 22 child_index: ~ utf16_len: 5 source_hash: 17839597501764990486 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap index 577c385..18dca3e 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap @@ -39,7 +39,7 @@ expression: result node_id: n0 char_offset_in_node: 5 child_index: ~ - utf16_len: 4 + utf16_len: 6 - byte_range: - 11 - 16 @@ -47,7 +47,7 @@ expression: result - 11 - 16 node_id: n0 - char_offset_in_node: 9 + char_offset_in_node: 11 child_index: ~ utf16_len: 5 source_hash: 10489263388249723293 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap index 5419936..0cb4391 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some *italic* text

\n" + html: "

Some *italic* text

\n" offset_map: - byte_range: - 0 @@ -30,6 +30,16 @@ expression: result char_offset_in_node: 0 child_index: ~ utf16_len: 5 + - byte_range: + - 5 + - 6 + char_range: + - 5 + - 6 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 - byte_range: - 6 - 12 @@ -37,7 +47,7 @@ expression: result - 6 - 12 node_id: n0 - char_offset_in_node: 5 + char_offset_in_node: 6 child_index: ~ utf16_len: 6 - byte_range: @@ -47,7 +57,7 @@ expression: result - 12 - 13 node_id: n0 - char_offset_in_node: 11 + char_offset_in_node: 12 child_index: ~ utf16_len: 1 - byte_range: @@ -57,7 +67,7 @@ expression: result - 13 - 18 node_id: n0 - char_offset_in_node: 12 + char_offset_in_node: 13 child_index: ~ utf16_len: 5 source_hash: 4363411941421262428 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap index 9040222..b367c1c 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 32 - html: "

**Bold** and *italic* and `code`

\n" + html: "

**Bold** and *italic* and `code`

\n" offset_map: - byte_range: - 0 @@ -20,6 +20,16 @@ expression: result char_offset_in_node: 0 child_index: 0 utf16_len: 0 + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 - byte_range: - 2 - 6 @@ -27,7 +37,7 @@ expression: result - 2 - 6 node_id: n0 - char_offset_in_node: 0 + char_offset_in_node: 2 child_index: ~ utf16_len: 4 - byte_range: @@ -37,7 +47,7 @@ expression: result - 6 - 8 node_id: n0 - char_offset_in_node: 4 + char_offset_in_node: 6 child_index: ~ utf16_len: 2 - byte_range: @@ -47,9 +57,19 @@ expression: result - 8 - 13 node_id: n0 - char_offset_in_node: 6 + char_offset_in_node: 8 child_index: ~ utf16_len: 5 + - byte_range: + - 13 + - 14 + char_range: + - 13 + - 14 + node_id: n0 + char_offset_in_node: 13 + child_index: ~ + utf16_len: 1 - byte_range: - 14 - 20 @@ -57,7 +77,7 @@ expression: result - 14 - 20 node_id: n0 - char_offset_in_node: 11 + char_offset_in_node: 14 child_index: ~ utf16_len: 6 - byte_range: @@ -67,7 +87,7 @@ expression: result - 20 - 21 node_id: n0 - char_offset_in_node: 17 + char_offset_in_node: 20 child_index: ~ utf16_len: 1 - byte_range: @@ -77,7 +97,7 @@ expression: result - 21 - 26 node_id: n0 - char_offset_in_node: 18 + char_offset_in_node: 21 child_index: ~ utf16_len: 5 - byte_range: @@ -87,7 +107,7 @@ expression: result - 27 - 31 node_id: n0 - char_offset_in_node: 23 + char_offset_in_node: 26 child_index: ~ - utf16_len: 4 + utf16_len: 6 source_hash: 17988102203032347642 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap index a2fa11f..f40a915 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap @@ -8,28 +8,18 @@ expression: result char_range: - 0 - 27 - html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" + html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" offset_map: - byte_range: - 0 - - 2 - char_range: - - 0 - - 2 - node_id: n0 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 2 - - byte_range: - - 2 - 3 char_range: - - 2 + - 0 - 3 node_id: n0 - char_offset_in_node: 2 + char_offset_in_node: 0 child_index: ~ - utf16_len: 1 + utf16_len: 3 - byte_range: - 3 - 8 @@ -52,24 +42,14 @@ expression: result utf16_len: 1 - byte_range: - 9 - - 11 - char_range: - - 9 - - 11 - node_id: n1 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 2 - - byte_range: - - 11 - 12 char_range: - - 11 + - 9 - 12 node_id: n1 - char_offset_in_node: 2 + char_offset_in_node: 0 child_index: ~ - utf16_len: 1 + utf16_len: 3 - byte_range: - 12 - 18 @@ -92,24 +72,14 @@ expression: result utf16_len: 1 - byte_range: - 19 - - 21 - char_range: - - 19 - - 21 - node_id: n2 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 2 - - byte_range: - - 21 - 22 char_range: - - 21 + - 19 - 22 node_id: n2 - char_offset_in_node: 2 + char_offset_in_node: 0 child_index: ~ - utf16_len: 1 + utf16_len: 3 - byte_range: - 22 - 27 diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 07e32ac..0ee81c6 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -3,7 +3,7 @@ use super::offset_map::{OffsetMapping, find_mapping_for_char}; use super::paragraph::ParagraphRender; use super::render::render_paragraphs_incremental; -use jumprope::JumpRopeBuf; +use loro::LoroDoc; use serde::Serialize; /// Serializable version of ParagraphRender for snapshot testing. @@ -54,8 +54,10 @@ impl From<&OffsetMapping> for TestOffsetMapping { /// Helper: render markdown and convert to serializable test output. fn render_test(input: &str) -> Vec { - let rope = JumpRopeBuf::from(input); - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + text.insert(0, input).unwrap(); + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); paragraphs.iter().map(TestParagraph::from).collect() } @@ -434,8 +436,10 @@ fn test_char_range_coverage_allows_paragraph_breaks() { // cursor snaps to adjacent paragraphs for standard breaks. // Only EXTRA whitespace beyond \n\n gets gap elements. let input = "Hello\n\nWorld"; - let rope = JumpRopeBuf::from(input); - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + text.insert(0, input).unwrap(); + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); // With standard \n\n break, we expect 2 paragraphs (no gap element) // Paragraph ranges include some trailing whitespace from markdown parsing @@ -453,8 +457,10 @@ fn test_char_range_coverage_with_extra_whitespace() { // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements // Plain paragraphs don't consume trailing newlines like headings do let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 - let rope = JumpRopeBuf::from(input); - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + text.insert(0, input).unwrap(); + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); // With extra newlines, we expect 3 elements: para, gap, para assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); @@ -542,13 +548,15 @@ fn test_offset_mappings_reference_own_paragraph() { fn test_incremental_cache_reuse() { // Verify cache is populated and can be reused let input = "First para\n\nSecond para"; - let rope = JumpRopeBuf::from(input); + let doc = LoroDoc::new(); + let text = doc.get_text("content"); + text.insert(0, input).unwrap(); - let (paras1, cache1) = render_paragraphs_incremental(&rope, None, None); + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); // Second render with same content should reuse cache - let (paras2, _cache2) = render_paragraphs_incremental(&rope, Some(&cache1), None); + let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None); // Should produce identical output assert_eq!(paras1.len(), paras2.len()); diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs index 8251c68..4dbcea2 100644 --- a/crates/weaver-app/src/components/editor/visibility.rs +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -33,29 +33,57 @@ impl VisibilityState { let mut visible = HashSet::new(); for span in syntax_spans { + // Find the paragraph containing this span for boundary clamping + let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); + let should_show = match span.syntax_type { SyntaxType::Inline => { // Show if cursor within formatted span content OR adjacent to markers - // "Adjacent" means within 1 char of the syntax boundaries - let extended_range = span.char_range.start.saturating_sub(1) - ..span.char_range.end.saturating_add(1); + // "Adjacent" means within 1 char of the syntax boundaries, + // clamped to paragraph bounds (paragraphs are split by newlines, + // so clamping to para bounds prevents cross-line extension) + let extended_start = + safe_extend_left(span.char_range.start, 1, para_bounds.as_ref()); + let extended_end = + safe_extend_right(span.char_range.end, 1, para_bounds.as_ref()); + let extended_range = extended_start..extended_end; // Also show if cursor is anywhere in the formatted_range // (the region between paired opening/closing markers) + // Extend by 1 char on BOTH sides for symmetric "approaching" behavior, + // clamped to paragraph bounds. let in_formatted_region = span .formatted_range .as_ref() - .map(|r| r.contains(&cursor_offset)) + .map(|r| { + let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref()); + let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref()); + cursor_offset >= ext_start && cursor_offset <= ext_end + }) .unwrap_or(false); - extended_range.contains(&cursor_offset) + let in_extended = extended_range.contains(&cursor_offset); + let result = in_extended || in_formatted_region || selection_overlaps(selection, &span.char_range) || span .formatted_range .as_ref() .map(|r| selection_overlaps(selection, r)) - .unwrap_or(false) + .unwrap_or(false); + + tracing::debug!( + "[VISIBILITY] span {} char_range {:?} formatted_range {:?} cursor {} -> in_extended={} in_formatted={} visible={}", + span.syn_id, + span.char_range, + span.formatted_range, + cursor_offset, + in_extended, + in_formatted_region, + result + ); + + result } SyntaxType::Block => { // Show if cursor anywhere in same paragraph @@ -116,6 +144,42 @@ fn cursor_in_same_paragraph( false } +/// Find the paragraph bounds containing a syntax span. +fn find_paragraph_bounds( + syntax_range: &Range, + paragraphs: &[ParagraphRender], +) -> Option> { + for para in paragraphs { + // Skip gap paragraphs + if para.syntax_spans.is_empty() && !para.char_range.is_empty() { + continue; + } + + if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { + return Some(para.char_range.clone()); + } + } + None +} + +/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds. +/// +/// Paragraphs are already split by newlines, so clamping to paragraph bounds +/// naturally prevents extending across line boundaries. +fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize { + let min_pos = para_bounds.map(|p| p.start).unwrap_or(0); + pos.saturating_sub(amount).max(min_pos) +} + +/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds. +/// +/// Paragraphs are already split by newlines, so clamping to paragraph bounds +/// naturally prevents extending across line boundaries. +fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize { + let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX); + pos.saturating_add(amount).min(max_pos) +} + #[cfg(test)] mod tests { use super::*; @@ -197,10 +261,12 @@ mod tests { #[test] fn test_inline_visibility_cursor_adjacent() { + // "test **bold** after" + // 5 7 let spans = vec![ make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 ]; - let paras = vec![make_para(0, 20, spans.clone())]; + let paras = vec![make_para(0, 19, spans.clone())]; // Cursor at position 4 (one before ** which starts at 5) let vis = VisibilityState::calculate(4, None, &spans, ¶s); @@ -216,7 +282,7 @@ mod tests { let spans = vec![ make_span("s0", 10, 12, SyntaxType::Inline), ]; - let paras = vec![make_para(0, 30, spans.clone())]; + let paras = vec![make_para(0, 33, spans.clone())]; // Cursor at position 0 (far from **) let vis = VisibilityState::calculate(0, None, &spans, ¶s); @@ -259,11 +325,44 @@ mod tests { let spans = vec![ make_span("s0", 5, 7, SyntaxType::Inline), ]; - let paras = vec![make_para(0, 20, spans.clone())]; + let paras = vec![make_para(0, 24, spans.clone())]; // Selection overlaps the syntax span let selection = Selection { anchor: 3, head: 10 }; let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s); assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); } + + #[test] + fn test_paragraph_boundary_blocks_extension() { + // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1, + // even if cursor is only 1 char after the paragraph boundary + // (paragraph bounds clamp the extension) + let spans = vec![ + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** + ]; + let paras = vec![ + make_para(0, 8, spans.clone()), // "**bold**" + make_para(9, 13, vec![]), // "text" (after newline) + ]; + + // Cursor at position 9 (start of second paragraph) + // Should NOT reveal the closing ** because para bounds clamp extension + let vis = VisibilityState::calculate(9, None, &spans, ¶s); + assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph"); + } + + #[test] + fn test_extension_clamps_to_paragraph() { + // Syntax at very start of paragraph - extension left should stop at para start + let spans = vec![ + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), + ]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 0 - should still see the opening ** + let vis = VisibilityState::calculate(0, None, &spans, ¶s); + assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0"); + } } diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 67a03e7..32033f2 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -7,7 +7,7 @@ //! represent consumed formatting characters. use super::offset_map::{OffsetMapping, RenderResult}; -use jumprope::JumpRopeBuf; +use loro::LoroText; use markdown_weaver::{ Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, }; @@ -109,7 +109,7 @@ impl EmbedContentProvider for () { /// and emits them as styled spans for visibility in the editor. pub struct EditorWriter<'a, I: Iterator, Range)>, W: StrWrite, E = ()> { source: &'a str, - source_rope: &'a JumpRopeBuf, + source_text: &'a LoroText, events: I, writer: W, last_byte_offset: usize, @@ -142,6 +142,11 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St current_node_char_offset: usize, // UTF-16 offset within current node current_node_child_count: usize, // number of child elements/text nodes in current container + // Incremental UTF-16 offset tracking (replaces rope.chars_to_wchars) + // Maps char_offset -> utf16_offset at checkpoints we've traversed. + // Can be reused for future lookups or passed to subsequent writers. + utf16_checkpoints: Vec<(usize, usize)>, // (char_offset, utf16_offset) + // Paragraph boundary tracking for incremental rendering paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range) current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) @@ -170,23 +175,23 @@ enum TableState { impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedContentProvider> EditorWriter<'a, I, W, E> { - pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self { - Self::new_with_node_offset(source, source_rope, events, writer, 0) + 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_rope: &'a JumpRopeBuf, + source_text: &'a LoroText, events: I, writer: W, node_id_offset: usize, ) -> Self { - Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0) + Self::new_with_offsets(source, source_text, events, writer, node_id_offset, 0) } pub fn new_with_offsets( source: &'a str, - source_rope: &'a JumpRopeBuf, + source_text: &'a LoroText, events: I, writer: W, node_id_offset: usize, @@ -194,7 +199,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon ) -> Self { Self { source, - source_rope, + source_text, events, writer, last_byte_offset: 0, @@ -217,6 +222,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon current_node_id: None, current_node_char_offset: 0, current_node_child_count: 0, + utf16_checkpoints: vec![(0, 0)], paragraph_ranges: Vec::new(), current_paragraph_start: None, list_depth: 0, @@ -232,13 +238,13 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon /// Used for fast boundary discovery in incremental rendering. pub fn new_boundary_only( source: &'a str, - source_rope: &'a JumpRopeBuf, + source_text: &'a LoroText, events: I, writer: W, ) -> Self { Self { source, - source_rope, + source_text, events, writer, last_byte_offset: 0, @@ -261,6 +267,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon 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(), @@ -276,7 +283,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { EditorWriter { source: self.source, - source_rope: self.source_rope, + source_text: self.source_text, events: self.events, writer: self.writer, last_byte_offset: self.last_byte_offset, @@ -299,6 +306,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon 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, @@ -343,6 +351,12 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let format_end = self.last_char_offset; let formatted_range = format_start..format_end; + tracing::debug!( + "[FINALIZE_PAIRED] Setting formatted_range {:?} for opening '{}' and closing (last span)", + formatted_range, + opening_syn_id + ); + // Update the opening span's formatted_range if let Some(opening_span) = self .syntax_spans @@ -350,6 +364,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon .find(|s| s.syn_id == opening_syn_id) { opening_span.formatted_range = Some(formatted_range.clone()); + tracing::debug!("[FINALIZE_PAIRED] Updated opening span {}", opening_syn_id); + } else { + tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id); } // Update the closing span's formatted_range (the most recent one) @@ -358,6 +375,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Only update if it's an inline span (closing syntax should be inline) if closing_span.syntax_type == SyntaxType::Inline { closing_span.formatted_range = Some(formatted_range); + tracing::debug!("[FINALIZE_PAIRED] Updated closing span {}", closing_span.syn_id); } } } @@ -544,16 +562,38 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.current_node_child_count = 0; } + /// Compute UTF-16 length for a text slice with fast path for ASCII. + #[inline] + fn utf16_len_for_slice(text: &str) -> usize { + let byte_len = text.len(); + let char_len = text.chars().count(); + + // Fast path: if byte_len == char_len, all ASCII, so utf16_len == char_len + if byte_len == char_len { + char_len + } else { + // Slow path: has multi-byte chars, need to count UTF-16 code units + text.encode_utf16().count() + } + } + /// Record an offset mapping for the given byte and char ranges. /// - /// Computes UTF-16 length efficiently using the rope's internal indexing. + /// Builds up utf16_checkpoints incrementally for efficient lookups. fn record_mapping(&mut self, byte_range: Range, char_range: Range) { if let Some(ref node_id) = self.current_node_id { - // Use rope to convert char offsets to UTF-16 (wchar) offsets - O(log n) - let rope = self.source_rope.borrow(); - let wchar_start = rope.chars_to_wchars(char_range.start); - let wchar_end = rope.chars_to_wchars(char_range.end); - let utf16_len = wchar_end - wchar_start; + // Get UTF-16 length using fast path + let text_slice = &self.source[byte_range.clone()]; + let utf16_len = Self::utf16_len_for_slice(text_slice); + + // Record checkpoint at end of this range for future lookups + let last_checkpoint = self.utf16_checkpoints.last().copied().unwrap_or((0, 0)); + let new_utf16_offset = last_checkpoint.1 + utf16_len; + + // Only add checkpoint if we've advanced + if char_range.end > last_checkpoint.0 { + self.utf16_checkpoints.push((char_range.end, new_utf16_offset)); + } let mapping = OffsetMapping { byte_range: byte_range.clone(), @@ -601,15 +641,27 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // For End events, emit any trailing content within the event's range // BEFORE calling end_tag (which calls end_node and clears current_node_id) - if matches!(&event, Event::End(_)) { + // + // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), + // the closing syntax must be emitted AFTER the closing HTML tag, not before. + // Otherwise the closing `**` span ends up INSIDE the element. + // These tags handle their own closing syntax in end_tag(). + use markdown_weaver::TagEnd; + let is_inline_format_end = matches!( + &event, + Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) + ); + + if matches!(&event, Event::End(_)) && !is_inline_format_end { // Emit gap from last_byte_offset to range.end // (emit_syntax handles char offset tracking) self.emit_gap_before(range.end)?; - } else { + } else if !matches!(&event, Event::End(_)) { // For other events, emit any gap before range.start // (emit_syntax handles char offset tracking) self.emit_gap_before(range.start)?; } + // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML // Store last_byte before processing let last_byte_before = self.last_byte_offset; @@ -632,7 +684,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Handle unmapped trailing content (stripped by parser) // This includes trailing spaces that markdown ignores let doc_byte_len = self.source.len(); - let doc_char_len = self.source_rope.len_chars(); + let doc_char_len = self.source_text.len_unicode(); if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { // Emit the trailing content as visible syntax @@ -1174,6 +1226,13 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon formatted_range: None, // Will be updated when closing tag is emitted }); + // Record offset mapping for cursor positioning + // This is critical - without it, current_node_char_offset is wrong + // and all subsequent cursor positions are shifted + let byte_start = range.start; + let byte_end = range.start + syntax_byte_len; + self.record_mapping(byte_start..byte_end, char_start..char_end); + // For paired inline syntax (Strong, Emphasis, Strikethrough), // track the opening span so we can set formatted_range when closing if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) { @@ -1990,18 +2049,27 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.write("\n") } TagEnd::Emphasis => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("")?; + self.emit_gap_before(range.end)?; self.finalize_paired_inline_format(); - self.write("") + Ok(()) } TagEnd::Superscript => self.write(""), TagEnd::Subscript => self.write(""), TagEnd::Strong => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("")?; + self.emit_gap_before(range.end)?; self.finalize_paired_inline_format(); - self.write("") + Ok(()) } TagEnd::Strikethrough => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("")?; + self.emit_gap_before(range.end)?; self.finalize_paired_inline_format(); - self.write("") + Ok(()) } TagEnd::Link => self.write(""), TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event @@ -2019,9 +2087,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon result?; - // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled - // by emit_gap_before(range.end) which is called before end_tag() in the main loop. - // No need for manual emission here anymore. + // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) + // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. + // This ensures the closing syntax span appears OUTSIDE the formatted element. + // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. Ok(()) }