loro refactor

Orual cda2ec7e f88aa1d1

+650 -485
-17
Cargo.lock
··· 4990 4990 ] 4991 4991 4992 4992 [[package]] 4993 - name = "jumprope" 4994 - version = "1.1.2" 4995 - source = "registry+https://github.com/rust-lang/crates.io-index" 4996 - checksum = "829c74fe88dda0d2a5425b022b44921574a65c4eb78e6e39a61b40eb416a4ef8" 4997 - dependencies = [ 4998 - "rand 0.8.5", 4999 - "str_indices", 5000 - ] 5001 - 5002 - [[package]] 5003 4993 name = "k256" 5004 4994 version = "0.13.4" 5005 4995 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8349 8339 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 8350 8340 8351 8341 [[package]] 8352 - name = "str_indices" 8353 - version = "0.4.4" 8354 - source = "registry+https://github.com/rust-lang/crates.io-index" 8355 - checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" 8356 - 8357 - [[package]] 8358 8342 name = "string_cache" 8359 8343 version = "0.8.9" 8360 8344 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9963 9947 "jacquard-identity", 9964 9948 "jacquard-lexicon", 9965 9949 "js-sys", 9966 - "jumprope", 9967 9950 "lol_alloc", 9968 9951 "loro", 9969 9952 "markdown-weaver",
-1
crates/weaver-app/Cargo.toml
··· 49 49 serde_html_form = "0.2.8" 50 50 tracing.workspace = true 51 51 serde_ipld_dagcbor = { version = "0.6" } 52 - jumprope = { version = "1.1", features = ["wchar_conversion"] } 53 52 loro = "1.9.1" 54 53 markdown-weaver-escape = { workspace = true } 55 54
+9 -11
crates/weaver-app/src/components/editor/cursor.rs
··· 8 8 //! 4. Setting cursor with web_sys Selection API 9 9 10 10 use super::offset_map::{find_mapping_for_char, OffsetMapping}; 11 - use jumprope::JumpRopeBuf; 12 11 13 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 14 13 use wasm_bindgen::JsCast; ··· 16 15 /// Restore cursor position in the DOM after re-render. 17 16 /// 18 17 /// # Arguments 19 - /// - `rope`: The document content (for length bounds checking) 20 - /// - `char_offset`: Cursor position as char offset in rope 18 + /// - `char_offset`: Cursor position as char offset in document 21 19 /// - `offset_map`: Mappings from source to DOM positions 22 20 /// - `editor_id`: DOM ID of the contenteditable element 23 21 /// ··· 28 26 /// 4. Set cursor with Selection API 29 27 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 30 28 pub fn restore_cursor_position( 31 - rope: &JumpRopeBuf, 32 29 char_offset: usize, 33 30 offset_map: &[OffsetMapping], 34 31 editor_id: &str, 35 32 ) -> Result<(), wasm_bindgen::JsValue> { 36 - // Bounds check 37 - let max_offset = rope.len_chars(); 38 - if char_offset > max_offset { 39 - return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into()); 33 + // Empty document - no cursor to restore 34 + if offset_map.is_empty() { 35 + return Ok(()); 40 36 } 41 37 42 - // Empty document - no cursor to restore 43 - if offset_map.is_empty() || max_offset == 0 { 38 + // Bounds check using offset map 39 + let max_offset = offset_map.iter().map(|m| m.char_range.end).max().unwrap_or(0); 40 + if char_offset > max_offset { 41 + tracing::warn!("cursor offset {} > max mapping offset {}", char_offset, max_offset); 42 + // Don't error, just skip restoration - this can happen during edits 44 43 return Ok(()); 45 44 } 46 45 ··· 159 158 /// Non-WASM stub for testing 160 159 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 161 160 pub fn restore_cursor_position( 162 - _rope: &JumpRopeBuf, 163 161 _char_offset: usize, 164 162 _offset_map: &[OffsetMapping], 165 163 _editor_id: &str,
+129 -32
crates/weaver-app/src/components/editor/document.rs
··· 1 1 //! Core data structures for the markdown editor. 2 + //! 3 + //! Uses Loro CRDT for text storage with built-in undo/redo support. 2 4 3 - use jumprope::JumpRopeBuf; 5 + use loro::{LoroDoc, LoroResult, LoroText, UndoManager}; 4 6 5 7 /// Single source of truth for editor state. 6 8 /// 7 - /// Contains the document text, cursor position, selection, and IME composition state. 8 - #[derive(Clone, Debug)] 9 + /// Contains the document text (backed by Loro CRDT), cursor position, 10 + /// selection, and IME composition state. 11 + #[derive(Debug)] 9 12 pub struct EditorDocument { 10 - /// The rope storing document text (uses char offsets, not bytes). 11 - /// Uses JumpRopeBuf to batch consecutive edits for performance. 12 - pub rope: JumpRopeBuf, 13 + /// The Loro document containing all editor state. 14 + /// Using full LoroDoc (not just LoroText) to support future 15 + /// expansion to blobs, metadata, etc. 16 + doc: LoroDoc, 17 + 18 + /// Handle to the text container within the doc. 19 + text: LoroText, 20 + 21 + /// Undo manager for the document. 22 + undo_mgr: UndoManager, 13 23 14 24 /// Current cursor position (char offset) 15 25 pub cursor: CursorState, ··· 28 38 /// Cursor state including position and affinity. 29 39 #[derive(Clone, Debug, Copy)] 30 40 pub struct CursorState { 31 - /// Character offset in rope (NOT byte offset!) 41 + /// Character offset in text (NOT byte offset!) 32 42 pub offset: usize, 33 43 34 44 /// Prefer left/right when at boundary (for vertical cursor movement) ··· 85 95 return true; 86 96 } 87 97 88 - // Find distance from previous newline by scanning forward and tracking last newline 89 - let rope = self.rope.borrow(); 98 + let content = self.text.to_string(); 90 99 let mut last_newline_pos: Option<usize> = None; 91 100 92 - for (i, c) in rope.slice_chars(0..pos).enumerate() { 101 + for (i, c) in content.chars().take(pos).enumerate() { 93 102 if c == '\n' { 94 103 last_newline_pos = Some(i); 95 104 } 96 105 } 97 106 98 107 let chars_from_line_start = match last_newline_pos { 99 - Some(nl_pos) => pos - nl_pos - 1, // -1 because newline itself is not part of current line 100 - None => pos, // No newline found, distance is from document start 108 + Some(nl_pos) => pos - nl_pos - 1, 109 + None => pos, 101 110 }; 102 111 103 112 chars_from_line_start <= BLOCK_SYNTAX_ZONE ··· 105 114 106 115 /// Create a new editor document with the given content. 107 116 pub fn new(content: String) -> Self { 117 + let doc = LoroDoc::new(); 118 + let text = doc.get_text("content"); 119 + 120 + // Insert initial content if any 121 + if !content.is_empty() { 122 + text.insert(0, &content).expect("failed to insert initial content"); 123 + } 124 + 125 + // Set up undo manager with merge interval for batching keystrokes 126 + let mut undo_mgr = UndoManager::new(&doc); 127 + undo_mgr.set_merge_interval(300); // 300ms merge window 128 + undo_mgr.set_max_undo_steps(100); 129 + 108 130 Self { 109 - rope: JumpRopeBuf::from(content.as_str()), 131 + doc, 132 + text, 133 + undo_mgr, 110 134 cursor: CursorState { 111 135 offset: 0, 112 136 affinity: Affinity::Before, ··· 117 141 } 118 142 } 119 143 144 + /// Get the underlying LoroText for read operations. 145 + pub fn loro_text(&self) -> &LoroText { 146 + &self.text 147 + } 148 + 120 149 /// Convert the document to a string. 121 150 pub fn to_string(&self) -> String { 122 - self.rope.to_string() 151 + self.text.to_string() 123 152 } 124 153 125 154 /// Get the length of the document in characters. 126 155 pub fn len_chars(&self) -> usize { 127 - self.rope.len_chars() 156 + self.text.len_unicode() 157 + } 158 + 159 + /// Get the length of the document in UTF-8 bytes. 160 + pub fn len_bytes(&self) -> usize { 161 + self.text.len_utf8() 162 + } 163 + 164 + /// Get the length of the document in UTF-16 code units. 165 + pub fn len_utf16(&self) -> usize { 166 + self.text.len_utf16() 128 167 } 129 168 130 169 /// Check if the document is empty. 131 170 pub fn is_empty(&self) -> bool { 132 - self.rope.len_chars() == 0 171 + self.text.len_unicode() == 0 133 172 } 134 173 135 174 /// Insert text and record edit info for incremental rendering. 136 - pub fn insert_tracked(&mut self, pos: usize, text: &str) { 175 + pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { 137 176 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 138 177 self.last_edit = Some(EditInfo { 139 178 edit_char_pos: pos, ··· 142 181 contains_newline: text.contains('\n'), 143 182 in_block_syntax_zone, 144 183 }); 145 - self.rope.insert(pos, text); 184 + self.text.insert(pos, text) 146 185 } 147 186 148 187 /// Remove text range and record edit info for incremental rendering. 149 - pub fn remove_tracked(&mut self, range: std::ops::Range<usize>) { 150 - // Check if deleted region contains newline - borrow inner JumpRope 151 - let contains_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); 152 - let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); 188 + pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 189 + let content = self.text.to_string(); 190 + let end = start + len; 191 + let contains_newline = content 192 + .chars() 193 + .skip(start) 194 + .take(len) 195 + .any(|c| c == '\n'); 196 + let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 197 + 153 198 self.last_edit = Some(EditInfo { 154 - edit_char_pos: range.start, 199 + edit_char_pos: start, 155 200 inserted_len: 0, 156 - deleted_len: range.end - range.start, 201 + deleted_len: len, 157 202 contains_newline, 158 203 in_block_syntax_zone, 159 204 }); 160 - self.rope.remove(range); 205 + self.text.delete(start, len) 161 206 } 162 207 163 208 /// Replace text (delete then insert) and record combined edit info. 164 - pub fn replace_tracked(&mut self, range: std::ops::Range<usize>, text: &str) { 165 - let delete_has_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); 166 - let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); 209 + pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 210 + let content = self.text.to_string(); 211 + let delete_has_newline = content 212 + .chars() 213 + .skip(start) 214 + .take(len) 215 + .any(|c| c == '\n'); 216 + let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 217 + 167 218 self.last_edit = Some(EditInfo { 168 - edit_char_pos: range.start, 219 + edit_char_pos: start, 169 220 inserted_len: text.chars().count(), 170 - deleted_len: range.end - range.start, 221 + deleted_len: len, 171 222 contains_newline: delete_has_newline || text.contains('\n'), 172 223 in_block_syntax_zone, 173 224 }); 174 - self.rope.remove(range); 175 - self.rope.insert(self.last_edit.as_ref().unwrap().edit_char_pos, text); 225 + 226 + // Use splice for atomic replace 227 + self.text.splice(start, len, text)?; 228 + Ok(()) 229 + } 230 + 231 + /// Undo the last operation. 232 + /// Returns true if an undo was performed. 233 + pub fn undo(&mut self) -> LoroResult<bool> { 234 + self.undo_mgr.undo() 235 + } 236 + 237 + /// Redo the last undone operation. 238 + /// Returns true if a redo was performed. 239 + pub fn redo(&mut self) -> LoroResult<bool> { 240 + self.undo_mgr.redo() 241 + } 242 + 243 + /// Check if undo is available. 244 + pub fn can_undo(&self) -> bool { 245 + self.undo_mgr.can_undo() 246 + } 247 + 248 + /// Check if redo is available. 249 + pub fn can_redo(&self) -> bool { 250 + self.undo_mgr.can_redo() 251 + } 252 + 253 + /// Get a slice of the document text. 254 + /// Returns None if the range is invalid. 255 + pub fn slice(&self, start: usize, end: usize) -> Option<String> { 256 + self.text.slice(start, end).ok() 257 + } 258 + } 259 + 260 + // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. 261 + // This is intentional - the document should be the single source of truth. 262 + 263 + impl Clone for EditorDocument { 264 + fn clone(&self) -> Self { 265 + // Create a new document with the same content 266 + let content = self.to_string(); 267 + let mut new_doc = Self::new(content); 268 + new_doc.cursor = self.cursor; 269 + new_doc.selection = self.selection; 270 + new_doc.composition = self.composition.clone(); 271 + new_doc.last_edit = self.last_edit.clone(); 272 + new_doc 176 273 } 177 274 }
+60 -55
crates/weaver-app/src/components/editor/formatting.rs
··· 21 21 /// 22 22 /// Expands to whitespace boundaries. Used when applying formatting 23 23 /// without a selection. 24 - pub fn find_word_boundaries(rope: &jumprope::JumpRopeBuf, offset: usize) -> (usize, usize) { 25 - let rope = rope.borrow(); 24 + pub fn find_word_boundaries(text: &loro::LoroText, offset: usize) -> (usize, usize) { 25 + let len = text.len_unicode(); 26 + 27 + // Find start by scanning backwards using char_at 26 28 let mut start = 0; 27 - let mut end = rope.len_chars(); 28 - 29 - // Find start by scanning backwards 30 - let mut char_pos = 0; 31 - for substr in rope.slice_substrings(0..offset) { 32 - for c in substr.chars() { 33 - if c.is_whitespace() { 34 - start = char_pos + 1; 29 + for i in (0..offset).rev() { 30 + match text.char_at(i) { 31 + Ok(c) if c.is_whitespace() => { 32 + start = i + 1; 33 + break; 35 34 } 36 - char_pos += 1; 35 + Ok(_) => continue, 36 + Err(_) => break, 37 37 } 38 38 } 39 39 40 - // Find end by scanning forwards 41 - char_pos = offset; 42 - let byte_len = rope.len_bytes(); 43 - for substr in rope.slice_substrings(offset..byte_len) { 44 - for c in substr.chars() { 45 - if c.is_whitespace() { 46 - end = char_pos; 47 - return (start, end); 40 + // Find end by scanning forwards using char_at 41 + let mut end = len; 42 + for i in offset..len { 43 + match text.char_at(i) { 44 + Ok(c) if c.is_whitespace() => { 45 + end = i; 46 + break; 48 47 } 49 - char_pos += 1; 48 + Ok(_) => continue, 49 + Err(_) => break, 50 50 } 51 51 } 52 52 ··· 62 62 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 63 63 } else { 64 64 // Expand to word 65 - find_word_boundaries(&doc.rope, doc.cursor.offset) 65 + find_word_boundaries(doc.loro_text(), doc.cursor.offset) 66 66 }; 67 67 68 68 match action { 69 69 FormatAction::Bold => { 70 - doc.rope.insert(end, "**"); 71 - doc.rope.insert(start, "**"); 70 + // Insert end marker first so start position stays valid 71 + let _ = doc.insert_tracked(end, "**"); 72 + let _ = doc.insert_tracked(start, "**"); 72 73 doc.cursor.offset = end + 4; 73 74 doc.selection = None; 74 75 } 75 76 FormatAction::Italic => { 76 - doc.rope.insert(end, "*"); 77 - doc.rope.insert(start, "*"); 77 + let _ = doc.insert_tracked(end, "*"); 78 + let _ = doc.insert_tracked(start, "*"); 78 79 doc.cursor.offset = end + 2; 79 80 doc.selection = None; 80 81 } 81 82 FormatAction::Strikethrough => { 82 - doc.rope.insert(end, "~~"); 83 - doc.rope.insert(start, "~~"); 83 + let _ = doc.insert_tracked(end, "~~"); 84 + let _ = doc.insert_tracked(start, "~~"); 84 85 doc.cursor.offset = end + 4; 85 86 doc.selection = None; 86 87 } 87 88 FormatAction::Code => { 88 - doc.rope.insert(end, "`"); 89 - doc.rope.insert(start, "`"); 89 + let _ = doc.insert_tracked(end, "`"); 90 + let _ = doc.insert_tracked(start, "`"); 90 91 doc.cursor.offset = end + 2; 91 92 doc.selection = None; 92 93 } 93 94 FormatAction::Link => { 94 95 // Insert [selected text](url) 95 - doc.rope.insert(end, "](url)"); 96 - doc.rope.insert(start, "["); 96 + let _ = doc.insert_tracked(end, "](url)"); 97 + let _ = doc.insert_tracked(start, "["); 97 98 doc.cursor.offset = end + 8; // Position cursor after ](url) 98 99 doc.selection = None; 99 100 } 100 101 FormatAction::Image => { 101 102 // Insert ![alt text](url) 102 - doc.rope.insert(end, "](url)"); 103 - doc.rope.insert(start, "!["); 103 + let _ = doc.insert_tracked(end, "](url)"); 104 + let _ = doc.insert_tracked(start, "!["); 104 105 doc.cursor.offset = end + 9; 105 106 doc.selection = None; 106 107 } 107 108 FormatAction::Heading(level) => { 108 109 // Find start of current line 109 - let line_start = find_line_start(&doc.rope, doc.cursor.offset); 110 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 110 111 let prefix = "#".repeat(level as usize) + " "; 111 - doc.rope.insert(line_start, &prefix); 112 + let _ = doc.insert_tracked(line_start, &prefix); 112 113 doc.cursor.offset += prefix.len(); 113 114 doc.selection = None; 114 115 } 115 116 FormatAction::BulletList => { 116 - let line_start = find_line_start(&doc.rope, doc.cursor.offset); 117 - doc.rope.insert(line_start, "- "); 117 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 118 + let _ = doc.insert_tracked(line_start, "- "); 118 119 doc.cursor.offset += 2; 119 120 doc.selection = None; 120 121 } 121 122 FormatAction::NumberedList => { 122 - let line_start = find_line_start(&doc.rope, doc.cursor.offset); 123 - doc.rope.insert(line_start, "1. "); 123 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 124 + let _ = doc.insert_tracked(line_start, "1. "); 124 125 doc.cursor.offset += 3; 125 126 doc.selection = None; 126 127 } 127 128 FormatAction::Quote => { 128 - let line_start = find_line_start(&doc.rope, doc.cursor.offset); 129 - doc.rope.insert(line_start, "> "); 129 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 130 + let _ = doc.insert_tracked(line_start, "> "); 130 131 doc.cursor.offset += 2; 131 132 doc.selection = None; 132 133 } 133 134 } 134 135 } 135 136 136 - /// Find start of line containing offset (same as in mod.rs) 137 - fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 138 - let mut char_pos = 0; 139 - let mut last_newline_pos = None; 140 - 141 - let rope = rope.borrow(); 142 - for substr in rope.slice_substrings(0..offset) { 143 - for c in substr.chars() { 144 - if c == '\n' { 145 - last_newline_pos = Some(char_pos); 146 - } 147 - char_pos += 1; 148 - } 137 + /// Find start of line containing offset 138 + fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 139 + if offset == 0 { 140 + return 0; 149 141 } 150 142 151 - last_newline_pos.map(|pos| pos + 1).unwrap_or(0) 143 + // Get text up to offset 144 + let prefix = match text.slice(0, offset) { 145 + Ok(s) => s, 146 + Err(_) => return 0, 147 + }; 148 + 149 + // Find last newline 150 + prefix 151 + .chars() 152 + .enumerate() 153 + .filter(|(_, c)| *c == '\n') 154 + .last() 155 + .map(|(pos, _)| pos + 1) 156 + .unwrap_or(0) 152 157 }
+118 -156
crates/weaver-app/src/components/editor/mod.rs
··· 10 10 mod offset_map; 11 11 mod paragraph; 12 12 mod render; 13 - mod rope_writer; 14 13 mod storage; 15 14 mod toolbar; 16 15 mod visibility; ··· 24 23 pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 25 24 pub use paragraph::ParagraphRender; 26 25 pub use render::{RenderCache, render_paragraphs_incremental}; 27 - pub use rope_writer::RopeWriter; 28 26 pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; 29 27 pub use toolbar::EditorToolbar; 30 28 pub use visibility::VisibilityState; ··· 38 36 /// - `initial_content`: Optional initial markdown content 39 37 /// 40 38 /// # Features 41 - /// - JumpRope-based text storage for efficient editing 39 + /// - Loro CRDT-based text storage with undo/redo support 42 40 /// - Event interception for full control over editing operations 43 41 /// - Toolbar formatting buttons 44 42 /// - LocalStorage auto-save with debouncing ··· 75 73 let edit = doc.last_edit.as_ref(); 76 74 77 75 let (paras, new_cache) = 78 - render::render_paragraphs_incremental(&doc.rope, Some(&cache), edit); 76 + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); 79 77 80 78 // Update cache for next render (write-only via spawn to avoid reactive loop) 81 79 dioxus::prelude::spawn(async move { ··· 107 105 // Update DOM when paragraphs change (incremental rendering) 108 106 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 109 107 use_effect(move || { 108 + // Read document once to avoid multiple borrows 109 + let doc = document(); 110 + let cursor_offset = doc.cursor.offset; 111 + let selection = doc.selection; 112 + drop(doc); // Release borrow before other operations 113 + 110 114 let new_paras = paragraphs(); 111 - let cursor_offset = document().cursor.offset; 115 + let map = offset_map(); 116 + let spans = syntax_spans(); 112 117 113 118 // Use peek() to avoid creating reactive dependency on cached_paragraphs 114 119 let prev = cached_paragraphs.peek().clone(); ··· 120 125 use wasm_bindgen::JsCast; 121 126 use wasm_bindgen::prelude::*; 122 127 123 - let rope = document().rope.clone(); 124 - let map = offset_map(); 125 - 126 128 // Use requestAnimationFrame to wait for browser paint 127 129 if let Some(window) = web_sys::window() { 128 130 let closure = Closure::once(move || { 129 131 if let Err(e) = 130 - cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) 132 + cursor::restore_cursor_position(cursor_offset, &map, editor_id) 131 133 { 132 134 tracing::warn!("Cursor restoration failed: {:?}", e); 133 135 } ··· 142 144 cached_paragraphs.set(new_paras.clone()); 143 145 144 146 // Update syntax visibility after DOM changes 145 - let doc = document(); 146 - let spans = syntax_spans(); 147 + // Debug: log what syntax spans we have 148 + for span in spans.iter() { 149 + tracing::debug!( 150 + "[VISIBILITY_INPUT] span {} char_range {:?} formatted_range {:?}", 151 + span.syn_id, 152 + span.char_range, 153 + span.formatted_range 154 + ); 155 + } 147 156 update_syntax_visibility( 148 - doc.cursor.offset, 149 - doc.selection.as_ref(), 157 + cursor_offset, 158 + selection.as_ref(), 150 159 &spans, 151 160 &new_paras, 152 161 ); ··· 155 164 // Auto-save with debounce 156 165 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 157 166 use_effect(move || { 167 + // Read document once and extract what we need 158 168 let doc = document(); 169 + let content = doc.to_string(); 170 + let cursor = doc.cursor.offset; 171 + drop(doc); 159 172 160 173 // Save after 500ms of no typing 161 174 let timer = gloo_timers::callback::Timeout::new(500, move || { 162 - let _ = storage::save_to_storage(&doc.to_string(), doc.cursor.offset); 175 + let _ = storage::save_to_storage(&content, cursor); 163 176 }); 164 177 timer.forget(); 165 178 }); ··· 213 226 }, 214 227 215 228 onclick: move |_evt| { 216 - // After mouse click, sync cursor from DOM 217 - let paras = cached_paragraphs(); 218 - sync_cursor_from_dom(&mut document, editor_id, &paras); 219 - // Update syntax visibility after cursor sync 220 - let doc = document(); 221 - let spans = syntax_spans(); 222 - update_syntax_visibility( 223 - doc.cursor.offset, 224 - doc.selection.as_ref(), 225 - &spans, 226 - &paras, 227 - ); 228 - }, 229 - 230 - onmouseup: move |_evt| { 231 - // After drag selection, sync cursor/selection from DOM 229 + // After mouse click or drag selection, sync cursor from DOM 230 + // (click fires after mouseup, so this handles both cases) 232 231 let paras = cached_paragraphs(); 233 232 sync_cursor_from_dom(&mut document, editor_id, &paras); 234 233 // Update syntax visibility after cursor sync ··· 528 527 // Delete selection if present 529 528 if let Some(sel) = doc.selection { 530 529 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 531 - doc.rope.remove(start..end); 530 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 532 531 doc.cursor.offset = start; 533 532 doc.selection = None; 534 533 } 535 534 536 535 // Insert pasted text 537 - doc.rope.insert(doc.cursor.offset, &text); 536 + let _ = doc.insert_tracked(doc.cursor.offset, &text); 538 537 doc.cursor.offset += text.chars().count(); 539 538 }); 540 539 } ··· 545 544 } 546 545 } 547 546 548 - /// Handle cut events - extract text, write to clipboard, then delete from rope 547 + /// Handle cut events - extract text, write to clipboard, then delete 549 548 fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 550 549 tracing::info!("[CUT] handle_cut called"); 551 550 ··· 560 559 if let Some(sel) = doc.selection { 561 560 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 562 561 if start != end { 563 - // Extract text from rope 564 - let selected_text = extract_rope_slice(&doc.rope, start, end); 562 + // Extract text 563 + let selected_text = doc.slice(start, end).unwrap_or_default(); 565 564 tracing::info!( 566 565 "[CUT] Extracted {} chars: {:?}", 567 566 selected_text.len(), ··· 575 574 } 576 575 } 577 576 578 - // Now delete from rope 579 - doc.rope.remove(start..end); 577 + // Now delete 578 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 580 579 doc.cursor.offset = start; 581 580 doc.selection = None; 582 581 } ··· 591 590 } 592 591 } 593 592 594 - /// Handle copy events - extract text from rope, clean it up, write to clipboard 593 + /// Handle copy events - extract text, clean it up, write to clipboard 595 594 fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 596 595 tracing::info!("[COPY] handle_copy called"); 597 596 ··· 606 605 if let Some(sel) = doc.selection { 607 606 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 608 607 if start != end { 609 - // Extract text from rope 610 - let selected_text = extract_rope_slice(&doc.rope, start, end); 608 + // Extract text 609 + let selected_text = doc.slice(start, end).unwrap_or_default(); 611 610 612 611 // Strip zero-width chars used for gap handling 613 612 let clean_text = selected_text ··· 640 639 } 641 640 } 642 641 643 - /// Extract a slice of text from the rope as a String 644 - fn extract_rope_slice(rope: &jumprope::JumpRopeBuf, start: usize, end: usize) -> String { 645 - let mut result = String::new(); 646 - let rope_ref = rope.borrow(); 647 - for substr in rope_ref.slice_substrings(start..end) { 648 - result.push_str(substr); 649 - } 650 - result 642 + /// Extract a slice of text from a string by char indices 643 + fn extract_text_slice(text: &str, start: usize, end: usize) -> String { 644 + text.chars().skip(start).take(end.saturating_sub(start)).collect() 651 645 } 652 646 653 647 /// Handle keyboard events and update document state ··· 675 669 } 676 670 } 677 671 678 - // Insert character at cursor 679 - if doc.selection.is_some() { 680 - // Delete selection first 681 - let sel = doc.selection.unwrap(); 672 + // Insert character at cursor (replacing selection if any) 673 + if let Some(sel) = doc.selection.take() { 682 674 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 683 - doc.rope.remove(start..end); 684 - doc.cursor.offset = start; 685 - doc.selection = None; 675 + let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 676 + doc.cursor.offset = start + ch.chars().count(); 677 + } else { 678 + let _ = doc.insert_tracked(doc.cursor.offset, &ch); 679 + doc.cursor.offset += ch.chars().count(); 686 680 } 687 - 688 - doc.rope.insert(doc.cursor.offset, &ch); 689 - doc.cursor.offset += ch.chars().count(); 690 681 } 691 682 692 683 Key::Backspace => { 693 684 if let Some(sel) = doc.selection { 694 685 // Delete selection 695 686 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 696 - doc.rope.remove(start..end); 687 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 697 688 doc.cursor.offset = start; 698 689 doc.selection = None; 699 690 } else if doc.cursor.offset > 0 { 700 691 // Check if we're about to delete a newline 701 - let prev_char = get_char_at(&doc.rope, doc.cursor.offset - 1); 692 + let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); 702 693 703 694 if prev_char == Some('\n') { 704 695 let newline_pos = doc.cursor.offset - 1; ··· 708 699 // Check if there's another newline before this one (empty paragraph) 709 700 // If so, delete both newlines to merge paragraphs 710 701 if newline_pos > 0 { 711 - let prev_prev_char = get_char_at(&doc.rope, newline_pos - 1); 702 + let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 712 703 if prev_prev_char == Some('\n') { 713 704 // Empty paragraph case: delete both newlines 714 705 delete_start = newline_pos - 1; ··· 716 707 } 717 708 718 709 // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 719 - if let Some(ch) = get_char_at(&doc.rope, delete_end) { 710 + if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 720 711 if ch == '\u{200C}' || ch == '\u{200B}' { 721 712 delete_end += 1; 722 713 } ··· 724 715 725 716 // Scan backwards through whitespace before the newline(s) 726 717 while delete_start > 0 { 727 - let ch = get_char_at(&doc.rope, delete_start - 1); 718 + let ch = get_char_at(doc.loro_text(), delete_start - 1); 728 719 match ch { 729 720 Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => { 730 721 delete_start -= 1; ··· 735 726 } 736 727 737 728 // Delete from where we stopped to end (including any trailing zero-width) 738 - doc.rope.remove(delete_start..delete_end); 729 + let _ = doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 739 730 doc.cursor.offset = delete_start; 740 731 } else { 741 732 // Normal backspace - delete one char 742 733 let prev = doc.cursor.offset - 1; 743 - doc.rope.remove(prev..doc.cursor.offset); 734 + let _ = doc.remove_tracked(prev, 1); 744 735 doc.cursor.offset = prev; 745 736 } 746 737 } 747 738 } 748 739 749 740 Key::Delete => { 750 - if let Some(sel) = doc.selection { 741 + if let Some(sel) = doc.selection.take() { 751 742 // Delete selection 752 743 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 753 - doc.rope.remove(start..end); 744 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 754 745 doc.cursor.offset = start; 755 - doc.selection = None; 756 746 } else if doc.cursor.offset < doc.len_chars() { 757 747 // Delete next char 758 - doc.rope.remove(doc.cursor.offset..doc.cursor.offset + 1); 748 + let _ = doc.remove_tracked(doc.cursor.offset, 1); 759 749 } 760 750 } 761 751 ··· 765 755 } 766 756 767 757 Key::Enter => { 768 - if doc.selection.is_some() { 769 - let sel = doc.selection.unwrap(); 758 + if let Some(sel) = doc.selection.take() { 770 759 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 771 - doc.rope.remove(start..end); 760 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 772 761 doc.cursor.offset = start; 773 - doc.selection = None; 774 762 } 775 763 776 764 if mods.shift() { 777 765 // Shift+Enter: hard line break (soft break) 778 - doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); 766 + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 779 767 doc.cursor.offset += 3; 780 - } else if let Some(ctx) = detect_list_context(&doc.rope, doc.cursor.offset) { 768 + } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 781 769 // We're in a list item 782 770 tracing::debug!("[ENTER] List context detected: {:?}", ctx); 783 771 tracing::debug!( 784 - "[ENTER] Cursor at {}, rope len {}", 772 + "[ENTER] Cursor at {}, doc len {}", 785 773 doc.cursor.offset, 786 - doc.rope.len_chars() 774 + doc.len_chars() 787 775 ); 788 - if is_list_item_empty(&doc.rope, doc.cursor.offset, &ctx) { 776 + if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { 789 777 tracing::debug!("[ENTER] Item is empty, exiting list"); 790 778 // Empty item - exit list by removing marker and inserting paragraph break 791 - let line_start = find_line_start(&doc.rope, doc.cursor.offset); 792 - let line_end = find_line_end(&doc.rope, doc.cursor.offset); 779 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 780 + let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); 793 781 794 782 // Delete the empty list item line INCLUDING its trailing newline 795 783 // line_end points to the newline, so +1 to include it 796 - let delete_end = (line_end + 1).min(doc.rope.len_chars()); 784 + let delete_end = (line_end + 1).min(doc.len_chars()); 797 785 798 - doc.rope.remove(line_start..delete_end); 799 - doc.cursor.offset = line_start; 800 - 801 - // Insert two newlines, a zero-width whitespace character, and then another 802 - // newline to properly split the list (TODO: clean up the weird whitespace 803 - // char once that new paragraph has content) 804 - doc.rope.insert(doc.cursor.offset, "\n\n\u{200C}\n"); 805 - doc.cursor.offset += 2; 786 + // Use replace_tracked to atomically delete line and insert paragraph break 787 + let _ = doc.replace_tracked(line_start, delete_end.saturating_sub(line_start), "\n\n\u{200C}\n"); 788 + doc.cursor.offset = line_start + 2; 806 789 } else { 807 790 // Non-empty item - continue list 808 791 let continuation = match ctx { ··· 814 797 } 815 798 }; 816 799 let len = continuation.chars().count(); 817 - doc.rope.insert(doc.cursor.offset, &continuation); 800 + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 818 801 doc.cursor.offset += len; 819 802 } 820 803 } else { 821 804 // Not in a list - normal paragraph break 822 - doc.rope.insert(doc.cursor.offset, "\n\n"); 805 + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 823 806 doc.cursor.offset += 2; 824 807 } 825 808 } ··· 846 829 /// Detect if cursor is in a list item and return context for continuation. 847 830 /// 848 831 /// Scans backwards to find start of current line, then checks for list marker. 849 - fn detect_list_context(rope: &jumprope::JumpRopeBuf, cursor_offset: usize) -> Option<ListContext> { 832 + fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> { 850 833 // Find start of current line 851 - let line_start = find_line_start(rope, cursor_offset); 834 + let line_start = find_line_start(text, cursor_offset); 852 835 853 836 // Get the line content from start to cursor 854 - let line_end = find_line_end(rope, cursor_offset); 837 + let line_end = find_line_end(text, cursor_offset); 855 838 if line_start >= line_end { 856 839 return None; 857 840 } 858 841 859 842 // Extract line text 860 - let mut line = String::new(); 861 - let rope_ref = rope.borrow(); 862 - for substr in rope_ref.slice_substrings(line_start..line_end) { 863 - line.push_str(substr); 864 - } 843 + let line = text.slice(line_start, line_end).ok()?; 865 844 866 845 // Parse indentation 867 846 let indent: String = line ··· 901 880 /// 902 881 /// Used to determine whether Enter should continue the list or exit it. 903 882 fn is_list_item_empty( 904 - rope: &jumprope::JumpRopeBuf, 883 + text: &loro::LoroText, 905 884 cursor_offset: usize, 906 885 ctx: &ListContext, 907 886 ) -> bool { 908 - let line_start = find_line_start(rope, cursor_offset); 909 - let line_end = find_line_end(rope, cursor_offset); 887 + let line_start = find_line_start(text, cursor_offset); 888 + let line_end = find_line_end(text, cursor_offset); 910 889 911 890 // Get line content 912 - let mut line = String::new(); 913 - let rope_ref = rope.borrow(); 914 - for substr in rope_ref.slice_substrings(line_start..line_end) { 915 - line.push_str(substr); 916 - } 891 + let line = match text.slice(line_start, line_end) { 892 + Ok(s) => s, 893 + Err(_) => return false, 894 + }; 917 895 918 896 // Calculate expected marker length 919 897 let marker_len = match ctx { ··· 935 913 line.len() <= marker_len 936 914 } 937 915 938 - /// Get character at the given offset in the rope 939 - fn get_char_at(rope: &jumprope::JumpRopeBuf, offset: usize) -> Option<char> { 940 - if offset >= rope.len_chars() { 941 - return None; 942 - } 943 - 944 - let rope = rope.borrow(); 945 - let mut current = 0; 946 - for substr in rope.slice_substrings(offset..offset + 1) { 947 - for c in substr.chars() { 948 - if current == 0 { 949 - return Some(c); 950 - } 951 - current += 1; 952 - } 953 - } 954 - None 916 + /// Get character at the given offset in LoroText 917 + fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> { 918 + text.char_at(offset).ok() 955 919 } 956 920 957 921 /// Find start of line containing offset 958 - fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 959 - // Search backwards from cursor for newline 960 - let mut char_pos = 0; 961 - let mut last_newline_pos = None; 962 - 963 - let rope = rope.borrow(); 964 - for substr in rope.slice_substrings(0..offset) { 965 - // TODO: make more efficient 966 - for c in substr.chars() { 967 - if c == '\n' { 968 - last_newline_pos = Some(char_pos); 969 - } 970 - char_pos += 1; 971 - } 922 + fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 923 + if offset == 0 { 924 + return 0; 972 925 } 973 - 974 - last_newline_pos.map(|pos| pos + 1).unwrap_or(0) 926 + // Only slice the portion before cursor 927 + let prefix = match text.slice(0, offset) { 928 + Ok(s) => s, 929 + Err(_) => return 0, 930 + }; 931 + prefix 932 + .chars() 933 + .enumerate() 934 + .filter(|(_, c)| *c == '\n') 935 + .last() 936 + .map(|(pos, _)| pos + 1) 937 + .unwrap_or(0) 975 938 } 976 939 977 940 /// Find end of line containing offset 978 - fn find_line_end(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 979 - // Search forwards from cursor for newline 980 - let mut char_pos = offset; 981 - 982 - let rope = rope.borrow(); 983 - let byte_len = rope.len_bytes() - 1; 984 - for substr in rope.slice_substrings(offset..byte_len) { 985 - // TODO: make more efficient 986 - for c in substr.chars() { 987 - if c == '\n' { 988 - return char_pos; 989 - } 990 - char_pos += 1; 991 - } 941 + fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { 942 + let char_len = text.len_unicode(); 943 + if offset >= char_len { 944 + return char_len; 992 945 } 993 - 994 - rope.len_chars() 946 + // Only slice from cursor to end 947 + let suffix = match text.slice(offset, char_len) { 948 + Ok(s) => s, 949 + Err(_) => return char_len, 950 + }; 951 + suffix 952 + .chars() 953 + .enumerate() 954 + .find(|(_, c)| *c == '\n') 955 + .map(|(i, _)| offset + i) 956 + .unwrap_or(char_len) 995 957 } 996 958 997 959 /// Update paragraph DOM elements incrementally.
+4 -11
crates/weaver-app/src/components/editor/paragraph.rs
··· 5 5 6 6 use super::offset_map::OffsetMapping; 7 7 use super::writer::SyntaxSpanInfo; 8 - use jumprope::JumpRopeBuf; 8 + use loro::LoroText; 9 9 use std::ops::Range; 10 10 11 11 /// A rendered paragraph with its source range and offset mappings. ··· 40 40 hasher.finish() 41 41 } 42 42 43 - /// Extract substring from rope as String 44 - pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range<usize>) -> String { 45 - let rope_borrow = rope.borrow(); 46 - let mut result = String::new(); 47 - 48 - for substr in rope_borrow.slice_substrings(range) { 49 - result.push_str(substr); 50 - } 51 - 52 - result 43 + /// Extract substring from LoroText as String 44 + pub fn text_slice_to_string(text: &LoroText, range: Range<usize>) -> String { 45 + text.slice(range.start, range.end).unwrap_or_default() 53 46 } 54 47
+15 -12
crates/weaver-app/src/components/editor/render.rs
··· 6 6 7 7 use super::document::EditInfo; 8 8 use super::offset_map::{OffsetMapping, RenderResult}; 9 - use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; 9 + use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string}; 10 10 use super::writer::{EditorWriter, SyntaxSpanInfo}; 11 - use jumprope::JumpRopeBuf; 11 + use loro::LoroText; 12 12 use markdown_weaver::Parser; 13 13 use std::ops::Range; 14 14 ··· 105 105 /// # Returns 106 106 /// Tuple of (rendered paragraphs, updated cache) 107 107 pub fn render_paragraphs_incremental( 108 - rope: &JumpRopeBuf, 108 + text: &LoroText, 109 109 cache: Option<&RenderCache>, 110 110 edit: Option<&EditInfo>, 111 111 ) -> (Vec<ParagraphRender>, RenderCache) { 112 - let source = rope.to_string(); 112 + let source = text.to_string(); 113 113 114 114 // Handle empty document 115 115 if source.is_empty() { ··· 190 190 191 191 match EditorWriter::<_, _, ()>::new_boundary_only( 192 192 &source, 193 - rope, 193 + text, 194 194 parser, 195 195 &mut scratch_output, 196 196 ) ··· 213 213 let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0); 214 214 215 215 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 216 - let para_source = rope_slice_to_string(rope, char_range.clone()); 216 + let para_source = text_slice_to_string(text, char_range.clone()); 217 217 let source_hash = hash_source(&para_source); 218 218 219 219 tracing::debug!( ··· 250 250 251 251 (cached.html.clone(), adjusted_map, adjusted_syntax) 252 252 } else { 253 - // Fresh render needed 254 - let para_rope = JumpRopeBuf::from(para_source.as_str()); 253 + // Fresh render needed - create detached LoroDoc for this paragraph 254 + let para_doc = loro::LoroDoc::new(); 255 + let para_text = para_doc.get_text("content"); 256 + let _ = para_text.insert(0, &para_source); 257 + 255 258 let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 256 259 .into_offset_iter(); 257 260 let mut output = String::new(); ··· 259 262 let (mut offset_map, mut syntax_spans) = 260 263 match EditorWriter::<_, _, ()>::new_with_offsets( 261 264 &para_source, 262 - &para_rope, 265 + &para_text, 263 266 parser, 264 267 &mut output, 265 268 node_id_offset, ··· 374 377 utf16_len: 1, 375 378 }], 376 379 syntax_spans: vec![], 377 - source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), 380 + source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)), 378 381 }); 379 382 } 380 383 ··· 386 389 // Add trailing gap if needed 387 390 let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); 388 391 if has_trailing_newlines { 389 - let doc_end_char = rope.len_chars(); 390 - let doc_end_byte = rope.len_bytes(); 392 + let doc_end_char = text.len_unicode(); 393 + let doc_end_byte = text.len_utf8(); 391 394 392 395 if doc_end_char > prev_end_char { 393 396 // Position-based ID for trailing gap
-81
crates/weaver-app/src/components/editor/rope_writer.rs
··· 1 - //! StrWrite wrapper for JumpRopeBuf to enable efficient HTML rendering. 2 - 3 - use jumprope::JumpRopeBuf; 4 - use markdown_weaver_escape::StrWrite; 5 - 6 - /// Wrapper around JumpRopeBuf that implements StrWrite. 7 - /// 8 - /// This allows rendering HTML directly into a rope structure, enabling: 9 - /// - O(log n) insertions instead of O(n) string reallocation 10 - /// - Efficient splicing for incremental rendering 11 - /// - Fast paragraph replacement in cached output 12 - pub struct RopeWriter { 13 - rope: JumpRopeBuf, 14 - } 15 - 16 - impl RopeWriter { 17 - pub fn new() -> Self { 18 - Self { 19 - rope: JumpRopeBuf::new(), 20 - } 21 - } 22 - 23 - pub fn from_rope(rope: JumpRopeBuf) -> Self { 24 - Self { rope } 25 - } 26 - 27 - pub fn into_rope(self) -> JumpRopeBuf { 28 - self.rope 29 - } 30 - 31 - pub fn as_rope(&self) -> &JumpRopeBuf { 32 - &self.rope 33 - } 34 - 35 - pub fn to_string(&self) -> String { 36 - self.rope.to_string() 37 - } 38 - } 39 - 40 - impl Default for RopeWriter { 41 - fn default() -> Self { 42 - Self::new() 43 - } 44 - } 45 - 46 - impl StrWrite for RopeWriter { 47 - type Error = std::convert::Infallible; 48 - 49 - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { 50 - let offset = self.rope.len_chars(); 51 - self.rope.insert(offset, s); 52 - Ok(()) 53 - } 54 - 55 - fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> Result<(), Self::Error> { 56 - let mut temp = String::new(); 57 - std::fmt::Write::write_fmt(&mut temp, args).unwrap(); 58 - self.write_str(&temp) 59 - } 60 - } 61 - 62 - #[cfg(test)] 63 - mod tests { 64 - use super::*; 65 - 66 - #[test] 67 - fn test_rope_writer_basic() { 68 - let mut writer = RopeWriter::new(); 69 - writer.write_str("hello ").unwrap(); 70 - writer.write_str("world").unwrap(); 71 - assert_eq!(writer.to_string(), "hello world"); 72 - } 73 - 74 - #[test] 75 - fn test_rope_writer_fmt() { 76 - use std::fmt::Write; 77 - let mut writer = RopeWriter::new(); 78 - write!(&mut writer, "number: {}", 42).unwrap(); 79 - assert_eq!(writer.to_string(), "number: 42"); 80 - } 81 - }
+14 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"7\">**</span><strong>bold<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">**</span></strong> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"7\">**</span><strong>bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">**</span> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 31 31 child_index: ~ 32 32 utf16_len: 5 33 33 - byte_range: 34 + - 5 35 + - 7 36 + char_range: 37 + - 5 38 + - 7 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 2 43 + - byte_range: 34 44 - 7 35 45 - 11 36 46 char_range: 37 47 - 7 38 48 - 11 39 49 node_id: n0 40 - char_offset_in_node: 5 50 + char_offset_in_node: 7 41 51 child_index: ~ 42 52 utf16_len: 4 43 53 - byte_range: ··· 47 57 - 11 48 58 - 13 49 59 node_id: n0 50 - char_offset_in_node: 9 60 + char_offset_in_node: 11 51 61 child_index: ~ 52 62 utf16_len: 2 53 63 - byte_range: ··· 57 67 - 13 58 68 - 18 59 69 node_id: n0 60 - char_offset_in_node: 11 70 + char_offset_in_node: 13 61 71 child_index: ~ 62 72 utf16_len: 5 63 73 source_hash: 3007541947422346271
+25 -5
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span><strong>bold italic<span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"21\">**</span></strong><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">*</span></em> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span><strong>bold italic</strong><span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"21\">**</span></em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">*</span> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 31 31 child_index: ~ 32 32 utf16_len: 5 33 33 - byte_range: 34 + - 5 35 + - 6 36 + char_range: 37 + - 5 38 + - 6 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 1 43 + - byte_range: 44 + - 6 45 + - 8 46 + char_range: 47 + - 6 48 + - 8 49 + node_id: n0 50 + char_offset_in_node: 6 51 + child_index: ~ 52 + utf16_len: 2 53 + - byte_range: 34 54 - 8 35 55 - 19 36 56 char_range: 37 57 - 8 38 58 - 19 39 59 node_id: n0 40 - char_offset_in_node: 5 60 + char_offset_in_node: 8 41 61 child_index: ~ 42 62 utf16_len: 11 43 63 - byte_range: ··· 47 67 - 19 48 68 - 21 49 69 node_id: n0 50 - char_offset_in_node: 16 70 + char_offset_in_node: 19 51 71 child_index: ~ 52 72 utf16_len: 2 53 73 - byte_range: ··· 57 77 - 21 58 78 - 22 59 79 node_id: n0 60 - char_offset_in_node: 18 80 + char_offset_in_node: 21 61 81 child_index: ~ 62 82 utf16_len: 1 63 83 - byte_range: ··· 67 87 - 22 68 88 - 27 69 89 node_id: n0 70 - char_offset_in_node: 19 90 + char_offset_in_node: 22 71 91 child_index: ~ 72 92 utf16_len: 5 73 93 source_hash: 17839597501764990486
+2 -2
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
··· 39 39 node_id: n0 40 40 char_offset_in_node: 5 41 41 child_index: ~ 42 - utf16_len: 4 42 + utf16_len: 6 43 43 - byte_range: 44 44 - 11 45 45 - 16 ··· 47 47 - 11 48 48 - 16 49 49 node_id: n0 50 - char_offset_in_node: 9 50 + char_offset_in_node: 11 51 51 child_index: ~ 52 52 utf16_len: 5 53 53 source_hash: 10489263388249723293
+14 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em>italic<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"13\">*</span></em> text</p>\n" 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"13\">*</span> text</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 31 31 child_index: ~ 32 32 utf16_len: 5 33 33 - byte_range: 34 + - 5 35 + - 6 36 + char_range: 37 + - 5 38 + - 6 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 1 43 + - byte_range: 34 44 - 6 35 45 - 12 36 46 char_range: 37 47 - 6 38 48 - 12 39 49 node_id: n0 40 - char_offset_in_node: 5 50 + char_offset_in_node: 6 41 51 child_index: ~ 42 52 utf16_len: 6 43 53 - byte_range: ··· 47 57 - 12 48 58 - 13 49 59 node_id: n0 50 - char_offset_in_node: 11 60 + char_offset_in_node: 12 51 61 child_index: ~ 52 62 utf16_len: 1 53 63 - byte_range: ··· 57 67 - 13 58 68 - 18 59 69 node_id: n0 60 - char_offset_in_node: 12 70 + char_offset_in_node: 13 61 71 child_index: ~ 62 72 utf16_len: 5 63 73 source_hash: 4363411941421262428
+29 -9
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 32 11 - html: "<p id=\"n0\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span></strong> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span></em> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\">`</span></p>\n" 11 + html: "<p id=\"n0\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\">`</span></p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 21 21 child_index: 0 22 22 utf16_len: 0 23 23 - byte_range: 24 + - 0 25 + - 2 26 + char_range: 27 + - 0 28 + - 2 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 2 33 + - byte_range: 24 34 - 2 25 35 - 6 26 36 char_range: 27 37 - 2 28 38 - 6 29 39 node_id: n0 30 - char_offset_in_node: 0 40 + char_offset_in_node: 2 31 41 child_index: ~ 32 42 utf16_len: 4 33 43 - byte_range: ··· 37 47 - 6 38 48 - 8 39 49 node_id: n0 40 - char_offset_in_node: 4 50 + char_offset_in_node: 6 41 51 child_index: ~ 42 52 utf16_len: 2 43 53 - byte_range: ··· 47 57 - 8 48 58 - 13 49 59 node_id: n0 50 - char_offset_in_node: 6 60 + char_offset_in_node: 8 51 61 child_index: ~ 52 62 utf16_len: 5 53 63 - byte_range: 64 + - 13 65 + - 14 66 + char_range: 67 + - 13 68 + - 14 69 + node_id: n0 70 + char_offset_in_node: 13 71 + child_index: ~ 72 + utf16_len: 1 73 + - byte_range: 54 74 - 14 55 75 - 20 56 76 char_range: 57 77 - 14 58 78 - 20 59 79 node_id: n0 60 - char_offset_in_node: 11 80 + char_offset_in_node: 14 61 81 child_index: ~ 62 82 utf16_len: 6 63 83 - byte_range: ··· 67 87 - 20 68 88 - 21 69 89 node_id: n0 70 - char_offset_in_node: 17 90 + char_offset_in_node: 20 71 91 child_index: ~ 72 92 utf16_len: 1 73 93 - byte_range: ··· 77 97 - 21 78 98 - 26 79 99 node_id: n0 80 - char_offset_in_node: 18 100 + char_offset_in_node: 21 81 101 child_index: ~ 82 102 utf16_len: 5 83 103 - byte_range: ··· 87 107 - 27 88 108 - 31 89 109 node_id: n0 90 - char_offset_in_node: 23 110 + char_offset_in_node: 26 91 111 child_index: ~ 92 - utf16_len: 4 112 + utf16_len: 6 93 113 source_hash: 17988102203032347642
+10 -40
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">1.</span><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"2\" data-char-end=\"3\"> </span>First<span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"9\" data-char-end=\"11\">2.</span><span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"11\" data-char-end=\"12\"> </span>Second<span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"18\" data-char-end=\"19\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s6\" data-char-start=\"19\" data-char-end=\"21\">3.</span><span class=\"md-syntax-inline\" data-syn-id=\"s7\" data-char-start=\"21\" data-char-end=\"22\"> </span>Third</li>\n</ol>\n" 11 + html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\">1. </span>First<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"9\" data-char-end=\"12\">2. </span>Second<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"18\" data-char-end=\"19\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"19\" data-char-end=\"22\">3. </span>Third</li>\n</ol>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 15 - - 2 15 + - 3 16 16 char_range: 17 17 - 0 18 - - 2 18 + - 3 19 19 node_id: n0 20 20 char_offset_in_node: 0 21 21 child_index: ~ 22 - utf16_len: 2 23 - - byte_range: 24 - - 2 25 - - 3 26 - char_range: 27 - - 2 28 - - 3 29 - node_id: n0 30 - char_offset_in_node: 2 31 - child_index: ~ 32 - utf16_len: 1 22 + utf16_len: 3 33 23 - byte_range: 34 24 - 3 35 25 - 8 ··· 52 42 utf16_len: 1 53 43 - byte_range: 54 44 - 9 55 - - 11 56 - char_range: 57 - - 9 58 - - 11 59 - node_id: n1 60 - char_offset_in_node: 0 61 - child_index: ~ 62 - utf16_len: 2 63 - - byte_range: 64 - - 11 65 45 - 12 66 46 char_range: 67 - - 11 47 + - 9 68 48 - 12 69 49 node_id: n1 70 - char_offset_in_node: 2 50 + char_offset_in_node: 0 71 51 child_index: ~ 72 - utf16_len: 1 52 + utf16_len: 3 73 53 - byte_range: 74 54 - 12 75 55 - 18 ··· 92 72 utf16_len: 1 93 73 - byte_range: 94 74 - 19 95 - - 21 75 + - 22 96 76 char_range: 97 77 - 19 98 - - 21 78 + - 22 99 79 node_id: n2 100 80 char_offset_in_node: 0 101 81 child_index: ~ 102 - utf16_len: 2 103 - - byte_range: 104 - - 21 105 - - 22 106 - char_range: 107 - - 21 108 - - 22 109 - node_id: n2 110 - char_offset_in_node: 2 111 - child_index: ~ 112 - utf16_len: 1 82 + utf16_len: 3 113 83 - byte_range: 114 84 - 22 115 85 - 27
+18 -10
crates/weaver-app/src/components/editor/tests.rs
··· 3 3 use super::offset_map::{OffsetMapping, find_mapping_for_char}; 4 4 use super::paragraph::ParagraphRender; 5 5 use super::render::render_paragraphs_incremental; 6 - use jumprope::JumpRopeBuf; 6 + use loro::LoroDoc; 7 7 use serde::Serialize; 8 8 9 9 /// Serializable version of ParagraphRender for snapshot testing. ··· 54 54 55 55 /// Helper: render markdown and convert to serializable test output. 56 56 fn render_test(input: &str) -> Vec<TestParagraph> { 57 - let rope = JumpRopeBuf::from(input); 58 - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 57 + let doc = LoroDoc::new(); 58 + let text = doc.get_text("content"); 59 + text.insert(0, input).unwrap(); 60 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 59 61 paragraphs.iter().map(TestParagraph::from).collect() 60 62 } 61 63 ··· 434 436 // cursor snaps to adjacent paragraphs for standard breaks. 435 437 // Only EXTRA whitespace beyond \n\n gets gap elements. 436 438 let input = "Hello\n\nWorld"; 437 - let rope = JumpRopeBuf::from(input); 438 - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 439 + let doc = LoroDoc::new(); 440 + let text = doc.get_text("content"); 441 + text.insert(0, input).unwrap(); 442 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 439 443 440 444 // With standard \n\n break, we expect 2 paragraphs (no gap element) 441 445 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 453 457 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 454 458 // Plain paragraphs don't consume trailing newlines like headings do 455 459 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 456 - let rope = JumpRopeBuf::from(input); 457 - let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None); 460 + let doc = LoroDoc::new(); 461 + let text = doc.get_text("content"); 462 + text.insert(0, input).unwrap(); 463 + let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 458 464 459 465 // With extra newlines, we expect 3 elements: para, gap, para 460 466 assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); ··· 542 548 fn test_incremental_cache_reuse() { 543 549 // Verify cache is populated and can be reused 544 550 let input = "First para\n\nSecond para"; 545 - let rope = JumpRopeBuf::from(input); 551 + let doc = LoroDoc::new(); 552 + let text = doc.get_text("content"); 553 + text.insert(0, input).unwrap(); 546 554 547 - let (paras1, cache1) = render_paragraphs_incremental(&rope, None, None); 555 + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); 548 556 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 549 557 550 558 // Second render with same content should reuse cache 551 - let (paras2, _cache2) = render_paragraphs_incremental(&rope, Some(&cache1), None); 559 + let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None); 552 560 553 561 // Should produce identical output 554 562 assert_eq!(paras1.len(), paras2.len());
+108 -9
crates/weaver-app/src/components/editor/visibility.rs
··· 33 33 let mut visible = HashSet::new(); 34 34 35 35 for span in syntax_spans { 36 + // Find the paragraph containing this span for boundary clamping 37 + let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); 38 + 36 39 let should_show = match span.syntax_type { 37 40 SyntaxType::Inline => { 38 41 // Show if cursor within formatted span content OR adjacent to markers 39 - // "Adjacent" means within 1 char of the syntax boundaries 40 - let extended_range = span.char_range.start.saturating_sub(1) 41 - ..span.char_range.end.saturating_add(1); 42 + // "Adjacent" means within 1 char of the syntax boundaries, 43 + // clamped to paragraph bounds (paragraphs are split by newlines, 44 + // so clamping to para bounds prevents cross-line extension) 45 + let extended_start = 46 + safe_extend_left(span.char_range.start, 1, para_bounds.as_ref()); 47 + let extended_end = 48 + safe_extend_right(span.char_range.end, 1, para_bounds.as_ref()); 49 + let extended_range = extended_start..extended_end; 42 50 43 51 // Also show if cursor is anywhere in the formatted_range 44 52 // (the region between paired opening/closing markers) 53 + // Extend by 1 char on BOTH sides for symmetric "approaching" behavior, 54 + // clamped to paragraph bounds. 45 55 let in_formatted_region = span 46 56 .formatted_range 47 57 .as_ref() 48 - .map(|r| r.contains(&cursor_offset)) 58 + .map(|r| { 59 + let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref()); 60 + let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref()); 61 + cursor_offset >= ext_start && cursor_offset <= ext_end 62 + }) 49 63 .unwrap_or(false); 50 64 51 - extended_range.contains(&cursor_offset) 65 + let in_extended = extended_range.contains(&cursor_offset); 66 + let result = in_extended 52 67 || in_formatted_region 53 68 || selection_overlaps(selection, &span.char_range) 54 69 || span 55 70 .formatted_range 56 71 .as_ref() 57 72 .map(|r| selection_overlaps(selection, r)) 58 - .unwrap_or(false) 73 + .unwrap_or(false); 74 + 75 + tracing::debug!( 76 + "[VISIBILITY] span {} char_range {:?} formatted_range {:?} cursor {} -> in_extended={} in_formatted={} visible={}", 77 + span.syn_id, 78 + span.char_range, 79 + span.formatted_range, 80 + cursor_offset, 81 + in_extended, 82 + in_formatted_region, 83 + result 84 + ); 85 + 86 + result 59 87 } 60 88 SyntaxType::Block => { 61 89 // Show if cursor anywhere in same paragraph ··· 116 144 false 117 145 } 118 146 147 + /// Find the paragraph bounds containing a syntax span. 148 + fn find_paragraph_bounds( 149 + syntax_range: &Range<usize>, 150 + paragraphs: &[ParagraphRender], 151 + ) -> Option<Range<usize>> { 152 + for para in paragraphs { 153 + // Skip gap paragraphs 154 + if para.syntax_spans.is_empty() && !para.char_range.is_empty() { 155 + continue; 156 + } 157 + 158 + if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { 159 + return Some(para.char_range.clone()); 160 + } 161 + } 162 + None 163 + } 164 + 165 + /// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds. 166 + /// 167 + /// Paragraphs are already split by newlines, so clamping to paragraph bounds 168 + /// naturally prevents extending across line boundaries. 169 + fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize { 170 + let min_pos = para_bounds.map(|p| p.start).unwrap_or(0); 171 + pos.saturating_sub(amount).max(min_pos) 172 + } 173 + 174 + /// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds. 175 + /// 176 + /// Paragraphs are already split by newlines, so clamping to paragraph bounds 177 + /// naturally prevents extending across line boundaries. 178 + fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize { 179 + let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX); 180 + pos.saturating_add(amount).min(max_pos) 181 + } 182 + 119 183 #[cfg(test)] 120 184 mod tests { 121 185 use super::*; ··· 197 261 198 262 #[test] 199 263 fn test_inline_visibility_cursor_adjacent() { 264 + // "test **bold** after" 265 + // 5 7 200 266 let spans = vec![ 201 267 make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 202 268 ]; 203 - let paras = vec![make_para(0, 20, spans.clone())]; 269 + let paras = vec![make_para(0, 19, spans.clone())]; 204 270 205 271 // Cursor at position 4 (one before ** which starts at 5) 206 272 let vis = VisibilityState::calculate(4, None, &spans, &paras); ··· 216 282 let spans = vec![ 217 283 make_span("s0", 10, 12, SyntaxType::Inline), 218 284 ]; 219 - let paras = vec![make_para(0, 30, spans.clone())]; 285 + let paras = vec![make_para(0, 33, spans.clone())]; 220 286 221 287 // Cursor at position 0 (far from **) 222 288 let vis = VisibilityState::calculate(0, None, &spans, &paras); ··· 259 325 let spans = vec![ 260 326 make_span("s0", 5, 7, SyntaxType::Inline), 261 327 ]; 262 - let paras = vec![make_para(0, 20, spans.clone())]; 328 + let paras = vec![make_para(0, 24, spans.clone())]; 263 329 264 330 // Selection overlaps the syntax span 265 331 let selection = Selection { anchor: 3, head: 10 }; 266 332 let vis = VisibilityState::calculate(10, Some(&selection), &spans, &paras); 267 333 assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); 334 + } 335 + 336 + #[test] 337 + fn test_paragraph_boundary_blocks_extension() { 338 + // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1, 339 + // even if cursor is only 1 char after the paragraph boundary 340 + // (paragraph bounds clamp the extension) 341 + let spans = vec![ 342 + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** 343 + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** 344 + ]; 345 + let paras = vec![ 346 + make_para(0, 8, spans.clone()), // "**bold**" 347 + make_para(9, 13, vec![]), // "text" (after newline) 348 + ]; 349 + 350 + // Cursor at position 9 (start of second paragraph) 351 + // Should NOT reveal the closing ** because para bounds clamp extension 352 + let vis = VisibilityState::calculate(9, None, &spans, &paras); 353 + assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph"); 354 + } 355 + 356 + #[test] 357 + fn test_extension_clamps_to_paragraph() { 358 + // Syntax at very start of paragraph - extension left should stop at para start 359 + let spans = vec![ 360 + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), 361 + ]; 362 + let paras = vec![make_para(0, 8, spans.clone())]; 363 + 364 + // Cursor at position 0 - should still see the opening ** 365 + let vis = VisibilityState::calculate(0, None, &spans, &paras); 366 + assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0"); 268 367 } 269 368 }
+95 -26
crates/weaver-app/src/components/editor/writer.rs
··· 7 7 //! represent consumed formatting characters. 8 8 9 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 - use jumprope::JumpRopeBuf; 10 + use loro::LoroText; 11 11 use markdown_weaver::{ 12 12 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 13 13 }; ··· 109 109 /// and emits them as styled spans for visibility in the editor. 110 110 pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> { 111 111 source: &'a str, 112 - source_rope: &'a JumpRopeBuf, 112 + source_text: &'a LoroText, 113 113 events: I, 114 114 writer: W, 115 115 last_byte_offset: usize, ··· 141 141 current_node_id: Option<String>, // node ID for current text container 142 142 current_node_char_offset: usize, // UTF-16 offset within current node 143 143 current_node_child_count: usize, // number of child elements/text nodes in current container 144 + 145 + // Incremental UTF-16 offset tracking (replaces rope.chars_to_wchars) 146 + // Maps char_offset -> utf16_offset at checkpoints we've traversed. 147 + // Can be reused for future lookups or passed to subsequent writers. 148 + utf16_checkpoints: Vec<(usize, usize)>, // (char_offset, utf16_offset) 144 149 145 150 // Paragraph boundary tracking for incremental rendering 146 151 paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range) ··· 170 175 impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider> 171 176 EditorWriter<'a, I, W, E> 172 177 { 173 - pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self { 174 - Self::new_with_node_offset(source, source_rope, events, writer, 0) 178 + pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { 179 + Self::new_with_node_offset(source, source_text, events, writer, 0) 175 180 } 176 181 177 182 pub fn new_with_node_offset( 178 183 source: &'a str, 179 - source_rope: &'a JumpRopeBuf, 184 + source_text: &'a LoroText, 180 185 events: I, 181 186 writer: W, 182 187 node_id_offset: usize, 183 188 ) -> Self { 184 - Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0) 189 + Self::new_with_offsets(source, source_text, events, writer, node_id_offset, 0) 185 190 } 186 191 187 192 pub fn new_with_offsets( 188 193 source: &'a str, 189 - source_rope: &'a JumpRopeBuf, 194 + source_text: &'a LoroText, 190 195 events: I, 191 196 writer: W, 192 197 node_id_offset: usize, ··· 194 199 ) -> Self { 195 200 Self { 196 201 source, 197 - source_rope, 202 + source_text, 198 203 events, 199 204 writer, 200 205 last_byte_offset: 0, ··· 217 222 current_node_id: None, 218 223 current_node_char_offset: 0, 219 224 current_node_child_count: 0, 225 + utf16_checkpoints: vec![(0, 0)], 220 226 paragraph_ranges: Vec::new(), 221 227 current_paragraph_start: None, 222 228 list_depth: 0, ··· 232 238 /// Used for fast boundary discovery in incremental rendering. 233 239 pub fn new_boundary_only( 234 240 source: &'a str, 235 - source_rope: &'a JumpRopeBuf, 241 + source_text: &'a LoroText, 236 242 events: I, 237 243 writer: W, 238 244 ) -> Self { 239 245 Self { 240 246 source, 241 - source_rope, 247 + source_text, 242 248 events, 243 249 writer, 244 250 last_byte_offset: 0, ··· 261 267 current_node_id: None, 262 268 current_node_char_offset: 0, 263 269 current_node_child_count: 0, 270 + utf16_checkpoints: vec![(0, 0)], 264 271 syntax_spans: Vec::new(), 265 272 next_syn_id: 0, 266 273 pending_inline_formats: Vec::new(), ··· 276 283 pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { 277 284 EditorWriter { 278 285 source: self.source, 279 - source_rope: self.source_rope, 286 + source_text: self.source_text, 280 287 events: self.events, 281 288 writer: self.writer, 282 289 last_byte_offset: self.last_byte_offset, ··· 299 306 current_node_id: self.current_node_id, 300 307 current_node_char_offset: self.current_node_char_offset, 301 308 current_node_child_count: self.current_node_child_count, 309 + utf16_checkpoints: self.utf16_checkpoints, 302 310 paragraph_ranges: self.paragraph_ranges, 303 311 current_paragraph_start: self.current_paragraph_start, 304 312 list_depth: self.list_depth, ··· 343 351 let format_end = self.last_char_offset; 344 352 let formatted_range = format_start..format_end; 345 353 354 + tracing::debug!( 355 + "[FINALIZE_PAIRED] Setting formatted_range {:?} for opening '{}' and closing (last span)", 356 + formatted_range, 357 + opening_syn_id 358 + ); 359 + 346 360 // Update the opening span's formatted_range 347 361 if let Some(opening_span) = self 348 362 .syntax_spans ··· 350 364 .find(|s| s.syn_id == opening_syn_id) 351 365 { 352 366 opening_span.formatted_range = Some(formatted_range.clone()); 367 + tracing::debug!("[FINALIZE_PAIRED] Updated opening span {}", opening_syn_id); 368 + } else { 369 + tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id); 353 370 } 354 371 355 372 // Update the closing span's formatted_range (the most recent one) ··· 358 375 // Only update if it's an inline span (closing syntax should be inline) 359 376 if closing_span.syntax_type == SyntaxType::Inline { 360 377 closing_span.formatted_range = Some(formatted_range); 378 + tracing::debug!("[FINALIZE_PAIRED] Updated closing span {}", closing_span.syn_id); 361 379 } 362 380 } 363 381 } ··· 544 562 self.current_node_child_count = 0; 545 563 } 546 564 565 + /// Compute UTF-16 length for a text slice with fast path for ASCII. 566 + #[inline] 567 + fn utf16_len_for_slice(text: &str) -> usize { 568 + let byte_len = text.len(); 569 + let char_len = text.chars().count(); 570 + 571 + // Fast path: if byte_len == char_len, all ASCII, so utf16_len == char_len 572 + if byte_len == char_len { 573 + char_len 574 + } else { 575 + // Slow path: has multi-byte chars, need to count UTF-16 code units 576 + text.encode_utf16().count() 577 + } 578 + } 579 + 547 580 /// Record an offset mapping for the given byte and char ranges. 548 581 /// 549 - /// Computes UTF-16 length efficiently using the rope's internal indexing. 582 + /// Builds up utf16_checkpoints incrementally for efficient lookups. 550 583 fn record_mapping(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { 551 584 if let Some(ref node_id) = self.current_node_id { 552 - // Use rope to convert char offsets to UTF-16 (wchar) offsets - O(log n) 553 - let rope = self.source_rope.borrow(); 554 - let wchar_start = rope.chars_to_wchars(char_range.start); 555 - let wchar_end = rope.chars_to_wchars(char_range.end); 556 - let utf16_len = wchar_end - wchar_start; 585 + // Get UTF-16 length using fast path 586 + let text_slice = &self.source[byte_range.clone()]; 587 + let utf16_len = Self::utf16_len_for_slice(text_slice); 588 + 589 + // Record checkpoint at end of this range for future lookups 590 + let last_checkpoint = self.utf16_checkpoints.last().copied().unwrap_or((0, 0)); 591 + let new_utf16_offset = last_checkpoint.1 + utf16_len; 592 + 593 + // Only add checkpoint if we've advanced 594 + if char_range.end > last_checkpoint.0 { 595 + self.utf16_checkpoints.push((char_range.end, new_utf16_offset)); 596 + } 557 597 558 598 let mapping = OffsetMapping { 559 599 byte_range: byte_range.clone(), ··· 601 641 602 642 // For End events, emit any trailing content within the event's range 603 643 // BEFORE calling end_tag (which calls end_node and clears current_node_id) 604 - if matches!(&event, Event::End(_)) { 644 + // 645 + // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), 646 + // the closing syntax must be emitted AFTER the closing HTML tag, not before. 647 + // Otherwise the closing `**` span ends up INSIDE the <strong> element. 648 + // These tags handle their own closing syntax in end_tag(). 649 + use markdown_weaver::TagEnd; 650 + let is_inline_format_end = matches!( 651 + &event, 652 + Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) 653 + ); 654 + 655 + if matches!(&event, Event::End(_)) && !is_inline_format_end { 605 656 // Emit gap from last_byte_offset to range.end 606 657 // (emit_syntax handles char offset tracking) 607 658 self.emit_gap_before(range.end)?; 608 - } else { 659 + } else if !matches!(&event, Event::End(_)) { 609 660 // For other events, emit any gap before range.start 610 661 // (emit_syntax handles char offset tracking) 611 662 self.emit_gap_before(range.start)?; 612 663 } 664 + // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML 613 665 614 666 // Store last_byte before processing 615 667 let last_byte_before = self.last_byte_offset; ··· 632 684 // Handle unmapped trailing content (stripped by parser) 633 685 // This includes trailing spaces that markdown ignores 634 686 let doc_byte_len = self.source.len(); 635 - let doc_char_len = self.source_rope.len_chars(); 687 + let doc_char_len = self.source_text.len_unicode(); 636 688 637 689 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 638 690 // Emit the trailing content as visible syntax ··· 1173 1225 syntax_type, 1174 1226 formatted_range: None, // Will be updated when closing tag is emitted 1175 1227 }); 1228 + 1229 + // Record offset mapping for cursor positioning 1230 + // This is critical - without it, current_node_char_offset is wrong 1231 + // and all subsequent cursor positions are shifted 1232 + let byte_start = range.start; 1233 + let byte_end = range.start + syntax_byte_len; 1234 + self.record_mapping(byte_start..byte_end, char_start..char_end); 1176 1235 1177 1236 // For paired inline syntax (Strong, Emphasis, Strikethrough), 1178 1237 // track the opening span so we can set formatted_range when closing ··· 1990 2049 self.write("</dd>\n") 1991 2050 } 1992 2051 TagEnd::Emphasis => { 2052 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 2053 + self.write("</em>")?; 2054 + self.emit_gap_before(range.end)?; 1993 2055 self.finalize_paired_inline_format(); 1994 - self.write("</em>") 2056 + Ok(()) 1995 2057 } 1996 2058 TagEnd::Superscript => self.write("</sup>"), 1997 2059 TagEnd::Subscript => self.write("</sub>"), 1998 2060 TagEnd::Strong => { 2061 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 2062 + self.write("</strong>")?; 2063 + self.emit_gap_before(range.end)?; 1999 2064 self.finalize_paired_inline_format(); 2000 - self.write("</strong>") 2065 + Ok(()) 2001 2066 } 2002 2067 TagEnd::Strikethrough => { 2068 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 2069 + self.write("</s>")?; 2070 + self.emit_gap_before(range.end)?; 2003 2071 self.finalize_paired_inline_format(); 2004 - self.write("</s>") 2072 + Ok(()) 2005 2073 } 2006 2074 TagEnd::Link => self.write("</a>"), 2007 2075 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event ··· 2019 2087 2020 2088 result?; 2021 2089 2022 - // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled 2023 - // by emit_gap_before(range.end) which is called before end_tag() in the main loop. 2024 - // No need for manual emission here anymore. 2090 + // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) 2091 + // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. 2092 + // This ensures the closing syntax span appears OUTSIDE the formatted element. 2093 + // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. 2025 2094 2026 2095 Ok(()) 2027 2096 }