bunch of progress, debugging some gap cursor snapping issues

Orual 801eaa03 df2ba14f

+2927 -86
+2
Cargo.lock
··· 9443 9443 "gloo-timers", 9444 9444 "http", 9445 9445 "humansize", 9446 + "insta", 9446 9447 "jacquard", 9447 9448 "jacquard-axum", 9448 9449 "jacquard-identity", ··· 9455 9456 "mime-sniffer", 9456 9457 "mini-moka 0.11.0", 9457 9458 "n0-future", 9459 + "regex", 9458 9460 "reqwest", 9459 9461 "serde", 9460 9462 "serde_html_form",
+6 -1
crates/weaver-app/Cargo.toml
··· 35 35 axum = {version = "0.8.6", optional = true} 36 36 mime-sniffer = {version = "^0.1"} 37 37 chrono = { version = "0.4" } 38 - serde = { version = "1.0"} #, features = ["derive"] } 38 + serde = { version = "1.0" } 39 39 serde_json = "1.0" 40 40 humansize = "2.0.0" 41 41 base64 = "0.22" ··· 73 73 74 74 [build-dependencies] 75 75 dotenvy = "0.15.7" 76 + 77 + [dev-dependencies] 78 + insta = { version = "1.40", features = ["yaml"] } 79 + serde = { version = "1.0", features = ["derive"] } 80 + regex = "1"
+92
crates/weaver-app/src/components/editor/document.rs
··· 19 19 20 20 /// IME composition state (for Phase 3) 21 21 pub composition: Option<CompositionState>, 22 + 23 + /// Most recent edit info for incremental rendering optimization. 24 + /// Used to determine if we can skip full re-parsing. 25 + pub last_edit: Option<EditInfo>, 22 26 } 23 27 24 28 /// Cursor state including position and affinity. ··· 54 58 pub text: String, 55 59 } 56 60 61 + /// Information about the most recent edit, used for incremental rendering optimization. 62 + #[derive(Clone, Debug, Default)] 63 + pub struct EditInfo { 64 + /// Character offset where the edit occurred 65 + pub edit_char_pos: usize, 66 + /// Number of characters inserted 67 + pub inserted_len: usize, 68 + /// Number of characters deleted 69 + pub deleted_len: usize, 70 + /// Whether the edit contains a newline (boundary-affecting) 71 + pub contains_newline: bool, 72 + /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). 73 + /// Edits here could affect block-level syntax like headings, lists, code fences. 74 + pub in_block_syntax_zone: bool, 75 + } 76 + 77 + /// Max distance from line start where block syntax can appear. 78 + /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) 79 + const BLOCK_SYNTAX_ZONE: usize = 6; 80 + 57 81 impl EditorDocument { 82 + /// Check if a character position is within the block-syntax zone of its line. 83 + fn is_in_block_syntax_zone(&self, pos: usize) -> bool { 84 + if pos == 0 { 85 + return true; 86 + } 87 + 88 + // Find distance from previous newline by scanning forward and tracking last newline 89 + let rope = self.rope.borrow(); 90 + let mut last_newline_pos: Option<usize> = None; 91 + 92 + for (i, c) in rope.slice_chars(0..pos).enumerate() { 93 + if c == '\n' { 94 + last_newline_pos = Some(i); 95 + } 96 + } 97 + 98 + 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 101 + }; 102 + 103 + chars_from_line_start <= BLOCK_SYNTAX_ZONE 104 + } 105 + 58 106 /// Create a new editor document with the given content. 59 107 pub fn new(content: String) -> Self { 60 108 Self { ··· 65 113 }, 66 114 selection: None, 67 115 composition: None, 116 + last_edit: None, 68 117 } 69 118 } 70 119 ··· 81 130 /// Check if the document is empty. 82 131 pub fn is_empty(&self) -> bool { 83 132 self.rope.len_chars() == 0 133 + } 134 + 135 + /// Insert text and record edit info for incremental rendering. 136 + pub fn insert_tracked(&mut self, pos: usize, text: &str) { 137 + let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 138 + self.last_edit = Some(EditInfo { 139 + edit_char_pos: pos, 140 + inserted_len: text.chars().count(), 141 + deleted_len: 0, 142 + contains_newline: text.contains('\n'), 143 + in_block_syntax_zone, 144 + }); 145 + self.rope.insert(pos, text); 146 + } 147 + 148 + /// 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); 153 + self.last_edit = Some(EditInfo { 154 + edit_char_pos: range.start, 155 + inserted_len: 0, 156 + deleted_len: range.end - range.start, 157 + contains_newline, 158 + in_block_syntax_zone, 159 + }); 160 + self.rope.remove(range); 161 + } 162 + 163 + /// 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); 167 + self.last_edit = Some(EditInfo { 168 + edit_char_pos: range.start, 169 + inserted_len: text.chars().count(), 170 + deleted_len: range.end - range.start, 171 + contains_newline: delete_has_newline || text.contains('\n'), 172 + in_block_syntax_zone, 173 + }); 174 + self.rope.remove(range); 175 + self.rope.insert(self.last_edit.as_ref().unwrap().edit_char_pos, text); 84 176 } 85 177 }
+23 -3
crates/weaver-app/src/components/editor/mod.rs
··· 15 15 mod toolbar; 16 16 mod writer; 17 17 18 + #[cfg(test)] 19 + mod tests; 20 + 18 21 pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; 19 22 pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 20 23 pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 21 24 pub use paragraph::ParagraphRender; 22 - pub use render::{render_markdown_simple, render_paragraphs}; 25 + pub use render::{RenderCache, render_paragraphs, render_paragraphs_incremental}; 23 26 pub use rope_writer::RopeWriter; 24 27 pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; 25 28 pub use toolbar::EditorToolbar; ··· 60 63 let mut document = use_signal(|| EditorDocument::new(restored())); 61 64 let editor_id = "markdown-editor"; 62 65 63 - // Render paragraphs for incremental updates 64 - let paragraphs = use_memo(move || render::render_paragraphs(&document().rope)); 66 + // Cache for incremental paragraph rendering 67 + let mut render_cache = use_signal(|| render::RenderCache::default()); 68 + 69 + // Render paragraphs with incremental caching 70 + let paragraphs = use_memo(move || { 71 + let doc = document(); 72 + let cache = render_cache.peek(); 73 + let edit = doc.last_edit.as_ref(); 74 + 75 + let (paras, new_cache) = 76 + render::render_paragraphs_incremental(&doc.rope, Some(&cache), edit); 77 + 78 + // Update cache for next render (write-only via spawn to avoid reactive loop) 79 + dioxus::prelude::spawn(async move { 80 + render_cache.set(new_cache); 81 + }); 82 + 83 + paras 84 + }); 65 85 66 86 // Flatten offset maps from all paragraphs 67 87 let offset_map = use_memo(move || {
+6 -6
crates/weaver-app/src/components/editor/offset_map.rs
··· 119 119 char_offset: usize, 120 120 ) -> Option<(&OffsetMapping, bool)> { 121 121 // Binary search for the mapping 122 - // Note: We allow cursor at the end boundary of a mapping (cursor after text) 123 - // This makes ranges END-INCLUSIVE for cursor positioning 122 + // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. 123 + // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), 124 + // prefer the NEXT mapping so cursor goes "down" to new content. 124 125 let idx = offset_map 125 126 .binary_search_by(|mapping| { 126 - if mapping.char_range.end < char_offset { 127 - // Cursor is after this mapping 127 + if mapping.char_range.end <= char_offset { 128 + // Cursor is at or after end of this mapping - look forward 128 129 std::cmp::Ordering::Less 129 130 } else if mapping.char_range.start > char_offset { 130 131 // Cursor is before this mapping 131 132 std::cmp::Ordering::Greater 132 133 } else { 133 - // Cursor is within [start, end] OR exactly at end (inclusive) 134 - // This handles cursor at position N matching range N-1..N 134 + // Cursor is within [start, end) 135 135 std::cmp::Ordering::Equal 136 136 } 137 137 })
+368 -46
crates/weaver-app/src/components/editor/render.rs
··· 4 4 //! 5 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 6 7 + use super::document::EditInfo; 7 8 use super::offset_map::{OffsetMapping, RenderResult}; 8 9 use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; 9 10 use super::writer::EditorWriter; 10 11 use jumprope::JumpRopeBuf; 11 12 use markdown_weaver::Parser; 13 + use std::ops::Range; 12 14 13 - /// Render markdown to HTML with visible formatting characters and offset mappings. 14 - /// 15 - /// This function performs a full re-render of the document on every change. 16 - /// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility. 17 - /// 18 - /// Uses EditorWriter which processes offset_iter events to detect consumed 19 - /// formatting characters and emit them as `<span class="md-syntax-*">` elements. 20 - /// 21 - /// Returns both the rendered HTML and offset mappings for cursor restoration. 22 - /// 23 - /// # Phase 2 features 24 - /// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block) 25 - /// - Offset map generation for cursor restoration 26 - /// - Full document re-render (fast enough for current needs) 27 - /// 28 - /// # Deprecated: Use `render_paragraphs()` for incremental rendering 29 - pub fn render_markdown_simple(source: &str) -> RenderResult { 30 - let source_rope = JumpRopeBuf::from(source); 31 - let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter(); 32 - let mut output = String::new(); 15 + /// Cache for incremental paragraph rendering. 16 + /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. 17 + #[derive(Clone, Debug, Default)] 18 + pub struct RenderCache { 19 + /// Cached paragraph renders (content paragraphs only, gaps computed fresh) 20 + pub paragraphs: Vec<CachedParagraph>, 21 + /// Next available node ID for fresh renders 22 + pub next_node_id: usize, 23 + } 33 24 34 - match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() { 35 - Ok(result) => RenderResult { 36 - html: output, 37 - offset_map: result.offset_maps, 38 - }, 39 - Err(_) => { 40 - // Fallback to empty result on error 41 - RenderResult { 42 - html: String::new(), 43 - offset_map: Vec::new(), 44 - } 45 - } 46 - } 25 + /// A cached paragraph render that can be reused if source hasn't changed. 26 + #[derive(Clone, Debug)] 27 + pub struct CachedParagraph { 28 + /// Hash of paragraph source text for change detection 29 + pub source_hash: u64, 30 + /// Byte range in source document 31 + pub byte_range: Range<usize>, 32 + /// Char range in source document 33 + pub char_range: Range<usize>, 34 + /// Rendered HTML 35 + pub html: String, 36 + /// Offset mappings for cursor positioning 37 + pub offset_map: Vec<OffsetMapping>, 47 38 } 48 39 49 40 /// Render markdown in paragraph chunks for incremental DOM updates. ··· 98 89 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 99 90 let mut node_id_offset = 0; // Track total nodes used so far for unique IDs 100 91 101 - for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 92 + for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 102 93 // Extract paragraph source 103 94 let para_source = rope_slice_to_string(rope, char_range.clone()); 104 95 let source_hash = hash_source(&para_source); ··· 159 150 }); 160 151 } 161 152 162 - // Insert gap paragraphs for whitespace between blocks 163 - // This gives the cursor somewhere to land when positioned in newlines 153 + // Insert gap paragraphs for EXTRA whitespace between blocks. 154 + // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. 155 + // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. 156 + // Gap IDs are position-based for stability across renders. 157 + const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n 158 + 164 159 let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); 165 160 let mut prev_end_char = 0usize; 166 161 let mut prev_end_byte = 0usize; 167 162 168 163 for para in paragraphs { 169 - // Check for gap before this paragraph 170 - if para.char_range.start > prev_end_char { 171 - let gap_start_char = prev_end_char; 164 + // Check for gap before this paragraph - only if MORE than minimum break 165 + let gap_size = para.char_range.start.saturating_sub(prev_end_char); 166 + if gap_size > MIN_PARAGRAPH_BREAK { 167 + // Gap covers the EXTRA whitespace beyond the minimum break 168 + let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK; 172 169 let gap_end_char = para.char_range.start; 173 - let gap_start_byte = prev_end_byte; 170 + let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK; 174 171 let gap_end_byte = para.byte_range.start; 175 172 176 - let gap_node_id = format!("n{}", node_id_offset); 177 - node_id_offset += 1; 173 + // Position-based ID: deterministic, stable across cache states 174 + let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); 178 175 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 179 176 180 177 paragraphs_with_gaps.push(ParagraphRender { ··· 209 206 210 207 // Only add if there's actually a gap at the end 211 208 if doc_end_char > prev_end_char { 212 - let empty_node_id = format!("n{}", node_id_offset); 213 - let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 209 + // Position-based ID for trailing gap 210 + let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); 211 + let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}'); 214 212 215 213 paragraphs_with_gaps.push(ParagraphRender { 216 214 byte_range: prev_end_byte..doc_end_byte, 217 215 char_range: prev_end_char..doc_end_char, 218 - html: empty_html, 216 + html: trailing_html, 219 217 offset_map: vec![OffsetMapping { 220 218 byte_range: prev_end_byte..doc_end_byte, 221 219 char_range: prev_end_char..doc_end_char, 222 - node_id: empty_node_id, 220 + node_id: trailing_node_id, 223 221 char_offset_in_node: 0, 224 222 child_index: None, 225 223 utf16_len: 1, // zero-width space is 1 UTF-16 code unit ··· 231 229 232 230 paragraphs_with_gaps 233 231 } 232 + 233 + /// Check if an edit affects paragraph boundaries. 234 + /// 235 + /// Edits that don't contain newlines and aren't in the block-syntax zone 236 + /// are considered "safe" and can skip boundary rediscovery. 237 + fn is_boundary_affecting(edit: &EditInfo) -> bool { 238 + // Newlines always affect boundaries (paragraph splits/joins) 239 + if edit.contains_newline { 240 + return true; 241 + } 242 + 243 + // Edits in the block-syntax zone (first ~6 chars of line) could affect 244 + // headings, lists, blockquotes, code fences, etc. 245 + if edit.in_block_syntax_zone { 246 + return true; 247 + } 248 + 249 + false 250 + } 251 + 252 + /// Adjust a cached paragraph's positions after an earlier edit. 253 + fn adjust_paragraph_positions( 254 + cached: &CachedParagraph, 255 + char_delta: isize, 256 + byte_delta: isize, 257 + ) -> ParagraphRender { 258 + let mut adjusted_map = cached.offset_map.clone(); 259 + for mapping in &mut adjusted_map { 260 + mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize; 261 + mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; 262 + mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize; 263 + mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; 264 + } 265 + 266 + ParagraphRender { 267 + byte_range: (cached.byte_range.start as isize + byte_delta) as usize 268 + ..(cached.byte_range.end as isize + byte_delta) as usize, 269 + char_range: (cached.char_range.start as isize + char_delta) as usize 270 + ..(cached.char_range.end as isize + char_delta) as usize, 271 + html: cached.html.clone(), 272 + offset_map: adjusted_map, 273 + source_hash: cached.source_hash, 274 + } 275 + } 276 + 277 + /// Render markdown with incremental caching. 278 + /// 279 + /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 280 + /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 281 + /// 282 + /// # Arguments 283 + /// - `rope`: The document rope to render 284 + /// - `cache`: Previous render cache (if any) 285 + /// - `edit`: Information about the most recent edit (if any) 286 + /// 287 + /// # Returns 288 + /// Tuple of (rendered paragraphs, updated cache) 289 + pub fn render_paragraphs_incremental( 290 + rope: &JumpRopeBuf, 291 + cache: Option<&RenderCache>, 292 + edit: Option<&EditInfo>, 293 + ) -> (Vec<ParagraphRender>, RenderCache) { 294 + let source = rope.to_string(); 295 + 296 + // Handle empty document 297 + if source.is_empty() { 298 + let empty_node_id = "n0".to_string(); 299 + let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 300 + 301 + let para = ParagraphRender { 302 + byte_range: 0..0, 303 + char_range: 0..0, 304 + html: empty_html.clone(), 305 + offset_map: vec![], 306 + source_hash: 0, 307 + }; 308 + 309 + let new_cache = RenderCache { 310 + paragraphs: vec![CachedParagraph { 311 + source_hash: 0, 312 + byte_range: 0..0, 313 + char_range: 0..0, 314 + html: empty_html, 315 + offset_map: vec![], 316 + }], 317 + next_node_id: 1, 318 + }; 319 + 320 + return (vec![para], new_cache); 321 + } 322 + 323 + // Determine if we can use fast path (skip boundary discovery) 324 + let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); 325 + 326 + // Get paragraph boundaries 327 + let paragraph_ranges = if use_fast_path { 328 + // Fast path: adjust cached boundaries based on edit 329 + let cache = cache.unwrap(); 330 + let edit = edit.unwrap(); 331 + 332 + // Find which paragraph the edit falls into 333 + let edit_pos = edit.edit_char_pos; 334 + let char_delta = edit.inserted_len as isize - edit.deleted_len as isize; 335 + 336 + // Adjust each cached paragraph's range 337 + cache 338 + .paragraphs 339 + .iter() 340 + .map(|p| { 341 + if p.char_range.end <= edit_pos { 342 + // Before edit - no change 343 + (p.byte_range.clone(), p.char_range.clone()) 344 + } else if p.char_range.start >= edit_pos { 345 + // After edit - shift by delta 346 + // Calculate byte delta (approximation: assume 1 byte per char for ASCII) 347 + // This is imprecise but boundaries are rediscovered on slow path anyway 348 + let byte_delta = char_delta; // TODO: proper byte calculation 349 + ( 350 + (p.byte_range.start as isize + byte_delta) as usize 351 + ..(p.byte_range.end as isize + byte_delta) as usize, 352 + (p.char_range.start as isize + char_delta) as usize 353 + ..(p.char_range.end as isize + char_delta) as usize, 354 + ) 355 + } else { 356 + // Edit is within this paragraph - expand its end 357 + ( 358 + p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize, 359 + p.char_range.start..(p.char_range.end as isize + char_delta) as usize, 360 + ) 361 + } 362 + }) 363 + .collect::<Vec<_>>() 364 + } else { 365 + // Slow path: run boundary-only pass to discover paragraph boundaries 366 + let parser = 367 + Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); 368 + let mut scratch_output = String::new(); 369 + 370 + match EditorWriter::<_, _, ()>::new_boundary_only( 371 + &source, 372 + rope, 373 + parser, 374 + &mut scratch_output, 375 + ) 376 + .run() 377 + { 378 + Ok(result) => result.paragraph_ranges, 379 + Err(_) => return (Vec::new(), RenderCache::default()), 380 + } 381 + }; 382 + 383 + // Render paragraphs, reusing cache where possible 384 + let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 385 + let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 386 + let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); 387 + 388 + for (byte_range, char_range) in paragraph_ranges.iter() { 389 + let para_source = rope_slice_to_string(rope, char_range.clone()); 390 + let source_hash = hash_source(&para_source); 391 + 392 + // Check if we have a cached render with matching hash 393 + let cached_match = 394 + cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); 395 + 396 + let (html, offset_map) = if let Some(cached) = cached_match { 397 + // Reuse cached HTML and offset map (adjusted for position) 398 + let char_delta = char_range.start as isize - cached.char_range.start as isize; 399 + let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; 400 + 401 + let mut adjusted_map = cached.offset_map.clone(); 402 + for mapping in &mut adjusted_map { 403 + mapping.char_range.start = 404 + (mapping.char_range.start as isize + char_delta) as usize; 405 + mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; 406 + mapping.byte_range.start = 407 + (mapping.byte_range.start as isize + byte_delta) as usize; 408 + mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; 409 + } 410 + 411 + (cached.html.clone(), adjusted_map) 412 + } else { 413 + // Fresh render needed 414 + let para_rope = JumpRopeBuf::from(para_source.as_str()); 415 + let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 416 + .into_offset_iter(); 417 + let mut output = String::new(); 418 + 419 + let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( 420 + &para_source, 421 + &para_rope, 422 + parser, 423 + &mut output, 424 + node_id_offset, 425 + ) 426 + .run() 427 + { 428 + Ok(result) => { 429 + // Update node ID offset 430 + let max_node_id = result 431 + .offset_maps 432 + .iter() 433 + .filter_map(|m| { 434 + m.node_id 435 + .strip_prefix("n") 436 + .and_then(|s| s.parse::<usize>().ok()) 437 + }) 438 + .max() 439 + .unwrap_or(node_id_offset); 440 + node_id_offset = max_node_id + 1; 441 + result.offset_maps 442 + } 443 + Err(_) => Vec::new(), 444 + }; 445 + 446 + // Adjust offsets to document coordinates 447 + let para_char_start = char_range.start; 448 + let para_byte_start = byte_range.start; 449 + for mapping in &mut offset_map { 450 + mapping.byte_range.start += para_byte_start; 451 + mapping.byte_range.end += para_byte_start; 452 + mapping.char_range.start += para_char_start; 453 + mapping.char_range.end += para_char_start; 454 + } 455 + 456 + (output, offset_map) 457 + }; 458 + 459 + // Store in cache 460 + new_cached.push(CachedParagraph { 461 + source_hash, 462 + byte_range: byte_range.clone(), 463 + char_range: char_range.clone(), 464 + html: html.clone(), 465 + offset_map: offset_map.clone(), 466 + }); 467 + 468 + paragraphs.push(ParagraphRender { 469 + byte_range: byte_range.clone(), 470 + char_range: char_range.clone(), 471 + html, 472 + offset_map, 473 + source_hash, 474 + }); 475 + } 476 + 477 + // Insert gap paragraphs for EXTRA whitespace between blocks. 478 + // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. 479 + // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. 480 + const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n 481 + 482 + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); 483 + let mut prev_end_char = 0usize; 484 + let mut prev_end_byte = 0usize; 485 + 486 + for para in paragraphs { 487 + // Check for gap before this paragraph - only if MORE than minimum break 488 + let gap_size = para.char_range.start.saturating_sub(prev_end_char); 489 + if gap_size > MIN_PARAGRAPH_BREAK_INCR { 490 + // Gap covers the EXTRA whitespace beyond the minimum break 491 + let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 492 + let gap_end_char = para.char_range.start; 493 + let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; 494 + let gap_end_byte = para.byte_range.start; 495 + 496 + // Position-based ID: deterministic, stable across cache states 497 + let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); 498 + let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 499 + 500 + paragraphs_with_gaps.push(ParagraphRender { 501 + byte_range: gap_start_byte..gap_end_byte, 502 + char_range: gap_start_char..gap_end_char, 503 + html: gap_html, 504 + offset_map: vec![OffsetMapping { 505 + byte_range: gap_start_byte..gap_end_byte, 506 + char_range: gap_start_char..gap_end_char, 507 + node_id: gap_node_id, 508 + char_offset_in_node: 0, 509 + child_index: None, 510 + utf16_len: 1, 511 + }], 512 + source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), 513 + }); 514 + } 515 + 516 + prev_end_char = para.char_range.end; 517 + prev_end_byte = para.byte_range.end; 518 + paragraphs_with_gaps.push(para); 519 + } 520 + 521 + // Add trailing gap if needed 522 + let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); 523 + if has_trailing_newlines { 524 + let doc_end_char = rope.len_chars(); 525 + let doc_end_byte = rope.len_bytes(); 526 + 527 + if doc_end_char > prev_end_char { 528 + // Position-based ID for trailing gap 529 + let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); 530 + let trailing_html = format!(r#"<span id="{}">{}</span>"#, trailing_node_id, '\u{200B}'); 531 + 532 + paragraphs_with_gaps.push(ParagraphRender { 533 + byte_range: prev_end_byte..doc_end_byte, 534 + char_range: prev_end_char..doc_end_char, 535 + html: trailing_html, 536 + offset_map: vec![OffsetMapping { 537 + byte_range: prev_end_byte..doc_end_byte, 538 + char_range: prev_end_char..doc_end_char, 539 + node_id: trailing_node_id, 540 + char_offset_in_node: 0, 541 + child_index: None, 542 + utf16_len: 1, 543 + }], 544 + source_hash: 0, 545 + }); 546 + } 547 + } 548 + 549 + let new_cache = RenderCache { 550 + paragraphs: new_cached, 551 + next_node_id: node_id_offset, 552 + }; 553 + 554 + (paragraphs_with_gaps, new_cache) 555 + }
+101
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 41 7 + - 18 8 + char_range: 9 + - 0 10 + - 18 11 + html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\">&gt; </span>This is a quote<span class=\"md-syntax-inline\">\n</span></p>\n</blockquote>\n" 12 + offset_map: 13 + - byte_range: 14 + - 43 15 + - 43 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 41 25 + - 43 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: 34 + - 43 35 + - 58 36 + char_range: 37 + - 2 38 + - 17 39 + node_id: n0 40 + char_offset_in_node: 2 41 + child_index: ~ 42 + utf16_len: 15 43 + - byte_range: 44 + - 58 45 + - 59 46 + char_range: 47 + - 17 48 + - 18 49 + node_id: n0 50 + char_offset_in_node: 17 51 + child_index: ~ 52 + utf16_len: 1 53 + source_hash: 11268153476336590706 54 + - byte_range: 55 + - 20 56 + - 22 57 + char_range: 58 + - 20 59 + - 22 60 + html: "<span id=\"gap-20-22\">​</span>" 61 + offset_map: 62 + - byte_range: 63 + - 20 64 + - 22 65 + char_range: 66 + - 20 67 + - 22 68 + node_id: gap-20-22 69 + char_offset_in_node: 0 70 + child_index: ~ 71 + utf16_len: 1 72 + source_hash: 6078396554664461735 73 + - byte_range: 74 + - 22 75 + - 41 76 + char_range: 77 + - 22 78 + - 41 79 + html: "<p id=\"n1\">With multiple lines</p>\n" 80 + offset_map: 81 + - byte_range: 82 + - 22 83 + - 22 84 + char_range: 85 + - 22 86 + - 22 87 + node_id: n1 88 + char_offset_in_node: 0 89 + child_index: 0 90 + utf16_len: 0 91 + - byte_range: 92 + - 22 93 + - 41 94 + char_range: 95 + - 22 96 + - 41 97 + node_id: n1 98 + char_offset_in_node: 0 99 + child_index: ~ 100 + utf16_len: 19 101 + source_hash: 765233770142737875
+63
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 18 8 + char_range: 9 + - 0 10 + - 18 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">**</span><strong>bold<span class=\"md-syntax-inline\">**</span></strong> text</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 33 + - byte_range: 34 + - 7 35 + - 11 36 + char_range: 37 + - 7 38 + - 11 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 4 43 + - byte_range: 44 + - 11 45 + - 13 46 + char_range: 47 + - 11 48 + - 13 49 + node_id: n0 50 + char_offset_in_node: 9 51 + child_index: ~ 52 + utf16_len: 2 53 + - byte_range: 54 + - 13 55 + - 18 56 + char_range: 57 + - 13 58 + - 18 59 + node_id: n0 60 + char_offset_in_node: 11 61 + child_index: ~ 62 + utf16_len: 5 63 + source_hash: 3007541947422346271
+73
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 27 8 + char_range: 9 + - 0 10 + - 27 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">*</span><em><span class=\"md-syntax-inline\">**</span><strong>bold italic<span class=\"md-syntax-inline\">**</span></strong><span class=\"md-syntax-inline\">*</span></em> text</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 33 + - byte_range: 34 + - 8 35 + - 19 36 + char_range: 37 + - 8 38 + - 19 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 11 43 + - byte_range: 44 + - 19 45 + - 21 46 + char_range: 47 + - 19 48 + - 21 49 + node_id: n0 50 + char_offset_in_node: 16 51 + child_index: ~ 52 + utf16_len: 2 53 + - byte_range: 54 + - 21 55 + - 22 56 + char_range: 57 + - 21 58 + - 22 59 + node_id: n0 60 + char_offset_in_node: 18 61 + child_index: ~ 62 + utf16_len: 1 63 + - byte_range: 64 + - 22 65 + - 27 66 + char_range: 67 + - 22 68 + - 27 69 + node_id: n0 70 + char_offset_in_node: 19 71 + child_index: ~ 72 + utf16_len: 5 73 + source_hash: 17839597501764990486
+23
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 24 8 + char_range: 9 + - 0 10 + - 24 11 + html: "<span class=\"md-syntax-block\">```rust</span>\n<pre><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\">```</span>" 12 + offset_map: 13 + - byte_range: 14 + - 8 15 + - 21 16 + char_range: 17 + - 8 18 + - 21 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: ~ 22 + utf16_len: 13 23 + source_hash: 13617303422540996675
+13
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__empty_document.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 0 8 + char_range: 9 + - 0 10 + - 0 11 + html: "<span id=\"n0\">​</span>" 12 + offset_map: [] 13 + source_hash: 0
+82
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 10 8 + char_range: 9 + - 0 10 + - 10 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>Heading<span class=\"md-syntax-inline\">\n</span></h1>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 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: 34 + - 2 35 + - 9 36 + char_range: 37 + - 2 38 + - 9 39 + node_id: n0 40 + char_offset_in_node: 2 41 + child_index: ~ 42 + utf16_len: 7 43 + - byte_range: 44 + - 9 45 + - 10 46 + char_range: 47 + - 9 48 + - 10 49 + node_id: n0 50 + char_offset_in_node: 9 51 + child_index: ~ 52 + utf16_len: 1 53 + source_hash: 9728620054493468379 54 + - byte_range: 55 + - 11 56 + - 26 57 + char_range: 58 + - 11 59 + - 26 60 + html: "<p id=\"n1\">Paragraph below</p>\n" 61 + offset_map: 62 + - byte_range: 63 + - 11 64 + - 11 65 + char_range: 66 + - 11 67 + - 11 68 + node_id: n1 69 + char_offset_in_node: 0 70 + child_index: 0 71 + utf16_len: 0 72 + - byte_range: 73 + - 11 74 + - 26 75 + char_range: 76 + - 11 77 + - 26 78 + node_id: n1 79 + char_offset_in_node: 0 80 + child_index: ~ 81 + utf16_len: 15 82 + source_hash: 3568739071302808795
+63
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 19 8 + char_range: 9 + - 0 10 + - 19 11 + html: "<p id=\"n0\">Line one<span class=\"md-syntax-inline\"> </span><br />​Line two</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 8 26 + char_range: 27 + - 0 28 + - 8 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 8 33 + - byte_range: 34 + - 8 35 + - 10 36 + char_range: 37 + - 8 38 + - 10 39 + node_id: n0 40 + char_offset_in_node: 8 41 + child_index: ~ 42 + utf16_len: 2 43 + - byte_range: 44 + - 10 45 + - 11 46 + char_range: 47 + - 10 48 + - 11 49 + node_id: n0 50 + char_offset_in_node: 10 51 + child_index: ~ 52 + utf16_len: 1 53 + - byte_range: 54 + - 11 55 + - 19 56 + char_range: 57 + - 11 58 + - 19 59 + node_id: n0 60 + char_offset_in_node: 11 61 + child_index: ~ 62 + utf16_len: 8 63 + source_hash: 11960038156086585782
+43
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 11 8 + char_range: 9 + - 0 10 + - 11 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>Heading 1</h1>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 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: 34 + - 2 35 + - 11 36 + char_range: 37 + - 2 38 + - 11 39 + node_id: n0 40 + char_offset_in_node: 2 41 + child_index: ~ 42 + utf16_len: 9 43 + source_hash: 17388718256970297689
+190
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 5 8 + char_range: 9 + - 0 10 + - 5 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\"># </span>H1<span class=\"md-syntax-inline\">\n</span></h1>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 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: 34 + - 2 35 + - 4 36 + char_range: 37 + - 2 38 + - 4 39 + node_id: n0 40 + char_offset_in_node: 2 41 + child_index: ~ 42 + utf16_len: 2 43 + - byte_range: 44 + - 4 45 + - 5 46 + char_range: 47 + - 4 48 + - 5 49 + node_id: n0 50 + char_offset_in_node: 4 51 + child_index: ~ 52 + utf16_len: 1 53 + source_hash: 7777810684660076739 54 + - byte_range: 55 + - 6 56 + - 12 57 + char_range: 58 + - 6 59 + - 12 60 + html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\">## </span>H2<span class=\"md-syntax-inline\">\n</span></h2>\n" 61 + offset_map: 62 + - byte_range: 63 + - 6 64 + - 6 65 + char_range: 66 + - 6 67 + - 6 68 + node_id: n1 69 + char_offset_in_node: 0 70 + child_index: 0 71 + utf16_len: 0 72 + - byte_range: 73 + - 6 74 + - 9 75 + char_range: 76 + - 6 77 + - 9 78 + node_id: n1 79 + char_offset_in_node: 0 80 + child_index: ~ 81 + utf16_len: 3 82 + - byte_range: 83 + - 9 84 + - 11 85 + char_range: 86 + - 9 87 + - 11 88 + node_id: n1 89 + char_offset_in_node: 3 90 + child_index: ~ 91 + utf16_len: 2 92 + - byte_range: 93 + - 11 94 + - 12 95 + char_range: 96 + - 11 97 + - 12 98 + node_id: n1 99 + char_offset_in_node: 5 100 + child_index: ~ 101 + utf16_len: 1 102 + source_hash: 252166429066241764 103 + - byte_range: 104 + - 13 105 + - 20 106 + char_range: 107 + - 13 108 + - 20 109 + html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\">### </span>H3<span class=\"md-syntax-inline\">\n</span></h3>\n" 110 + offset_map: 111 + - byte_range: 112 + - 13 113 + - 13 114 + char_range: 115 + - 13 116 + - 13 117 + node_id: n2 118 + char_offset_in_node: 0 119 + child_index: 0 120 + utf16_len: 0 121 + - byte_range: 122 + - 13 123 + - 17 124 + char_range: 125 + - 13 126 + - 17 127 + node_id: n2 128 + char_offset_in_node: 0 129 + child_index: ~ 130 + utf16_len: 4 131 + - byte_range: 132 + - 17 133 + - 19 134 + char_range: 135 + - 17 136 + - 19 137 + node_id: n2 138 + char_offset_in_node: 4 139 + child_index: ~ 140 + utf16_len: 2 141 + - byte_range: 142 + - 19 143 + - 20 144 + char_range: 145 + - 19 146 + - 20 147 + node_id: n2 148 + char_offset_in_node: 6 149 + child_index: ~ 150 + utf16_len: 1 151 + source_hash: 7264449752500241334 152 + - byte_range: 153 + - 21 154 + - 28 155 + char_range: 156 + - 21 157 + - 28 158 + html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\">#### </span>H4</h4>\n" 159 + offset_map: 160 + - byte_range: 161 + - 21 162 + - 21 163 + char_range: 164 + - 21 165 + - 21 166 + node_id: n3 167 + char_offset_in_node: 0 168 + child_index: 0 169 + utf16_len: 0 170 + - byte_range: 171 + - 21 172 + - 26 173 + char_range: 174 + - 21 175 + - 26 176 + node_id: n3 177 + char_offset_in_node: 0 178 + child_index: ~ 179 + utf16_len: 5 180 + - byte_range: 181 + - 26 182 + - 28 183 + char_range: 184 + - 26 185 + - 28 186 + node_id: n3 187 + char_offset_in_node: 5 188 + child_index: ~ 189 + utf16_len: 2 190 + source_hash: 2886251954581387532
+53
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 16 8 + char_range: 9 + - 0 10 + - 16 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">`</span><code>code</code><span class=\"md-syntax-inline\">`</span> here</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 33 + - byte_range: 34 + - 5 35 + - 11 36 + char_range: 37 + - 6 38 + - 10 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 4 43 + - byte_range: 44 + - 11 45 + - 16 46 + char_range: 47 + - 11 48 + - 16 49 + node_id: n0 50 + char_offset_in_node: 9 51 + child_index: ~ 52 + utf16_len: 5 53 + source_hash: 10489263388249723293
+63
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 18 8 + char_range: 9 + - 0 10 + - 18 11 + html: "<p id=\"n0\">Some <span class=\"md-syntax-inline\">*</span><em>italic<span class=\"md-syntax-inline\">*</span></em> text</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 33 + - byte_range: 34 + - 6 35 + - 12 36 + char_range: 37 + - 6 38 + - 12 39 + node_id: n0 40 + char_offset_in_node: 5 41 + child_index: ~ 42 + utf16_len: 6 43 + - byte_range: 44 + - 12 45 + - 13 46 + char_range: 47 + - 12 48 + - 13 49 + node_id: n0 50 + char_offset_in_node: 11 51 + child_index: ~ 52 + utf16_len: 1 53 + - byte_range: 54 + - 13 55 + - 18 56 + char_range: 57 + - 13 58 + - 18 59 + node_id: n0 60 + char_offset_in_node: 12 61 + child_index: ~ 62 + utf16_len: 5 63 + source_hash: 4363411941421262428
+33
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 23 8 + char_range: 9 + - 0 10 + - 16 11 + html: "<p id=\"n0\">Hello 你好 world 🎉</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 23 26 + char_range: 27 + - 0 28 + - 16 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 17 33 + source_hash: 3781751019085848984
+91
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 6 8 + char_range: 9 + - 0 10 + - 6 11 + html: "<p id=\"n0\">First<span class=\"md-syntax-inline\">\n</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 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 + source_hash: 6799294212516041738 44 + - byte_range: 45 + - 8 46 + - 9 47 + char_range: 48 + - 8 49 + - 9 50 + html: "<span id=\"gap-8-9\">​</span>" 51 + offset_map: 52 + - byte_range: 53 + - 8 54 + - 9 55 + char_range: 56 + - 8 57 + - 9 58 + node_id: gap-8-9 59 + char_offset_in_node: 0 60 + child_index: ~ 61 + utf16_len: 1 62 + source_hash: 4789919281594481934 63 + - byte_range: 64 + - 9 65 + - 15 66 + char_range: 67 + - 9 68 + - 15 69 + html: "<p id=\"n1\">Second</p>\n" 70 + offset_map: 71 + - byte_range: 72 + - 9 73 + - 9 74 + char_range: 75 + - 9 76 + - 9 77 + node_id: n1 78 + char_offset_in_node: 0 79 + child_index: 0 80 + utf16_len: 0 81 + - byte_range: 82 + - 9 83 + - 15 84 + char_range: 85 + - 9 86 + - 15 87 + node_id: n1 88 + char_offset_in_node: 0 89 + child_index: ~ 90 + utf16_len: 6 91 + source_hash: 14114649427451643080
+93
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 32 8 + char_range: 9 + - 0 10 + - 32 11 + html: "<p id=\"n0\"><span class=\"md-syntax-inline\">**</span><strong>Bold<span class=\"md-syntax-inline\">**</span></strong> and <span class=\"md-syntax-inline\">*</span><em>italic<span class=\"md-syntax-inline\">*</span></em> and <span class=\"md-syntax-inline\">`</span><code>code</code><span class=\"md-syntax-inline\">`</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 2 25 + - 6 26 + char_range: 27 + - 2 28 + - 6 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 4 33 + - byte_range: 34 + - 6 35 + - 8 36 + char_range: 37 + - 6 38 + - 8 39 + node_id: n0 40 + char_offset_in_node: 4 41 + child_index: ~ 42 + utf16_len: 2 43 + - byte_range: 44 + - 8 45 + - 13 46 + char_range: 47 + - 8 48 + - 13 49 + node_id: n0 50 + char_offset_in_node: 6 51 + child_index: ~ 52 + utf16_len: 5 53 + - byte_range: 54 + - 14 55 + - 20 56 + char_range: 57 + - 14 58 + - 20 59 + node_id: n0 60 + char_offset_in_node: 11 61 + child_index: ~ 62 + utf16_len: 6 63 + - byte_range: 64 + - 20 65 + - 21 66 + char_range: 67 + - 20 68 + - 21 69 + node_id: n0 70 + char_offset_in_node: 17 71 + child_index: ~ 72 + utf16_len: 1 73 + - byte_range: 74 + - 21 75 + - 26 76 + char_range: 77 + - 21 78 + - 26 79 + node_id: n0 80 + char_offset_in_node: 18 81 + child_index: ~ 82 + utf16_len: 5 83 + - byte_range: 84 + - 26 85 + - 32 86 + char_range: 87 + - 27 88 + - 31 89 + node_id: n0 90 + char_offset_in_node: 23 91 + child_index: ~ 92 + utf16_len: 4 93 + source_hash: 17988102203032347642
+92
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 2 7 + - 11 8 + char_range: 9 + - 2 10 + - 11 11 + html: "<span id=\"gap-2-11\">​</span>" 12 + offset_map: 13 + - byte_range: 14 + - 2 15 + - 11 16 + char_range: 17 + - 2 18 + - 11 19 + node_id: gap-2-11 20 + char_offset_in_node: 0 21 + child_index: ~ 22 + utf16_len: 1 23 + source_hash: 10732713550789592057 24 + - byte_range: 25 + - 11 26 + - 33 27 + char_range: 28 + - 11 29 + - 33 30 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">- </span>Child 1<span class=\"md-syntax-inline\">\n </span>\n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">- </span>Child 2<span class=\"md-syntax-inline\">\n</span></li>\n</ul>\n</li>\n</ul>\n" 31 + offset_map: 32 + - byte_range: 33 + - 11 34 + - 13 35 + char_range: 36 + - 11 37 + - 13 38 + node_id: n0 39 + char_offset_in_node: 0 40 + child_index: ~ 41 + utf16_len: 2 42 + - byte_range: 43 + - 13 44 + - 20 45 + char_range: 46 + - 13 47 + - 20 48 + node_id: n0 49 + char_offset_in_node: 2 50 + child_index: ~ 51 + utf16_len: 7 52 + - byte_range: 53 + - 20 54 + - 23 55 + char_range: 56 + - 20 57 + - 23 58 + node_id: n0 59 + char_offset_in_node: 9 60 + child_index: ~ 61 + utf16_len: 3 62 + - byte_range: 63 + - 23 64 + - 25 65 + char_range: 66 + - 23 67 + - 25 68 + node_id: n1 69 + char_offset_in_node: 0 70 + child_index: ~ 71 + utf16_len: 2 72 + - byte_range: 73 + - 25 74 + - 32 75 + char_range: 76 + - 25 77 + - 32 78 + node_id: n1 79 + char_offset_in_node: 2 80 + child_index: ~ 81 + utf16_len: 7 82 + - byte_range: 83 + - 32 84 + - 33 85 + char_range: 86 + - 32 87 + - 33 88 + node_id: n1 89 + char_offset_in_node: 9 90 + child_index: ~ 91 + utf16_len: 1 92 + source_hash: 488032603397006983
+23
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 3 8 + char_range: 9 + - 0 10 + - 3 11 + html: "<span id=\"gap-0-3\">​</span>" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 3 16 + char_range: 17 + - 0 18 + - 3 19 + node_id: gap-0-3 20 + char_offset_in_node: 0 21 + child_index: ~ 22 + utf16_len: 1 23 + source_hash: 0
+123
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 27 8 + char_range: 9 + - 0 10 + - 27 11 + html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">1.</span><span class=\"md-syntax-inline\"> </span>First<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">2.</span><span class=\"md-syntax-inline\"> </span>Second<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\">3.</span><span class=\"md-syntax-inline\"> </span>Third</li>\n</ol>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 2 16 + char_range: 17 + - 0 18 + - 2 19 + node_id: n0 20 + char_offset_in_node: 0 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 33 + - byte_range: 34 + - 3 35 + - 8 36 + char_range: 37 + - 3 38 + - 8 39 + node_id: n0 40 + char_offset_in_node: 3 41 + child_index: ~ 42 + utf16_len: 5 43 + - byte_range: 44 + - 8 45 + - 9 46 + char_range: 47 + - 8 48 + - 9 49 + node_id: n0 50 + char_offset_in_node: 8 51 + child_index: ~ 52 + utf16_len: 1 53 + - byte_range: 54 + - 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 + - 12 66 + char_range: 67 + - 11 68 + - 12 69 + node_id: n1 70 + char_offset_in_node: 2 71 + child_index: ~ 72 + utf16_len: 1 73 + - byte_range: 74 + - 12 75 + - 18 76 + char_range: 77 + - 12 78 + - 18 79 + node_id: n1 80 + char_offset_in_node: 3 81 + child_index: ~ 82 + utf16_len: 6 83 + - byte_range: 84 + - 18 85 + - 19 86 + char_range: 87 + - 18 88 + - 19 89 + node_id: n1 90 + char_offset_in_node: 9 91 + child_index: ~ 92 + utf16_len: 1 93 + - byte_range: 94 + - 19 95 + - 21 96 + char_range: 97 + - 19 98 + - 21 99 + node_id: n2 100 + char_offset_in_node: 0 101 + 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 113 + - byte_range: 114 + - 22 115 + - 27 116 + char_range: 117 + - 22 118 + - 27 119 + node_id: n2 120 + char_offset_in_node: 3 121 + child_index: ~ 122 + utf16_len: 5 123 + source_hash: 5390174624313041457
+33
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 11 8 + char_range: 9 + - 0 10 + - 11 11 + html: "<p id=\"n0\">Hello world</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 11 26 + char_range: 27 + - 0 28 + - 11 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 11 33 + source_hash: 2216321107127430384
+111
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 5 8 + char_range: 9 + - 0 10 + - 5 11 + html: "<p id=\"n0\">One.<span class=\"md-syntax-inline\">\n</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 4 26 + char_range: 27 + - 0 28 + - 4 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 4 33 + - byte_range: 34 + - 4 35 + - 5 36 + char_range: 37 + - 4 38 + - 5 39 + node_id: n0 40 + char_offset_in_node: 4 41 + child_index: ~ 42 + utf16_len: 1 43 + source_hash: 9153687894432250931 44 + - byte_range: 45 + - 6 46 + - 11 47 + char_range: 48 + - 6 49 + - 11 50 + html: "<p id=\"n1\">Two.<span class=\"md-syntax-inline\">\n</span></p>\n" 51 + offset_map: 52 + - byte_range: 53 + - 6 54 + - 6 55 + char_range: 56 + - 6 57 + - 6 58 + node_id: n1 59 + char_offset_in_node: 0 60 + child_index: 0 61 + utf16_len: 0 62 + - byte_range: 63 + - 6 64 + - 10 65 + char_range: 66 + - 6 67 + - 10 68 + node_id: n1 69 + char_offset_in_node: 0 70 + child_index: ~ 71 + utf16_len: 4 72 + - byte_range: 73 + - 10 74 + - 11 75 + char_range: 76 + - 10 77 + - 11 78 + node_id: n1 79 + char_offset_in_node: 4 80 + child_index: ~ 81 + utf16_len: 1 82 + source_hash: 3900796536115303959 83 + - byte_range: 84 + - 12 85 + - 18 86 + char_range: 87 + - 12 88 + - 18 89 + html: "<p id=\"n2\">Three.</p>\n" 90 + offset_map: 91 + - byte_range: 92 + - 12 93 + - 12 94 + char_range: 95 + - 12 96 + - 12 97 + node_id: n2 98 + char_offset_in_node: 0 99 + child_index: 0 100 + utf16_len: 0 101 + - byte_range: 102 + - 12 103 + - 18 104 + char_range: 105 + - 12 106 + - 18 107 + node_id: n2 108 + char_offset_in_node: 0 109 + child_index: ~ 110 + utf16_len: 6 111 + source_hash: 11679754215709541712
+62
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 6 8 + char_range: 9 + - 0 10 + - 6 11 + html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\">\n</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 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 + source_hash: 1280328235052234789 44 + - byte_range: 45 + - 6 46 + - 7 47 + char_range: 48 + - 6 49 + - 7 50 + html: "<span id=\"gap-6-7\">​</span>" 51 + offset_map: 52 + - byte_range: 53 + - 6 54 + - 7 55 + char_range: 56 + - 6 57 + - 7 58 + node_id: gap-6-7 59 + char_offset_in_node: 0 60 + child_index: ~ 61 + utf16_len: 1 62 + source_hash: 0
+43
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 6 8 + char_range: 9 + - 0 10 + - 6 11 + html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\">\n</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 5 26 + char_range: 27 + - 0 28 + - 5 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 5 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 + source_hash: 1280328235052234789
+72
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 17 8 + char_range: 9 + - 0 10 + - 17 11 + html: "<p id=\"n0\">First paragraph.<span class=\"md-syntax-inline\">\n</span></p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 16 26 + char_range: 27 + - 0 28 + - 16 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 16 33 + - byte_range: 34 + - 16 35 + - 17 36 + char_range: 37 + - 16 38 + - 17 39 + node_id: n0 40 + char_offset_in_node: 16 41 + child_index: ~ 42 + utf16_len: 1 43 + source_hash: 15312977301334065815 44 + - byte_range: 45 + - 18 46 + - 35 47 + char_range: 48 + - 18 49 + - 35 50 + html: "<p id=\"n1\">Second paragraph.</p>\n" 51 + offset_map: 52 + - byte_range: 53 + - 18 54 + - 18 55 + char_range: 56 + - 18 57 + - 18 58 + node_id: n1 59 + char_offset_in_node: 0 60 + child_index: 0 61 + utf16_len: 0 62 + - byte_range: 63 + - 18 64 + - 35 65 + char_range: 66 + - 18 67 + - 35 68 + node_id: n1 69 + char_offset_in_node: 0 70 + child_index: ~ 71 + utf16_len: 17 72 + source_hash: 11636620376496732832
+33
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 12 8 + char_range: 9 + - 0 10 + - 4 11 + html: "<p id=\"n0\">你好世界</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 12 26 + char_range: 27 + - 0 28 + - 4 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 4 33 + source_hash: 18244118180434471326
+33
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 16 8 + char_range: 9 + - 0 10 + - 13 11 + html: "<p id=\"n0\">Hello 🎉 world</p>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 0 16 + char_range: 17 + - 0 18 + - 0 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: 0 22 + utf16_len: 0 23 + - byte_range: 24 + - 0 25 + - 16 26 + char_range: 27 + - 0 28 + - 13 29 + node_id: n0 30 + char_offset_in_node: 0 31 + child_index: ~ 32 + utf16_len: 14 33 + source_hash: 2978553570995254863
+93
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
··· 1 + --- 2 + source: crates/weaver-app/src/components/editor/tests.rs 3 + expression: result 4 + --- 5 + - byte_range: 6 + - 0 7 + - 26 8 + char_range: 9 + - 0 10 + - 26 11 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\">- </span>Item 1<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\">- </span>Item 2<span class=\"md-syntax-inline\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\">- </span>Item 3</li>\n</ul>\n" 12 + offset_map: 13 + - byte_range: 14 + - 0 15 + - 2 16 + char_range: 17 + - 0 18 + - 2 19 + node_id: n0 20 + char_offset_in_node: 0 21 + child_index: ~ 22 + utf16_len: 2 23 + - byte_range: 24 + - 2 25 + - 8 26 + char_range: 27 + - 2 28 + - 8 29 + node_id: n0 30 + char_offset_in_node: 2 31 + child_index: ~ 32 + utf16_len: 6 33 + - byte_range: 34 + - 8 35 + - 9 36 + char_range: 37 + - 8 38 + - 9 39 + node_id: n0 40 + char_offset_in_node: 8 41 + child_index: ~ 42 + utf16_len: 1 43 + - byte_range: 44 + - 9 45 + - 11 46 + char_range: 47 + - 9 48 + - 11 49 + node_id: n1 50 + char_offset_in_node: 0 51 + child_index: ~ 52 + utf16_len: 2 53 + - byte_range: 54 + - 11 55 + - 17 56 + char_range: 57 + - 11 58 + - 17 59 + node_id: n1 60 + char_offset_in_node: 2 61 + child_index: ~ 62 + utf16_len: 6 63 + - byte_range: 64 + - 17 65 + - 18 66 + char_range: 67 + - 17 68 + - 18 69 + node_id: n1 70 + char_offset_in_node: 8 71 + child_index: ~ 72 + utf16_len: 1 73 + - byte_range: 74 + - 18 75 + - 20 76 + char_range: 77 + - 18 78 + - 20 79 + node_id: n2 80 + char_offset_in_node: 0 81 + child_index: ~ 82 + utf16_len: 2 83 + - byte_range: 84 + - 20 85 + - 26 86 + char_range: 87 + - 20 88 + - 26 89 + node_id: n2 90 + char_offset_in_node: 2 91 + child_index: ~ 92 + utf16_len: 6 93 + source_hash: 10971198075422308878
+592
crates/weaver-app/src/components/editor/tests.rs
··· 1 + //! Snapshot tests for the markdown editor rendering pipeline. 2 + 3 + use super::offset_map::{OffsetMapping, find_mapping_for_char}; 4 + use super::paragraph::ParagraphRender; 5 + use super::render::render_paragraphs; 6 + use jumprope::JumpRopeBuf; 7 + use serde::Serialize; 8 + 9 + /// Serializable version of ParagraphRender for snapshot testing. 10 + #[derive(Debug, Serialize)] 11 + struct TestParagraph { 12 + byte_range: (usize, usize), 13 + char_range: (usize, usize), 14 + html: String, 15 + offset_map: Vec<TestOffsetMapping>, 16 + source_hash: u64, 17 + } 18 + 19 + impl From<&ParagraphRender> for TestParagraph { 20 + fn from(p: &ParagraphRender) -> Self { 21 + TestParagraph { 22 + byte_range: (p.byte_range.start, p.byte_range.end), 23 + char_range: (p.char_range.start, p.char_range.end), 24 + html: p.html.clone(), 25 + offset_map: p.offset_map.iter().map(TestOffsetMapping::from).collect(), 26 + source_hash: p.source_hash, 27 + } 28 + } 29 + } 30 + 31 + /// Serializable version of OffsetMapping for snapshot testing. 32 + #[derive(Debug, Serialize)] 33 + struct TestOffsetMapping { 34 + byte_range: (usize, usize), 35 + char_range: (usize, usize), 36 + node_id: String, 37 + char_offset_in_node: usize, 38 + child_index: Option<usize>, 39 + utf16_len: usize, 40 + } 41 + 42 + impl From<&OffsetMapping> for TestOffsetMapping { 43 + fn from(m: &OffsetMapping) -> Self { 44 + TestOffsetMapping { 45 + byte_range: (m.byte_range.start, m.byte_range.end), 46 + char_range: (m.char_range.start, m.char_range.end), 47 + node_id: m.node_id.clone(), 48 + char_offset_in_node: m.char_offset_in_node, 49 + child_index: m.child_index, 50 + utf16_len: m.utf16_len, 51 + } 52 + } 53 + } 54 + 55 + /// Helper: render markdown and convert to serializable test output. 56 + fn render_test(input: &str) -> Vec<TestParagraph> { 57 + let rope = JumpRopeBuf::from(input); 58 + let paragraphs = render_paragraphs(&rope); 59 + paragraphs.iter().map(TestParagraph::from).collect() 60 + } 61 + 62 + // ============================================================================= 63 + // Basic Paragraph Tests 64 + // ============================================================================= 65 + 66 + #[test] 67 + fn test_single_paragraph() { 68 + let result = render_test("Hello world"); 69 + insta::assert_yaml_snapshot!(result); 70 + } 71 + 72 + #[test] 73 + fn test_two_paragraphs() { 74 + let result = render_test("First paragraph.\n\nSecond paragraph."); 75 + insta::assert_yaml_snapshot!(result); 76 + } 77 + 78 + #[test] 79 + fn test_three_paragraphs() { 80 + let result = render_test("One.\n\nTwo.\n\nThree."); 81 + insta::assert_yaml_snapshot!(result); 82 + } 83 + 84 + // ============================================================================= 85 + // Block Element Tests 86 + // ============================================================================= 87 + 88 + #[test] 89 + fn test_heading_h1() { 90 + let result = render_test("# Heading 1"); 91 + insta::assert_yaml_snapshot!(result); 92 + } 93 + 94 + #[test] 95 + fn test_heading_levels() { 96 + let result = render_test("# H1\n\n## H2\n\n### H3\n\n#### H4"); 97 + insta::assert_yaml_snapshot!(result); 98 + } 99 + 100 + #[test] 101 + fn test_code_block_fenced() { 102 + let result = render_test("```rust\nfn main() {}\n```"); 103 + insta::assert_yaml_snapshot!(result); 104 + } 105 + 106 + #[test] 107 + fn test_unordered_list() { 108 + let result = render_test("- Item 1\n- Item 2\n- Item 3"); 109 + insta::assert_yaml_snapshot!(result); 110 + } 111 + 112 + #[test] 113 + fn test_ordered_list() { 114 + let result = render_test("1. First\n2. Second\n3. Third"); 115 + insta::assert_yaml_snapshot!(result); 116 + } 117 + 118 + #[test] 119 + fn test_nested_list() { 120 + let result = render_test("- Parent\n - Child 1\n - Child 2\n- Another parent"); 121 + insta::assert_yaml_snapshot!(result); 122 + } 123 + 124 + #[test] 125 + fn test_blockquote() { 126 + let result = render_test("> This is a quote\n>\n> With multiple lines"); 127 + insta::assert_yaml_snapshot!(result); 128 + } 129 + 130 + // ============================================================================= 131 + // Inline Formatting Tests 132 + // ============================================================================= 133 + 134 + #[test] 135 + fn test_bold() { 136 + let result = render_test("Some **bold** text"); 137 + insta::assert_yaml_snapshot!(result); 138 + } 139 + 140 + #[test] 141 + fn test_italic() { 142 + let result = render_test("Some *italic* text"); 143 + insta::assert_yaml_snapshot!(result); 144 + } 145 + 146 + #[test] 147 + fn test_inline_code() { 148 + let result = render_test("Some `code` here"); 149 + insta::assert_yaml_snapshot!(result); 150 + } 151 + 152 + #[test] 153 + fn test_bold_italic() { 154 + let result = render_test("Some ***bold italic*** text"); 155 + insta::assert_yaml_snapshot!(result); 156 + } 157 + 158 + #[test] 159 + fn test_multiple_inline_formats() { 160 + let result = render_test("**Bold** and *italic* and `code`"); 161 + insta::assert_yaml_snapshot!(result); 162 + } 163 + 164 + // ============================================================================= 165 + // Gap Paragraph Tests 166 + // ============================================================================= 167 + 168 + #[test] 169 + fn test_gap_between_blocks() { 170 + // Verify gap paragraphs are inserted for whitespace between blocks 171 + let result = render_test("# Heading\n\nParagraph below"); 172 + // Should have: heading, gap for \n\n, paragraph 173 + insta::assert_yaml_snapshot!(result); 174 + } 175 + 176 + #[test] 177 + fn test_multiple_blank_lines() { 178 + let result = render_test("First\n\n\n\nSecond"); 179 + // Extra blank lines should be captured in gap paragraphs 180 + insta::assert_yaml_snapshot!(result); 181 + } 182 + 183 + // ============================================================================= 184 + // Edge Case Tests 185 + // ============================================================================= 186 + 187 + #[test] 188 + fn test_empty_document() { 189 + let result = render_test(""); 190 + insta::assert_yaml_snapshot!(result); 191 + } 192 + 193 + #[test] 194 + fn test_only_newlines() { 195 + let result = render_test("\n\n\n"); 196 + insta::assert_yaml_snapshot!(result); 197 + } 198 + 199 + #[test] 200 + fn test_trailing_single_newline() { 201 + let result = render_test("Hello\n"); 202 + insta::assert_yaml_snapshot!(result); 203 + } 204 + 205 + #[test] 206 + fn test_trailing_double_newline() { 207 + let result = render_test("Hello\n\n"); 208 + insta::assert_yaml_snapshot!(result); 209 + } 210 + 211 + #[test] 212 + fn test_hard_break() { 213 + // Two trailing spaces + newline = hard break 214 + let result = render_test("Line one \nLine two"); 215 + insta::assert_yaml_snapshot!(result); 216 + } 217 + 218 + #[test] 219 + fn test_unicode_emoji() { 220 + let result = render_test("Hello 🎉 world"); 221 + insta::assert_yaml_snapshot!(result); 222 + } 223 + 224 + #[test] 225 + fn test_unicode_cjk() { 226 + let result = render_test("你好世界"); 227 + insta::assert_yaml_snapshot!(result); 228 + } 229 + 230 + #[test] 231 + fn test_mixed_unicode_ascii() { 232 + let result = render_test("Hello 你好 world 🎉"); 233 + insta::assert_yaml_snapshot!(result); 234 + } 235 + 236 + // ============================================================================= 237 + // Offset Map Lookup Tests 238 + // ============================================================================= 239 + 240 + #[test] 241 + fn test_find_mapping_exact_start() { 242 + let mappings = vec![OffsetMapping { 243 + byte_range: 0..5, 244 + char_range: 0..5, 245 + node_id: "n0".to_string(), 246 + char_offset_in_node: 0, 247 + child_index: None, 248 + utf16_len: 5, 249 + }]; 250 + 251 + let result = find_mapping_for_char(&mappings, 0); 252 + assert!(result.is_some()); 253 + let (mapping, _) = result.unwrap(); 254 + assert_eq!(mapping.char_range, 0..5); 255 + } 256 + 257 + #[test] 258 + fn test_find_mapping_exact_end_inclusive() { 259 + // Bug #1 regression: cursor at end of range should match 260 + let mappings = vec![OffsetMapping { 261 + byte_range: 0..5, 262 + char_range: 0..5, 263 + node_id: "n0".to_string(), 264 + char_offset_in_node: 0, 265 + child_index: None, 266 + utf16_len: 5, 267 + }]; 268 + 269 + // Position 5 should match the range 0..5 (end-inclusive for cursor) 270 + let result = find_mapping_for_char(&mappings, 5); 271 + assert!(result.is_some(), "cursor at end of range should match"); 272 + } 273 + 274 + #[test] 275 + fn test_find_mapping_middle() { 276 + let mappings = vec![OffsetMapping { 277 + byte_range: 0..10, 278 + char_range: 0..10, 279 + node_id: "n0".to_string(), 280 + char_offset_in_node: 0, 281 + child_index: None, 282 + utf16_len: 10, 283 + }]; 284 + 285 + let result = find_mapping_for_char(&mappings, 5); 286 + assert!(result.is_some()); 287 + } 288 + 289 + #[test] 290 + fn test_find_mapping_before_first() { 291 + let mappings = vec![OffsetMapping { 292 + byte_range: 5..10, 293 + char_range: 5..10, 294 + node_id: "n0".to_string(), 295 + char_offset_in_node: 0, 296 + child_index: None, 297 + utf16_len: 5, 298 + }]; 299 + 300 + // Position 2 is before the first mapping 301 + let result = find_mapping_for_char(&mappings, 2); 302 + assert!(result.is_none()); 303 + } 304 + 305 + #[test] 306 + fn test_find_mapping_after_last() { 307 + let mappings = vec![OffsetMapping { 308 + byte_range: 0..5, 309 + char_range: 0..5, 310 + node_id: "n0".to_string(), 311 + char_offset_in_node: 0, 312 + child_index: None, 313 + utf16_len: 5, 314 + }]; 315 + 316 + // Position 10 is after the last mapping 317 + let result = find_mapping_for_char(&mappings, 10); 318 + assert!(result.is_none()); 319 + } 320 + 321 + #[test] 322 + fn test_find_mapping_empty() { 323 + let mappings: Vec<OffsetMapping> = vec![]; 324 + let result = find_mapping_for_char(&mappings, 0); 325 + assert!(result.is_none()); 326 + } 327 + 328 + #[test] 329 + fn test_find_mapping_invisible_snaps() { 330 + // Invisible content should flag should_snap=true 331 + let mappings = vec![OffsetMapping { 332 + byte_range: 0..2, 333 + char_range: 0..2, 334 + node_id: "n0".to_string(), 335 + char_offset_in_node: 0, 336 + child_index: None, 337 + utf16_len: 0, // invisible 338 + }]; 339 + 340 + let result = find_mapping_for_char(&mappings, 1); 341 + assert!(result.is_some()); 342 + let (_, should_snap) = result.unwrap(); 343 + assert!(should_snap, "invisible content should trigger snap"); 344 + } 345 + 346 + // ============================================================================= 347 + // Regression Tests (from status doc bugs) 348 + // ============================================================================= 349 + 350 + #[test] 351 + fn regression_bug6_heading_as_paragraph_boundary() { 352 + // Bug #6: Headings should be tracked as paragraph boundaries 353 + let result = render_test("# Heading\n\nParagraph"); 354 + 355 + // Should have at least 2 content paragraphs (heading + paragraph) 356 + // Plus potential gap paragraphs 357 + assert!( 358 + result.len() >= 2, 359 + "heading should create separate paragraph" 360 + ); 361 + 362 + // First paragraph should contain heading 363 + assert!( 364 + result[0].html.contains("<h1>") || result[0].html.contains("Heading"), 365 + "first paragraph should be heading" 366 + ); 367 + } 368 + 369 + #[test] 370 + fn regression_bug8_inline_formatting_no_double_syntax() { 371 + // Bug #8: Inline formatting should not produce double ** 372 + let result = render_test("some **bold** text"); 373 + 374 + // Count occurrences of ** in HTML 375 + let html = &result[0].html; 376 + let double_star_count = html.matches("**").count(); 377 + 378 + // Should have exactly 2 occurrences (opening and closing, wrapped in spans) 379 + // The bug was producing 4 (doubled emission) 380 + assert!( 381 + double_star_count <= 2, 382 + "should not have double ** syntax: found {} in {}", 383 + double_star_count, 384 + html 385 + ); 386 + } 387 + 388 + #[test] 389 + fn regression_bug9_lists_as_paragraph_boundary() { 390 + // Bug #9: Lists should be tracked as paragraph boundaries 391 + let result = render_test("Before\n\n- Item 1\n- Item 2\n\nAfter"); 392 + 393 + // Should have paragraphs for: Before, list, After (plus gaps) 394 + let has_list = result 395 + .iter() 396 + .any(|p| p.html.contains("<li>") || p.html.contains("<ul>")); 397 + assert!(has_list, "list should be present in rendered output"); 398 + } 399 + 400 + #[test] 401 + fn regression_bug9_code_blocks_as_paragraph_boundary() { 402 + // Bug #9: Code blocks should be tracked as paragraph boundaries 403 + let result = render_test("Before\n\n```\ncode\n```\n\nAfter"); 404 + 405 + let has_code = result 406 + .iter() 407 + .any(|p| p.html.contains("<pre>") || p.html.contains("<code>")); 408 + assert!(has_code, "code block should be present in rendered output"); 409 + } 410 + 411 + #[test] 412 + fn regression_bug11_gap_paragraphs_for_whitespace() { 413 + // Bug #11: Gap paragraphs should be created for inter-block whitespace 414 + let result = render_test("# Title\n\nContent"); 415 + 416 + // Check that char ranges cover the full document without gaps 417 + let mut prev_end = 0; 418 + for para in &result { 419 + // Allow gaps to be filled by gap paragraphs 420 + if para.char_range.0 > prev_end { 421 + // This would be a gap - but gap paragraphs should fill it 422 + panic!( 423 + "Gap in char ranges: {}..{} missing coverage", 424 + prev_end, para.char_range.0 425 + ); 426 + } 427 + prev_end = para.char_range.1; 428 + } 429 + } 430 + 431 + // ============================================================================= 432 + // Char Range Coverage Tests 433 + // ============================================================================= 434 + 435 + #[test] 436 + fn test_char_range_full_coverage() { 437 + // Verify that char ranges cover entire document 438 + let input = "Hello\n\nWorld"; 439 + let rope = JumpRopeBuf::from(input); 440 + let paragraphs = render_paragraphs(&rope); 441 + 442 + let doc_len = rope.len_chars(); 443 + 444 + // Collect all ranges 445 + let mut ranges: Vec<_> = paragraphs.iter().map(|p| p.char_range.clone()).collect(); 446 + ranges.sort_by_key(|r| r.start); 447 + 448 + // Check coverage 449 + let mut covered = 0; 450 + for range in &ranges { 451 + assert!( 452 + range.start <= covered, 453 + "Gap at position {}, next range starts at {}", 454 + covered, 455 + range.start 456 + ); 457 + covered = covered.max(range.end); 458 + } 459 + 460 + assert!( 461 + covered >= doc_len, 462 + "Ranges don't cover full document: covered {} of {}", 463 + covered, 464 + doc_len 465 + ); 466 + } 467 + 468 + #[test] 469 + fn test_node_ids_unique_across_paragraphs() { 470 + // Verify HTML id attributes are unique across paragraphs 471 + let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item"); 472 + 473 + // Print rendered output for debugging failures 474 + for (i, para) in result.iter().enumerate() { 475 + eprintln!("--- Paragraph {} ---", i); 476 + eprintln!("char_range: {:?}", para.char_range); 477 + eprintln!("html: {}", para.html); 478 + eprintln!( 479 + "offset_map node_ids: {:?}", 480 + para.offset_map 481 + .iter() 482 + .map(|m| &m.node_id) 483 + .collect::<Vec<_>>() 484 + ); 485 + } 486 + 487 + // Extract all id and data-node-id attributes from HTML 488 + let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap(); 489 + 490 + let mut all_html_ids = std::collections::HashSet::new(); 491 + for (para_idx, para) in result.iter().enumerate() { 492 + for cap in id_regex.captures_iter(&para.html) { 493 + let id = cap.get(1).unwrap().as_str(); 494 + assert!( 495 + all_html_ids.insert(id.to_string()), 496 + "Duplicate HTML id '{}' in paragraph {}", 497 + id, 498 + para_idx 499 + ); 500 + } 501 + } 502 + } 503 + 504 + #[test] 505 + fn test_offset_mappings_reference_own_paragraph() { 506 + // Verify offset mappings only reference node IDs that exist in their paragraph's HTML 507 + let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item"); 508 + 509 + let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap(); 510 + 511 + for (para_idx, para) in result.iter().enumerate() { 512 + // Collect all node IDs in this paragraph's HTML 513 + let html_ids: std::collections::HashSet<_> = id_regex 514 + .captures_iter(&para.html) 515 + .map(|cap| cap.get(1).unwrap().as_str().to_string()) 516 + .collect(); 517 + 518 + // Verify each offset mapping references a node in this paragraph 519 + for mapping in &para.offset_map { 520 + assert!( 521 + html_ids.contains(&mapping.node_id), 522 + "Paragraph {} has offset mapping referencing '{}' but HTML only has {:?}\nHTML: {}", 523 + para_idx, 524 + mapping.node_id, 525 + html_ids, 526 + para.html 527 + ); 528 + } 529 + } 530 + } 531 + 532 + // ============================================================================= 533 + // Incremental Rendering Tests 534 + // ============================================================================= 535 + 536 + use super::render::render_paragraphs_incremental; 537 + 538 + #[test] 539 + fn test_incremental_renders_same_as_full() { 540 + // Incremental render with no cache should produce same result as full render 541 + let input = "# Heading\n\nParagraph with **bold**\n\n- List item"; 542 + let rope = JumpRopeBuf::from(input); 543 + 544 + let full = render_paragraphs(&rope); 545 + let (incremental, _cache) = render_paragraphs_incremental(&rope, None, None); 546 + 547 + // Compare HTML output (hashes may differ due to caching internals) 548 + assert_eq!( 549 + full.len(), 550 + incremental.len(), 551 + "Different paragraph count: full={}, incr={}", 552 + full.len(), 553 + incremental.len() 554 + ); 555 + 556 + for (i, (f, inc)) in full.iter().zip(incremental.iter()).enumerate() { 557 + assert_eq!( 558 + f.html, inc.html, 559 + "Paragraph {} HTML differs:\nFull: {}\nIncr: {}", 560 + i, f.html, inc.html 561 + ); 562 + assert_eq!( 563 + f.byte_range, inc.byte_range, 564 + "Paragraph {} byte_range differs", 565 + i 566 + ); 567 + assert_eq!( 568 + f.char_range, inc.char_range, 569 + "Paragraph {} char_range differs", 570 + i 571 + ); 572 + } 573 + } 574 + 575 + #[test] 576 + fn test_incremental_cache_reuse() { 577 + // Verify cache is populated and can be reused 578 + let input = "First para\n\nSecond para"; 579 + let rope = JumpRopeBuf::from(input); 580 + 581 + let (paras1, cache1) = render_paragraphs_incremental(&rope, None, None); 582 + assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 583 + 584 + // Second render with same content should reuse cache 585 + let (paras2, _cache2) = render_paragraphs_incremental(&rope, Some(&cache1), None); 586 + 587 + // Should produce identical output 588 + assert_eq!(paras1.len(), paras2.len()); 589 + for (p1, p2) in paras1.iter().zip(paras2.iter()) { 590 + assert_eq!(p1.html, p2.html); 591 + } 592 + }
+136 -30
crates/weaver-app/src/components/editor/writer.rs
··· 128 128 paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range) 129 129 current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) 130 130 131 + /// When true, skip HTML generation and only track paragraph boundaries. 132 + /// Used for fast boundary discovery in incremental rendering. 133 + boundary_only: bool, 134 + 131 135 _phantom: std::marker::PhantomData<&'a ()>, 132 136 } 133 137 ··· 178 182 current_node_child_count: 0, 179 183 paragraph_ranges: Vec::new(), 180 184 current_paragraph_start: None, 185 + boundary_only: false, 186 + _phantom: std::marker::PhantomData, 187 + } 188 + } 189 + 190 + /// Create a writer that only tracks paragraph boundaries without generating HTML. 191 + /// Used for fast boundary discovery in incremental rendering. 192 + pub fn new_boundary_only( 193 + source: &'a str, 194 + source_rope: &'a JumpRopeBuf, 195 + events: I, 196 + writer: W, 197 + ) -> Self { 198 + Self { 199 + source, 200 + source_rope, 201 + events, 202 + writer, 203 + last_byte_offset: 0, 204 + last_char_offset: 0, 205 + end_newline: true, 206 + in_non_writing_block: false, 207 + table_state: TableState::Head, 208 + table_alignments: vec![], 209 + table_cell_index: 0, 210 + numbers: HashMap::new(), 211 + embed_provider: None, 212 + code_buffer: None, 213 + code_buffer_byte_range: None, 214 + code_buffer_char_range: None, 215 + pending_blockquote_range: None, 216 + render_tables_as_markdown: true, 217 + table_start_offset: None, 218 + offset_maps: Vec::new(), 219 + next_node_id: 0, 220 + current_node_id: None, 221 + current_node_char_offset: 0, 222 + current_node_child_count: 0, 223 + paragraph_ranges: Vec::new(), 224 + current_paragraph_start: None, 225 + boundary_only: true, 181 226 _phantom: std::marker::PhantomData, 182 227 } 183 228 } ··· 211 256 current_node_child_count: self.current_node_child_count, 212 257 paragraph_ranges: self.paragraph_ranges, 213 258 current_paragraph_start: self.current_paragraph_start, 259 + boundary_only: self.boundary_only, 214 260 _phantom: std::marker::PhantomData, 215 261 } 216 262 } 217 263 #[inline] 218 264 fn write_newline(&mut self) -> Result<(), W::Error> { 219 265 self.end_newline = true; 266 + if self.boundary_only { 267 + return Ok(()); 268 + } 220 269 self.writer.write_str("\n") 221 270 } 222 271 223 272 #[inline] 224 273 fn write(&mut self, s: &str) -> Result<(), W::Error> { 225 - self.writer.write_str(s)?; 226 274 if !s.is_empty() { 227 275 self.end_newline = s.ends_with('\n'); 228 276 } 229 - Ok(()) 277 + if self.boundary_only { 278 + return Ok(()); 279 + } 280 + self.writer.write_str(s) 230 281 } 231 282 232 283 /// Emit syntax span for a given range and record offset mapping ··· 234 285 if range.start < range.end { 235 286 let syntax = &self.source[range.clone()]; 236 287 if !syntax.is_empty() { 288 + let char_start = self.last_char_offset; 289 + let syntax_char_len = syntax.chars().count(); 290 + 291 + // In boundary_only mode, just update offsets without HTML 292 + if self.boundary_only { 293 + self.last_char_offset = char_start + syntax_char_len; 294 + self.last_byte_offset = range.end; 295 + return Ok(()); 296 + } 297 + 237 298 let class = match classify_syntax(syntax) { 238 299 SyntaxClass::Inline => "md-syntax-inline", 239 300 SyntaxClass::Block => "md-syntax-block", 240 301 }; 241 - 242 - let char_start = self.last_char_offset; 243 - let syntax_char_len = syntax.chars().count(); 244 302 245 303 // If we're outside any node, create a wrapper span for tracking 246 304 let created_node = if self.current_node_id.is_none() { ··· 281 339 Ok(()) 282 340 } 283 341 342 + /// Emit syntax span inside current node with full offset tracking. 343 + /// 344 + /// Use this for syntax markers that appear inside block elements (headings, lists, 345 + /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates 346 + /// wrapper nodes, this assumes we're already inside a tracked node. 347 + /// 348 + /// - Writes `<span class="md-syntax-{class}">{syntax}</span>` 349 + /// - Records offset mapping (for cursor positioning) 350 + /// - Updates both `last_char_offset` and `last_byte_offset` 351 + fn emit_inner_syntax( 352 + &mut self, 353 + syntax: &str, 354 + byte_start: usize, 355 + class: SyntaxClass, 356 + ) -> Result<(), W::Error> { 357 + if syntax.is_empty() { 358 + return Ok(()); 359 + } 360 + 361 + let char_start = self.last_char_offset; 362 + let syntax_char_len = syntax.chars().count(); 363 + let byte_end = byte_start + syntax.len(); 364 + 365 + // In boundary_only mode, just update offsets 366 + if self.boundary_only { 367 + self.last_char_offset = char_start + syntax_char_len; 368 + self.last_byte_offset = byte_end; 369 + return Ok(()); 370 + } 371 + 372 + let class_str = match class { 373 + SyntaxClass::Inline => "md-syntax-inline", 374 + SyntaxClass::Block => "md-syntax-block", 375 + }; 376 + 377 + self.write("<span class=\"")?; 378 + self.write(class_str)?; 379 + self.write("\">")?; 380 + escape_html(&mut self.writer, syntax)?; 381 + self.write("</span>")?; 382 + 383 + // Record offset mapping for cursor positioning 384 + self.record_mapping( 385 + byte_start..byte_end, 386 + char_start..char_start + syntax_char_len, 387 + ); 388 + 389 + self.last_char_offset = char_start + syntax_char_len; 390 + self.last_byte_offset = byte_end; 391 + 392 + Ok(()) 393 + } 394 + 284 395 /// Emit any gap between last position and next offset 285 396 fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), W::Error> { 286 397 // Skip gap emission if we're inside a table being rendered as markdown ··· 835 946 // Emit > syntax if we're inside a blockquote 836 947 if let Some(bq_range) = self.pending_blockquote_range.take() { 837 948 if bq_range.start < bq_range.end { 838 - let raw_text = &self.source[bq_range]; 949 + let raw_text = &self.source[bq_range.clone()]; 839 950 if let Some(gt_pos) = raw_text.find('>') { 840 951 // Extract > [!NOTE] or just > 841 952 let after_gt = &raw_text[gt_pos + 1..]; ··· 852 963 }; 853 964 854 965 let syntax = &raw_text[gt_pos..syntax_end]; 855 - self.write("<span class=\"md-syntax-block\">")?; 856 - escape_html(&mut self.writer, syntax)?; 857 - self.write("</span> ")?; // Add space after 966 + let syntax_byte_start = bq_range.start + gt_pos; 967 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; 858 968 } 859 969 } 860 970 } ··· 938 1048 // Find where the # actually starts (might have leading whitespace) 939 1049 if let Some(hash_pos) = raw_text.find(&pattern) { 940 1050 // Extract "# " or "## " etc 941 - let syntax_start = hash_pos; 942 1051 let syntax_end = (hash_pos + count + 1).min(raw_text.len()); 943 - let syntax = &raw_text[syntax_start..syntax_end]; 944 - let syntax_char_len = syntax.chars().count(); 945 - 946 - // Calculate byte range for this syntax in the source 947 - let syntax_byte_start = range.start + syntax_start; 948 - let syntax_byte_end = range.start + syntax_end; 949 - let char_start = self.last_char_offset; 950 - 951 - self.write("<span class=\"md-syntax-block\">")?; 952 - escape_html(&mut self.writer, syntax)?; 953 - self.write("</span>")?; 1052 + let syntax = &raw_text[hash_pos..syntax_end]; 1053 + let syntax_byte_start = range.start + hash_pos; 954 1054 955 - // Record offset mapping and update char tracking 956 - // Note: last_byte_offset is managed by the main event loop 957 - self.record_mapping( 958 - syntax_byte_start..syntax_byte_end, 959 - char_start..char_start + syntax_char_len 960 - ); 961 - self.last_char_offset = char_start + syntax_char_len; 1055 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?; 962 1056 } 963 1057 } 964 1058 Ok(()) ··· 1137 1231 .map(|pos| pos + 1) 1138 1232 .unwrap_or(1); 1139 1233 let syntax = &trimmed[..marker_end.min(trimmed.len())]; 1234 + let char_start = self.last_char_offset; 1140 1235 let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1141 1236 let syntax_byte_len = leading_ws_bytes + syntax.len(); 1142 1237 self.write("<span class=\"md-syntax-block\">")?; 1143 1238 escape_html(&mut self.writer, syntax)?; 1144 1239 self.write("</span>")?; 1145 - self.last_char_offset += syntax_char_len; 1240 + // Record offset mapping for cursor positioning 1241 + self.record_mapping( 1242 + range.start..range.start + syntax_byte_len, 1243 + char_start..char_start + syntax_char_len, 1244 + ); 1245 + self.last_char_offset = char_start + syntax_char_len; 1146 1246 self.last_byte_offset = range.start + syntax_byte_len; 1147 1247 } else if marker.is_ascii_digit() { 1148 1248 // Ordered list: extract "1. " or similar 1149 1249 if let Some(dot_pos) = trimmed.find('.') { 1150 1250 let syntax_end = (dot_pos + 2).min(trimmed.len()); 1151 1251 let syntax = &trimmed[..syntax_end].trim_end(); 1252 + let char_start = self.last_char_offset; 1152 1253 let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1153 1254 let syntax_byte_len = leading_ws_bytes + syntax.len(); 1154 1255 self.write("<span class=\"md-syntax-block\">")?; 1155 1256 escape_html(&mut self.writer, syntax)?; 1156 1257 self.write("</span>")?; 1157 - self.last_char_offset += syntax_char_len; 1258 + // Record offset mapping for cursor positioning 1259 + self.record_mapping( 1260 + range.start..range.start + syntax_byte_len, 1261 + char_start..char_start + syntax_char_len, 1262 + ); 1263 + self.last_char_offset = char_start + syntax_char_len; 1158 1264 self.last_byte_offset = range.start + syntax_byte_len; 1159 1265 } 1160 1266 }