diff --git a/Cargo.lock b/Cargo.lock index 268d70b..ab8aabb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9443,6 +9443,7 @@ dependencies = [ "gloo-timers", "http", "humansize", + "insta", "jacquard", "jacquard-axum", "jacquard-identity", @@ -9455,6 +9456,7 @@ dependencies = [ "mime-sniffer", "mini-moka 0.11.0", "n0-future", + "regex", "reqwest", "serde", "serde_html_form", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 981184a..a9f1155 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -35,7 +35,7 @@ dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version axum = {version = "0.8.6", optional = true} mime-sniffer = {version = "^0.1"} chrono = { version = "0.4" } -serde = { version = "1.0"} #, features = ["derive"] } +serde = { version = "1.0" } serde_json = "1.0" humansize = "2.0.0" base64 = "0.22" @@ -73,3 +73,8 @@ lol_alloc = "0.4.1" [build-dependencies] dotenvy = "0.15.7" + +[dev-dependencies] +insta = { version = "1.40", features = ["yaml"] } +serde = { version = "1.0", features = ["derive"] } +regex = "1" diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index efa460f..4f71d09 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -19,6 +19,10 @@ pub struct EditorDocument { /// IME composition state (for Phase 3) pub composition: Option, + + /// Most recent edit info for incremental rendering optimization. + /// Used to determine if we can skip full re-parsing. + pub last_edit: Option, } /// Cursor state including position and affinity. @@ -54,7 +58,51 @@ pub struct CompositionState { pub text: String, } +/// Information about the most recent edit, used for incremental rendering optimization. +#[derive(Clone, Debug, Default)] +pub struct EditInfo { + /// Character offset where the edit occurred + pub edit_char_pos: usize, + /// Number of characters inserted + pub inserted_len: usize, + /// Number of characters deleted + pub deleted_len: usize, + /// Whether the edit contains a newline (boundary-affecting) + pub contains_newline: bool, + /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). + /// Edits here could affect block-level syntax like headings, lists, code fences. + pub in_block_syntax_zone: bool, +} + +/// Max distance from line start where block syntax can appear. +/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) +const BLOCK_SYNTAX_ZONE: usize = 6; + impl EditorDocument { + /// Check if a character position is within the block-syntax zone of its line. + fn is_in_block_syntax_zone(&self, pos: usize) -> bool { + if pos == 0 { + return true; + } + + // Find distance from previous newline by scanning forward and tracking last newline + let rope = self.rope.borrow(); + let mut last_newline_pos: Option = None; + + for (i, c) in rope.slice_chars(0..pos).enumerate() { + if c == '\n' { + last_newline_pos = Some(i); + } + } + + let chars_from_line_start = match last_newline_pos { + Some(nl_pos) => pos - nl_pos - 1, // -1 because newline itself is not part of current line + None => pos, // No newline found, distance is from document start + }; + + chars_from_line_start <= BLOCK_SYNTAX_ZONE + } + /// Create a new editor document with the given content. pub fn new(content: String) -> Self { Self { @@ -65,6 +113,7 @@ impl EditorDocument { }, selection: None, composition: None, + last_edit: None, } } @@ -82,4 +131,47 @@ impl EditorDocument { pub fn is_empty(&self) -> bool { self.rope.len_chars() == 0 } + + /// Insert text and record edit info for incremental rendering. + pub fn insert_tracked(&mut self, pos: usize, text: &str) { + let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); + self.last_edit = Some(EditInfo { + edit_char_pos: pos, + inserted_len: text.chars().count(), + deleted_len: 0, + contains_newline: text.contains('\n'), + in_block_syntax_zone, + }); + self.rope.insert(pos, text); + } + + /// Remove text range and record edit info for incremental rendering. + pub fn remove_tracked(&mut self, range: std::ops::Range) { + // Check if deleted region contains newline - borrow inner JumpRope + let contains_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); + let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + self.last_edit = Some(EditInfo { + edit_char_pos: range.start, + inserted_len: 0, + deleted_len: range.end - range.start, + contains_newline, + in_block_syntax_zone, + }); + self.rope.remove(range); + } + + /// Replace text (delete then insert) and record combined edit info. + pub fn replace_tracked(&mut self, range: std::ops::Range, text: &str) { + let delete_has_newline = self.rope.borrow().slice_chars(range.clone()).any(|c| c == '\n'); + let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + self.last_edit = Some(EditInfo { + edit_char_pos: range.start, + inserted_len: text.chars().count(), + deleted_len: range.end - range.start, + contains_newline: delete_has_newline || text.contains('\n'), + in_block_syntax_zone, + }); + self.rope.remove(range); + self.rope.insert(self.last_edit.as_ref().unwrap().edit_char_pos, text); + } } diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 271bbef..f29b741 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -15,11 +15,14 @@ mod storage; mod toolbar; mod writer; +#[cfg(test)] +mod tests; + pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; pub use paragraph::ParagraphRender; -pub use render::{render_markdown_simple, render_paragraphs}; +pub use render::{RenderCache, render_paragraphs, render_paragraphs_incremental}; pub use rope_writer::RopeWriter; pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; pub use toolbar::EditorToolbar; @@ -60,8 +63,25 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let mut document = use_signal(|| EditorDocument::new(restored())); let editor_id = "markdown-editor"; - // Render paragraphs for incremental updates - let paragraphs = use_memo(move || render::render_paragraphs(&document().rope)); + // Cache for incremental paragraph rendering + let mut render_cache = use_signal(|| render::RenderCache::default()); + + // Render paragraphs with incremental caching + let paragraphs = use_memo(move || { + let doc = document(); + let cache = render_cache.peek(); + let edit = doc.last_edit.as_ref(); + + let (paras, new_cache) = + render::render_paragraphs_incremental(&doc.rope, Some(&cache), edit); + + // Update cache for next render (write-only via spawn to avoid reactive loop) + dioxus::prelude::spawn(async move { + render_cache.set(new_cache); + }); + + paras + }); // Flatten offset maps from all paragraphs let offset_map = use_memo(move || { diff --git a/crates/weaver-app/src/components/editor/offset_map.rs b/crates/weaver-app/src/components/editor/offset_map.rs index 6c6a4b0..3422081 100644 --- a/crates/weaver-app/src/components/editor/offset_map.rs +++ b/crates/weaver-app/src/components/editor/offset_map.rs @@ -119,19 +119,19 @@ pub fn find_mapping_for_char( char_offset: usize, ) -> Option<(&OffsetMapping, bool)> { // Binary search for the mapping - // Note: We allow cursor at the end boundary of a mapping (cursor after text) - // This makes ranges END-INCLUSIVE for cursor positioning + // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. + // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), + // prefer the NEXT mapping so cursor goes "down" to new content. let idx = offset_map .binary_search_by(|mapping| { - if mapping.char_range.end < char_offset { - // Cursor is after this mapping + if mapping.char_range.end <= char_offset { + // Cursor is at or after end of this mapping - look forward std::cmp::Ordering::Less } else if mapping.char_range.start > char_offset { // Cursor is before this mapping std::cmp::Ordering::Greater } else { - // Cursor is within [start, end] OR exactly at end (inclusive) - // This handles cursor at position N matching range N-1..N + // Cursor is within [start, end) std::cmp::Ordering::Equal } }) diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index d01ade7..fb04a33 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -4,46 +4,37 @@ //! //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. +use super::document::EditInfo; use super::offset_map::{OffsetMapping, RenderResult}; use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; use super::writer::EditorWriter; use jumprope::JumpRopeBuf; use markdown_weaver::Parser; +use std::ops::Range; -/// Render markdown to HTML with visible formatting characters and offset mappings. -/// -/// This function performs a full re-render of the document on every change. -/// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility. -/// -/// Uses EditorWriter which processes offset_iter events to detect consumed -/// formatting characters and emit them as `` elements. -/// -/// Returns both the rendered HTML and offset mappings for cursor restoration. -/// -/// # Phase 2 features -/// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block) -/// - Offset map generation for cursor restoration -/// - Full document re-render (fast enough for current needs) -/// -/// # Deprecated: Use `render_paragraphs()` for incremental rendering -pub fn render_markdown_simple(source: &str) -> RenderResult { - let source_rope = JumpRopeBuf::from(source); - let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter(); - let mut output = String::new(); - - match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() { - Ok(result) => RenderResult { - html: output, - offset_map: result.offset_maps, - }, - Err(_) => { - // Fallback to empty result on error - RenderResult { - html: String::new(), - offset_map: Vec::new(), - } - } - } +/// Cache for incremental paragraph rendering. +/// Stores previously rendered paragraphs to avoid re-rendering unchanged content. +#[derive(Clone, Debug, Default)] +pub struct RenderCache { + /// Cached paragraph renders (content paragraphs only, gaps computed fresh) + pub paragraphs: Vec, + /// Next available node ID for fresh renders + pub next_node_id: usize, +} + +/// A cached paragraph render that can be reused if source hasn't changed. +#[derive(Clone, Debug)] +pub struct CachedParagraph { + /// Hash of paragraph source text for change detection + pub source_hash: u64, + /// Byte range in source document + pub byte_range: Range, + /// Char range in source document + pub char_range: Range, + /// Rendered HTML + pub html: String, + /// Offset mappings for cursor positioning + pub offset_map: Vec, } /// Render markdown in paragraph chunks for incremental DOM updates. @@ -98,7 +89,7 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); let mut node_id_offset = 0; // Track total nodes used so far for unique IDs - for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { + for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { // Extract paragraph source let para_source = rope_slice_to_string(rope, char_range.clone()); let source_hash = hash_source(¶_source); @@ -159,22 +150,28 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { }); } - // Insert gap paragraphs for whitespace between blocks - // This gives the cursor somewhere to land when positioned in newlines + // Insert gap paragraphs for EXTRA whitespace between blocks. + // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. + // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. + // Gap IDs are position-based for stability across renders. + const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); let mut prev_end_char = 0usize; let mut prev_end_byte = 0usize; for para in paragraphs { - // Check for gap before this paragraph - if para.char_range.start > prev_end_char { - let gap_start_char = prev_end_char; + // Check for gap before this paragraph - only if MORE than minimum break + let gap_size = para.char_range.start.saturating_sub(prev_end_char); + if gap_size > MIN_PARAGRAPH_BREAK { + // Gap covers the EXTRA whitespace beyond the minimum break + let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK; let gap_end_char = para.char_range.start; - let gap_start_byte = prev_end_byte; + let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK; let gap_end_byte = para.byte_range.start; - let gap_node_id = format!("n{}", node_id_offset); - node_id_offset += 1; + // Position-based ID: deterministic, stable across cache states + let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); paragraphs_with_gaps.push(ParagraphRender { @@ -209,17 +206,18 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { // Only add if there's actually a gap at the end if doc_end_char > prev_end_char { - let empty_node_id = format!("n{}", node_id_offset); - let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); + // Position-based ID for trailing gap + let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); + let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}'); paragraphs_with_gaps.push(ParagraphRender { byte_range: prev_end_byte..doc_end_byte, char_range: prev_end_char..doc_end_char, - html: empty_html, + html: trailing_html, offset_map: vec![OffsetMapping { byte_range: prev_end_byte..doc_end_byte, char_range: prev_end_char..doc_end_char, - node_id: empty_node_id, + node_id: trailing_node_id, char_offset_in_node: 0, child_index: None, utf16_len: 1, // zero-width space is 1 UTF-16 code unit @@ -231,3 +229,327 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { paragraphs_with_gaps } + +/// Check if an edit affects paragraph boundaries. +/// +/// Edits that don't contain newlines and aren't in the block-syntax zone +/// are considered "safe" and can skip boundary rediscovery. +fn is_boundary_affecting(edit: &EditInfo) -> bool { + // Newlines always affect boundaries (paragraph splits/joins) + if edit.contains_newline { + return true; + } + + // Edits in the block-syntax zone (first ~6 chars of line) could affect + // headings, lists, blockquotes, code fences, etc. + if edit.in_block_syntax_zone { + return true; + } + + false +} + +/// Adjust a cached paragraph's positions after an earlier edit. +fn adjust_paragraph_positions( + cached: &CachedParagraph, + char_delta: isize, + byte_delta: isize, +) -> ParagraphRender { + let mut adjusted_map = cached.offset_map.clone(); + for mapping in &mut adjusted_map { + mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize; + mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; + mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize; + mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; + } + + ParagraphRender { + byte_range: (cached.byte_range.start as isize + byte_delta) as usize + ..(cached.byte_range.end as isize + byte_delta) as usize, + char_range: (cached.char_range.start as isize + char_delta) as usize + ..(cached.char_range.end as isize + char_delta) as usize, + html: cached.html.clone(), + offset_map: adjusted_map, + source_hash: cached.source_hash, + } +} + +/// Render markdown with incremental caching. +/// +/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. +/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. +/// +/// # Arguments +/// - `rope`: The document rope to render +/// - `cache`: Previous render cache (if any) +/// - `edit`: Information about the most recent edit (if any) +/// +/// # Returns +/// Tuple of (rendered paragraphs, updated cache) +pub fn render_paragraphs_incremental( + rope: &JumpRopeBuf, + cache: Option<&RenderCache>, + edit: Option<&EditInfo>, +) -> (Vec, RenderCache) { + let source = rope.to_string(); + + // Handle empty document + if source.is_empty() { + let empty_node_id = "n0".to_string(); + let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); + + let para = ParagraphRender { + byte_range: 0..0, + char_range: 0..0, + html: empty_html.clone(), + offset_map: vec![], + source_hash: 0, + }; + + let new_cache = RenderCache { + paragraphs: vec![CachedParagraph { + source_hash: 0, + byte_range: 0..0, + char_range: 0..0, + html: empty_html, + offset_map: vec![], + }], + next_node_id: 1, + }; + + return (vec![para], new_cache); + } + + // Determine if we can use fast path (skip boundary discovery) + let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap()); + + // Get paragraph boundaries + let paragraph_ranges = if use_fast_path { + // Fast path: adjust cached boundaries based on edit + let cache = cache.unwrap(); + let edit = edit.unwrap(); + + // Find which paragraph the edit falls into + let edit_pos = edit.edit_char_pos; + let char_delta = edit.inserted_len as isize - edit.deleted_len as isize; + + // Adjust each cached paragraph's range + cache + .paragraphs + .iter() + .map(|p| { + if p.char_range.end <= edit_pos { + // Before edit - no change + (p.byte_range.clone(), p.char_range.clone()) + } else if p.char_range.start >= edit_pos { + // After edit - shift by delta + // Calculate byte delta (approximation: assume 1 byte per char for ASCII) + // This is imprecise but boundaries are rediscovered on slow path anyway + let byte_delta = char_delta; // TODO: proper byte calculation + ( + (p.byte_range.start as isize + byte_delta) as usize + ..(p.byte_range.end as isize + byte_delta) as usize, + (p.char_range.start as isize + char_delta) as usize + ..(p.char_range.end as isize + char_delta) as usize, + ) + } else { + // Edit is within this paragraph - expand its end + ( + p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize, + p.char_range.start..(p.char_range.end as isize + char_delta) as usize, + ) + } + }) + .collect::>() + } else { + // Slow path: run boundary-only pass to discover paragraph boundaries + let parser = + Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); + let mut scratch_output = String::new(); + + match EditorWriter::<_, _, ()>::new_boundary_only( + &source, + rope, + parser, + &mut scratch_output, + ) + .run() + { + Ok(result) => result.paragraph_ranges, + Err(_) => return (Vec::new(), RenderCache::default()), + } + }; + + // Render paragraphs, reusing cache where possible + let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); + let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); + let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0); + + for (byte_range, char_range) in paragraph_ranges.iter() { + let para_source = rope_slice_to_string(rope, char_range.clone()); + let source_hash = hash_source(¶_source); + + // Check if we have a cached render with matching hash + let cached_match = + cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); + + let (html, offset_map) = if let Some(cached) = cached_match { + // Reuse cached HTML and offset map (adjusted for position) + let char_delta = char_range.start as isize - cached.char_range.start as isize; + let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; + + let mut adjusted_map = cached.offset_map.clone(); + for mapping in &mut adjusted_map { + mapping.char_range.start = + (mapping.char_range.start as isize + char_delta) as usize; + mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; + mapping.byte_range.start = + (mapping.byte_range.start as isize + byte_delta) as usize; + mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; + } + + (cached.html.clone(), adjusted_map) + } else { + // Fresh render needed + let para_rope = JumpRopeBuf::from(para_source.as_str()); + let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options()) + .into_offset_iter(); + let mut output = String::new(); + + let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( + ¶_source, + ¶_rope, + parser, + &mut output, + node_id_offset, + ) + .run() + { + Ok(result) => { + // Update node ID offset + let max_node_id = result + .offset_maps + .iter() + .filter_map(|m| { + m.node_id + .strip_prefix("n") + .and_then(|s| s.parse::().ok()) + }) + .max() + .unwrap_or(node_id_offset); + node_id_offset = max_node_id + 1; + result.offset_maps + } + Err(_) => Vec::new(), + }; + + // Adjust offsets to document coordinates + let para_char_start = char_range.start; + let para_byte_start = byte_range.start; + for mapping in &mut offset_map { + mapping.byte_range.start += para_byte_start; + mapping.byte_range.end += para_byte_start; + mapping.char_range.start += para_char_start; + mapping.char_range.end += para_char_start; + } + + (output, offset_map) + }; + + // Store in cache + new_cached.push(CachedParagraph { + source_hash, + byte_range: byte_range.clone(), + char_range: char_range.clone(), + html: html.clone(), + offset_map: offset_map.clone(), + }); + + paragraphs.push(ParagraphRender { + byte_range: byte_range.clone(), + char_range: char_range.clone(), + html, + offset_map, + source_hash, + }); + } + + // Insert gap paragraphs for EXTRA whitespace between blocks. + // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that. + // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot. + const MIN_PARAGRAPH_BREAK_INCR: usize = 2; // \n\n + + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); + let mut prev_end_char = 0usize; + let mut prev_end_byte = 0usize; + + for para in paragraphs { + // Check for gap before this paragraph - only if MORE than minimum break + let gap_size = para.char_range.start.saturating_sub(prev_end_char); + if gap_size > MIN_PARAGRAPH_BREAK_INCR { + // Gap covers the EXTRA whitespace beyond the minimum break + let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; + let gap_end_char = para.char_range.start; + let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; + let gap_end_byte = para.byte_range.start; + + // Position-based ID: deterministic, stable across cache states + let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); + let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + byte_range: gap_start_byte..gap_end_byte, + char_range: gap_start_char..gap_end_char, + html: gap_html, + offset_map: vec![OffsetMapping { + byte_range: gap_start_byte..gap_end_byte, + char_range: gap_start_char..gap_end_char, + node_id: gap_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, + }], + source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), + }); + } + + prev_end_char = para.char_range.end; + prev_end_byte = para.byte_range.end; + paragraphs_with_gaps.push(para); + } + + // Add trailing gap if needed + let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); + if has_trailing_newlines { + let doc_end_char = rope.len_chars(); + let doc_end_byte = rope.len_bytes(); + + if doc_end_char > prev_end_char { + // Position-based ID for trailing gap + let trailing_node_id = format!("gap-{}-{}", prev_end_char, doc_end_char); + let trailing_html = format!(r#"{}"#, trailing_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + html: trailing_html, + offset_map: vec![OffsetMapping { + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + node_id: trailing_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, + }], + source_hash: 0, + }); + } + } + + let new_cache = RenderCache { + paragraphs: new_cached, + next_node_id: node_id_offset, + }; + + (paragraphs_with_gaps, new_cache) +} diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap new file mode 100644 index 0000000..3ceb2cb --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap @@ -0,0 +1,101 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 41 + - 18 + char_range: + - 0 + - 18 + html: "
\n

> This is a quote\n

\n
\n" + offset_map: + - byte_range: + - 43 + - 43 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 41 + - 43 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 43 + - 58 + char_range: + - 2 + - 17 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 15 + - byte_range: + - 58 + - 59 + char_range: + - 17 + - 18 + node_id: n0 + char_offset_in_node: 17 + child_index: ~ + utf16_len: 1 + source_hash: 11268153476336590706 +- byte_range: + - 20 + - 22 + char_range: + - 20 + - 22 + html: "​" + offset_map: + - byte_range: + - 20 + - 22 + char_range: + - 20 + - 22 + node_id: gap-20-22 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + source_hash: 6078396554664461735 +- byte_range: + - 22 + - 41 + char_range: + - 22 + - 41 + html: "

With multiple lines

\n" + offset_map: + - byte_range: + - 22 + - 22 + char_range: + - 22 + - 22 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 22 + - 41 + char_range: + - 22 + - 41 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 19 + source_hash: 765233770142737875 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap new file mode 100644 index 0000000..e818ceb --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap @@ -0,0 +1,63 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 18 + char_range: + - 0 + - 18 + html: "

Some **bold** text

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 7 + - 11 + char_range: + - 7 + - 11 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 4 + - byte_range: + - 11 + - 13 + char_range: + - 11 + - 13 + node_id: n0 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 2 + - byte_range: + - 13 + - 18 + char_range: + - 13 + - 18 + node_id: n0 + char_offset_in_node: 11 + child_index: ~ + utf16_len: 5 + source_hash: 3007541947422346271 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap new file mode 100644 index 0000000..c1ea510 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap @@ -0,0 +1,73 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 27 + char_range: + - 0 + - 27 + html: "

Some ***bold italic*** text

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 8 + - 19 + char_range: + - 8 + - 19 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 11 + - byte_range: + - 19 + - 21 + char_range: + - 19 + - 21 + node_id: n0 + char_offset_in_node: 16 + child_index: ~ + utf16_len: 2 + - byte_range: + - 21 + - 22 + char_range: + - 21 + - 22 + node_id: n0 + char_offset_in_node: 18 + child_index: ~ + utf16_len: 1 + - byte_range: + - 22 + - 27 + char_range: + - 22 + - 27 + node_id: n0 + char_offset_in_node: 19 + child_index: ~ + utf16_len: 5 + source_hash: 17839597501764990486 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap new file mode 100644 index 0000000..4878bc0 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap @@ -0,0 +1,23 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 24 + char_range: + - 0 + - 24 + html: "```rust\n
fn main() {}\n
```" + offset_map: + - byte_range: + - 8 + - 21 + char_range: + - 8 + - 21 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 13 + source_hash: 13617303422540996675 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__empty_document.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__empty_document.snap new file mode 100644 index 0000000..fc954bf --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__empty_document.snap @@ -0,0 +1,13 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + html: "​" + offset_map: [] + source_hash: 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap new file mode 100644 index 0000000..f84f3a6 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap @@ -0,0 +1,82 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 10 + char_range: + - 0 + - 10 + html: "

# Heading\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 2 + - 9 + char_range: + - 2 + - 9 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 7 + - byte_range: + - 9 + - 10 + char_range: + - 9 + - 10 + node_id: n0 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 1 + source_hash: 9728620054493468379 +- byte_range: + - 11 + - 26 + char_range: + - 11 + - 26 + html: "

Paragraph below

\n" + offset_map: + - byte_range: + - 11 + - 11 + char_range: + - 11 + - 11 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 11 + - 26 + char_range: + - 11 + - 26 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 15 + source_hash: 3568739071302808795 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap new file mode 100644 index 0000000..13d194d --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap @@ -0,0 +1,63 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 19 + char_range: + - 0 + - 19 + html: "

Line one
​Line two

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 8 + char_range: + - 0 + - 8 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 8 + - byte_range: + - 8 + - 10 + char_range: + - 8 + - 10 + node_id: n0 + char_offset_in_node: 8 + child_index: ~ + utf16_len: 2 + - byte_range: + - 10 + - 11 + char_range: + - 10 + - 11 + node_id: n0 + char_offset_in_node: 10 + child_index: ~ + utf16_len: 1 + - byte_range: + - 11 + - 19 + char_range: + - 11 + - 19 + node_id: n0 + char_offset_in_node: 11 + child_index: ~ + utf16_len: 8 + source_hash: 11960038156086585782 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap new file mode 100644 index 0000000..c746d96 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap @@ -0,0 +1,43 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 11 + char_range: + - 0 + - 11 + html: "

# Heading 1

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 2 + - 11 + char_range: + - 2 + - 11 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 9 + source_hash: 17388718256970297689 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap new file mode 100644 index 0000000..b2b949e --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap @@ -0,0 +1,190 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + html: "

# H1\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 2 + - 4 + char_range: + - 2 + - 4 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 2 + - byte_range: + - 4 + - 5 + char_range: + - 4 + - 5 + node_id: n0 + char_offset_in_node: 4 + child_index: ~ + utf16_len: 1 + source_hash: 7777810684660076739 +- byte_range: + - 6 + - 12 + char_range: + - 6 + - 12 + html: "

## H2\n

\n" + offset_map: + - byte_range: + - 6 + - 6 + char_range: + - 6 + - 6 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 6 + - 9 + char_range: + - 6 + - 9 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 3 + - byte_range: + - 9 + - 11 + char_range: + - 9 + - 11 + node_id: n1 + char_offset_in_node: 3 + child_index: ~ + utf16_len: 2 + - byte_range: + - 11 + - 12 + char_range: + - 11 + - 12 + node_id: n1 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 + source_hash: 252166429066241764 +- byte_range: + - 13 + - 20 + char_range: + - 13 + - 20 + html: "

### H3\n

\n" + offset_map: + - byte_range: + - 13 + - 13 + char_range: + - 13 + - 13 + node_id: n2 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 13 + - 17 + char_range: + - 13 + - 17 + node_id: n2 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 4 + - byte_range: + - 17 + - 19 + char_range: + - 17 + - 19 + node_id: n2 + char_offset_in_node: 4 + child_index: ~ + utf16_len: 2 + - byte_range: + - 19 + - 20 + char_range: + - 19 + - 20 + node_id: n2 + char_offset_in_node: 6 + child_index: ~ + utf16_len: 1 + source_hash: 7264449752500241334 +- byte_range: + - 21 + - 28 + char_range: + - 21 + - 28 + html: "

#### H4

\n" + offset_map: + - byte_range: + - 21 + - 21 + char_range: + - 21 + - 21 + node_id: n3 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 21 + - 26 + char_range: + - 21 + - 26 + node_id: n3 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 26 + - 28 + char_range: + - 26 + - 28 + node_id: n3 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 2 + source_hash: 2886251954581387532 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap new file mode 100644 index 0000000..7758c5d --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap @@ -0,0 +1,53 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 16 + char_range: + - 0 + - 16 + html: "

Some `code` here

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 5 + - 11 + char_range: + - 6 + - 10 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 4 + - byte_range: + - 11 + - 16 + char_range: + - 11 + - 16 + node_id: n0 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 5 + source_hash: 10489263388249723293 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap new file mode 100644 index 0000000..91fafa2 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap @@ -0,0 +1,63 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 18 + char_range: + - 0 + - 18 + html: "

Some *italic* text

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 6 + - 12 + char_range: + - 6 + - 12 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 6 + - byte_range: + - 12 + - 13 + char_range: + - 12 + - 13 + node_id: n0 + char_offset_in_node: 11 + child_index: ~ + utf16_len: 1 + - byte_range: + - 13 + - 18 + char_range: + - 13 + - 18 + node_id: n0 + char_offset_in_node: 12 + child_index: ~ + utf16_len: 5 + source_hash: 4363411941421262428 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap new file mode 100644 index 0000000..03f705b --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap @@ -0,0 +1,33 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 23 + char_range: + - 0 + - 16 + html: "

Hello 你好 world 🎉

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 23 + char_range: + - 0 + - 16 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 17 + source_hash: 3781751019085848984 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap new file mode 100644 index 0000000..f7fdcf4 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap @@ -0,0 +1,91 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 6 + char_range: + - 0 + - 6 + html: "

First\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 5 + - 6 + char_range: + - 5 + - 6 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 + source_hash: 6799294212516041738 +- byte_range: + - 8 + - 9 + char_range: + - 8 + - 9 + html: "​" + offset_map: + - byte_range: + - 8 + - 9 + char_range: + - 8 + - 9 + node_id: gap-8-9 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + source_hash: 4789919281594481934 +- byte_range: + - 9 + - 15 + char_range: + - 9 + - 15 + html: "

Second

\n" + offset_map: + - byte_range: + - 9 + - 9 + char_range: + - 9 + - 9 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 9 + - 15 + char_range: + - 9 + - 15 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 6 + source_hash: 14114649427451643080 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap new file mode 100644 index 0000000..b23a9f3 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap @@ -0,0 +1,93 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 32 + char_range: + - 0 + - 32 + html: "

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

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 2 + - 6 + char_range: + - 2 + - 6 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 4 + - byte_range: + - 6 + - 8 + char_range: + - 6 + - 8 + node_id: n0 + char_offset_in_node: 4 + child_index: ~ + utf16_len: 2 + - byte_range: + - 8 + - 13 + char_range: + - 8 + - 13 + node_id: n0 + char_offset_in_node: 6 + child_index: ~ + utf16_len: 5 + - byte_range: + - 14 + - 20 + char_range: + - 14 + - 20 + node_id: n0 + char_offset_in_node: 11 + child_index: ~ + utf16_len: 6 + - byte_range: + - 20 + - 21 + char_range: + - 20 + - 21 + node_id: n0 + char_offset_in_node: 17 + child_index: ~ + utf16_len: 1 + - byte_range: + - 21 + - 26 + char_range: + - 21 + - 26 + node_id: n0 + char_offset_in_node: 18 + child_index: ~ + utf16_len: 5 + - byte_range: + - 26 + - 32 + char_range: + - 27 + - 31 + node_id: n0 + char_offset_in_node: 23 + child_index: ~ + utf16_len: 4 + source_hash: 17988102203032347642 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap new file mode 100644 index 0000000..c467385 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap @@ -0,0 +1,92 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 2 + - 11 + char_range: + - 2 + - 11 + html: "​" + offset_map: + - byte_range: + - 2 + - 11 + char_range: + - 2 + - 11 + node_id: gap-2-11 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + source_hash: 10732713550789592057 +- byte_range: + - 11 + - 33 + char_range: + - 11 + - 33 + html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" + offset_map: + - byte_range: + - 11 + - 13 + char_range: + - 11 + - 13 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 13 + - 20 + char_range: + - 13 + - 20 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 7 + - byte_range: + - 20 + - 23 + char_range: + - 20 + - 23 + node_id: n0 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 3 + - byte_range: + - 23 + - 25 + char_range: + - 23 + - 25 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 25 + - 32 + char_range: + - 25 + - 32 + node_id: n1 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 7 + - byte_range: + - 32 + - 33 + char_range: + - 32 + - 33 + node_id: n1 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 1 + source_hash: 488032603397006983 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap new file mode 100644 index 0000000..b83e67d --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap @@ -0,0 +1,23 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 3 + char_range: + - 0 + - 3 + html: "​" + offset_map: + - byte_range: + - 0 + - 3 + char_range: + - 0 + - 3 + node_id: gap-0-3 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + source_hash: 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap new file mode 100644 index 0000000..9f22a40 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap @@ -0,0 +1,123 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 27 + char_range: + - 0 + - 27 + html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" + offset_map: + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 2 + - 3 + char_range: + - 2 + - 3 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 1 + - byte_range: + - 3 + - 8 + char_range: + - 3 + - 8 + node_id: n0 + char_offset_in_node: 3 + child_index: ~ + utf16_len: 5 + - byte_range: + - 8 + - 9 + char_range: + - 8 + - 9 + node_id: n0 + char_offset_in_node: 8 + child_index: ~ + utf16_len: 1 + - byte_range: + - 9 + - 11 + char_range: + - 9 + - 11 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 11 + - 12 + char_range: + - 11 + - 12 + node_id: n1 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 1 + - byte_range: + - 12 + - 18 + char_range: + - 12 + - 18 + node_id: n1 + char_offset_in_node: 3 + child_index: ~ + utf16_len: 6 + - byte_range: + - 18 + - 19 + char_range: + - 18 + - 19 + node_id: n1 + char_offset_in_node: 9 + child_index: ~ + utf16_len: 1 + - byte_range: + - 19 + - 21 + char_range: + - 19 + - 21 + node_id: n2 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 21 + - 22 + char_range: + - 21 + - 22 + node_id: n2 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 1 + - byte_range: + - 22 + - 27 + char_range: + - 22 + - 27 + node_id: n2 + char_offset_in_node: 3 + child_index: ~ + utf16_len: 5 + source_hash: 5390174624313041457 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap new file mode 100644 index 0000000..caa5557 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap @@ -0,0 +1,33 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 11 + char_range: + - 0 + - 11 + html: "

Hello world

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 11 + char_range: + - 0 + - 11 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 11 + source_hash: 2216321107127430384 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap new file mode 100644 index 0000000..fdd9b5b --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap @@ -0,0 +1,111 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + html: "

One.\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 4 + char_range: + - 0 + - 4 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 4 + - byte_range: + - 4 + - 5 + char_range: + - 4 + - 5 + node_id: n0 + char_offset_in_node: 4 + child_index: ~ + utf16_len: 1 + source_hash: 9153687894432250931 +- byte_range: + - 6 + - 11 + char_range: + - 6 + - 11 + html: "

Two.\n

\n" + offset_map: + - byte_range: + - 6 + - 6 + char_range: + - 6 + - 6 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 6 + - 10 + char_range: + - 6 + - 10 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 4 + - byte_range: + - 10 + - 11 + char_range: + - 10 + - 11 + node_id: n1 + char_offset_in_node: 4 + child_index: ~ + utf16_len: 1 + source_hash: 3900796536115303959 +- byte_range: + - 12 + - 18 + char_range: + - 12 + - 18 + html: "

Three.

\n" + offset_map: + - byte_range: + - 12 + - 12 + char_range: + - 12 + - 12 + node_id: n2 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 12 + - 18 + char_range: + - 12 + - 18 + node_id: n2 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 6 + source_hash: 11679754215709541712 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap new file mode 100644 index 0000000..b89dc00 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap @@ -0,0 +1,62 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 6 + char_range: + - 0 + - 6 + html: "

Hello\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 5 + - 6 + char_range: + - 5 + - 6 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 + source_hash: 1280328235052234789 +- byte_range: + - 6 + - 7 + char_range: + - 6 + - 7 + html: "​" + offset_map: + - byte_range: + - 6 + - 7 + char_range: + - 6 + - 7 + node_id: gap-6-7 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + source_hash: 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap new file mode 100644 index 0000000..122cd50 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap @@ -0,0 +1,43 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 6 + char_range: + - 0 + - 6 + html: "

Hello\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 5 + char_range: + - 0 + - 5 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 5 + - byte_range: + - 5 + - 6 + char_range: + - 5 + - 6 + node_id: n0 + char_offset_in_node: 5 + child_index: ~ + utf16_len: 1 + source_hash: 1280328235052234789 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap new file mode 100644 index 0000000..53b527f --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap @@ -0,0 +1,72 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 17 + char_range: + - 0 + - 17 + html: "

First paragraph.\n

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 16 + char_range: + - 0 + - 16 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 16 + - byte_range: + - 16 + - 17 + char_range: + - 16 + - 17 + node_id: n0 + char_offset_in_node: 16 + child_index: ~ + utf16_len: 1 + source_hash: 15312977301334065815 +- byte_range: + - 18 + - 35 + char_range: + - 18 + - 35 + html: "

Second paragraph.

\n" + offset_map: + - byte_range: + - 18 + - 18 + char_range: + - 18 + - 18 + node_id: n1 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 18 + - 35 + char_range: + - 18 + - 35 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 17 + source_hash: 11636620376496732832 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap new file mode 100644 index 0000000..86f2417 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap @@ -0,0 +1,33 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 12 + char_range: + - 0 + - 4 + html: "

你好世界

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 12 + char_range: + - 0 + - 4 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 4 + source_hash: 18244118180434471326 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap new file mode 100644 index 0000000..b4409c5 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_emoji.snap @@ -0,0 +1,33 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 16 + char_range: + - 0 + - 13 + html: "

Hello 🎉 world

\n" + offset_map: + - byte_range: + - 0 + - 0 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 0 + - 16 + char_range: + - 0 + - 13 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 14 + source_hash: 2978553570995254863 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap new file mode 100644 index 0000000..8500571 --- /dev/null +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap @@ -0,0 +1,93 @@ +--- +source: crates/weaver-app/src/components/editor/tests.rs +expression: result +--- +- byte_range: + - 0 + - 26 + char_range: + - 0 + - 26 + html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" + offset_map: + - byte_range: + - 0 + - 2 + char_range: + - 0 + - 2 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 2 + - 8 + char_range: + - 2 + - 8 + node_id: n0 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 6 + - byte_range: + - 8 + - 9 + char_range: + - 8 + - 9 + node_id: n0 + char_offset_in_node: 8 + child_index: ~ + utf16_len: 1 + - byte_range: + - 9 + - 11 + char_range: + - 9 + - 11 + node_id: n1 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 11 + - 17 + char_range: + - 11 + - 17 + node_id: n1 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 6 + - byte_range: + - 17 + - 18 + char_range: + - 17 + - 18 + node_id: n1 + char_offset_in_node: 8 + child_index: ~ + utf16_len: 1 + - byte_range: + - 18 + - 20 + char_range: + - 18 + - 20 + node_id: n2 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 2 + - byte_range: + - 20 + - 26 + char_range: + - 20 + - 26 + node_id: n2 + char_offset_in_node: 2 + child_index: ~ + utf16_len: 6 + source_hash: 10971198075422308878 diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs new file mode 100644 index 0000000..b403367 --- /dev/null +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -0,0 +1,592 @@ +//! Snapshot tests for the markdown editor rendering pipeline. + +use super::offset_map::{OffsetMapping, find_mapping_for_char}; +use super::paragraph::ParagraphRender; +use super::render::render_paragraphs; +use jumprope::JumpRopeBuf; +use serde::Serialize; + +/// Serializable version of ParagraphRender for snapshot testing. +#[derive(Debug, Serialize)] +struct TestParagraph { + byte_range: (usize, usize), + char_range: (usize, usize), + html: String, + offset_map: Vec, + source_hash: u64, +} + +impl From<&ParagraphRender> for TestParagraph { + fn from(p: &ParagraphRender) -> Self { + TestParagraph { + byte_range: (p.byte_range.start, p.byte_range.end), + char_range: (p.char_range.start, p.char_range.end), + html: p.html.clone(), + offset_map: p.offset_map.iter().map(TestOffsetMapping::from).collect(), + source_hash: p.source_hash, + } + } +} + +/// Serializable version of OffsetMapping for snapshot testing. +#[derive(Debug, Serialize)] +struct TestOffsetMapping { + byte_range: (usize, usize), + char_range: (usize, usize), + node_id: String, + char_offset_in_node: usize, + child_index: Option, + utf16_len: usize, +} + +impl From<&OffsetMapping> for TestOffsetMapping { + fn from(m: &OffsetMapping) -> Self { + TestOffsetMapping { + byte_range: (m.byte_range.start, m.byte_range.end), + char_range: (m.char_range.start, m.char_range.end), + node_id: m.node_id.clone(), + char_offset_in_node: m.char_offset_in_node, + child_index: m.child_index, + utf16_len: m.utf16_len, + } + } +} + +/// Helper: render markdown and convert to serializable test output. +fn render_test(input: &str) -> Vec { + let rope = JumpRopeBuf::from(input); + let paragraphs = render_paragraphs(&rope); + paragraphs.iter().map(TestParagraph::from).collect() +} + +// ============================================================================= +// Basic Paragraph Tests +// ============================================================================= + +#[test] +fn test_single_paragraph() { + let result = render_test("Hello world"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_two_paragraphs() { + let result = render_test("First paragraph.\n\nSecond paragraph."); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_three_paragraphs() { + let result = render_test("One.\n\nTwo.\n\nThree."); + insta::assert_yaml_snapshot!(result); +} + +// ============================================================================= +// Block Element Tests +// ============================================================================= + +#[test] +fn test_heading_h1() { + let result = render_test("# Heading 1"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_heading_levels() { + let result = render_test("# H1\n\n## H2\n\n### H3\n\n#### H4"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_code_block_fenced() { + let result = render_test("```rust\nfn main() {}\n```"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_unordered_list() { + let result = render_test("- Item 1\n- Item 2\n- Item 3"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_ordered_list() { + let result = render_test("1. First\n2. Second\n3. Third"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_nested_list() { + let result = render_test("- Parent\n - Child 1\n - Child 2\n- Another parent"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_blockquote() { + let result = render_test("> This is a quote\n>\n> With multiple lines"); + insta::assert_yaml_snapshot!(result); +} + +// ============================================================================= +// Inline Formatting Tests +// ============================================================================= + +#[test] +fn test_bold() { + let result = render_test("Some **bold** text"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_italic() { + let result = render_test("Some *italic* text"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_inline_code() { + let result = render_test("Some `code` here"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_bold_italic() { + let result = render_test("Some ***bold italic*** text"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_multiple_inline_formats() { + let result = render_test("**Bold** and *italic* and `code`"); + insta::assert_yaml_snapshot!(result); +} + +// ============================================================================= +// Gap Paragraph Tests +// ============================================================================= + +#[test] +fn test_gap_between_blocks() { + // Verify gap paragraphs are inserted for whitespace between blocks + let result = render_test("# Heading\n\nParagraph below"); + // Should have: heading, gap for \n\n, paragraph + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_multiple_blank_lines() { + let result = render_test("First\n\n\n\nSecond"); + // Extra blank lines should be captured in gap paragraphs + insta::assert_yaml_snapshot!(result); +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +#[test] +fn test_empty_document() { + let result = render_test(""); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_only_newlines() { + let result = render_test("\n\n\n"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_trailing_single_newline() { + let result = render_test("Hello\n"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_trailing_double_newline() { + let result = render_test("Hello\n\n"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_hard_break() { + // Two trailing spaces + newline = hard break + let result = render_test("Line one \nLine two"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_unicode_emoji() { + let result = render_test("Hello 🎉 world"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_unicode_cjk() { + let result = render_test("你好世界"); + insta::assert_yaml_snapshot!(result); +} + +#[test] +fn test_mixed_unicode_ascii() { + let result = render_test("Hello 你好 world 🎉"); + insta::assert_yaml_snapshot!(result); +} + +// ============================================================================= +// Offset Map Lookup Tests +// ============================================================================= + +#[test] +fn test_find_mapping_exact_start() { + let mappings = vec![OffsetMapping { + byte_range: 0..5, + char_range: 0..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }]; + + let result = find_mapping_for_char(&mappings, 0); + assert!(result.is_some()); + let (mapping, _) = result.unwrap(); + assert_eq!(mapping.char_range, 0..5); +} + +#[test] +fn test_find_mapping_exact_end_inclusive() { + // Bug #1 regression: cursor at end of range should match + let mappings = vec![OffsetMapping { + byte_range: 0..5, + char_range: 0..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }]; + + // Position 5 should match the range 0..5 (end-inclusive for cursor) + let result = find_mapping_for_char(&mappings, 5); + assert!(result.is_some(), "cursor at end of range should match"); +} + +#[test] +fn test_find_mapping_middle() { + let mappings = vec![OffsetMapping { + byte_range: 0..10, + char_range: 0..10, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 10, + }]; + + let result = find_mapping_for_char(&mappings, 5); + assert!(result.is_some()); +} + +#[test] +fn test_find_mapping_before_first() { + let mappings = vec![OffsetMapping { + byte_range: 5..10, + char_range: 5..10, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }]; + + // Position 2 is before the first mapping + let result = find_mapping_for_char(&mappings, 2); + assert!(result.is_none()); +} + +#[test] +fn test_find_mapping_after_last() { + let mappings = vec![OffsetMapping { + byte_range: 0..5, + char_range: 0..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }]; + + // Position 10 is after the last mapping + let result = find_mapping_for_char(&mappings, 10); + assert!(result.is_none()); +} + +#[test] +fn test_find_mapping_empty() { + let mappings: Vec = vec![]; + let result = find_mapping_for_char(&mappings, 0); + assert!(result.is_none()); +} + +#[test] +fn test_find_mapping_invisible_snaps() { + // Invisible content should flag should_snap=true + let mappings = vec![OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible + }]; + + let result = find_mapping_for_char(&mappings, 1); + assert!(result.is_some()); + let (_, should_snap) = result.unwrap(); + assert!(should_snap, "invisible content should trigger snap"); +} + +// ============================================================================= +// Regression Tests (from status doc bugs) +// ============================================================================= + +#[test] +fn regression_bug6_heading_as_paragraph_boundary() { + // Bug #6: Headings should be tracked as paragraph boundaries + let result = render_test("# Heading\n\nParagraph"); + + // Should have at least 2 content paragraphs (heading + paragraph) + // Plus potential gap paragraphs + assert!( + result.len() >= 2, + "heading should create separate paragraph" + ); + + // First paragraph should contain heading + assert!( + result[0].html.contains("

") || result[0].html.contains("Heading"), + "first paragraph should be heading" + ); +} + +#[test] +fn regression_bug8_inline_formatting_no_double_syntax() { + // Bug #8: Inline formatting should not produce double ** + let result = render_test("some **bold** text"); + + // Count occurrences of ** in HTML + let html = &result[0].html; + let double_star_count = html.matches("**").count(); + + // Should have exactly 2 occurrences (opening and closing, wrapped in spans) + // The bug was producing 4 (doubled emission) + assert!( + double_star_count <= 2, + "should not have double ** syntax: found {} in {}", + double_star_count, + html + ); +} + +#[test] +fn regression_bug9_lists_as_paragraph_boundary() { + // Bug #9: Lists should be tracked as paragraph boundaries + let result = render_test("Before\n\n- Item 1\n- Item 2\n\nAfter"); + + // Should have paragraphs for: Before, list, After (plus gaps) + let has_list = result + .iter() + .any(|p| p.html.contains("
  • ") || p.html.contains("
      ")); + assert!(has_list, "list should be present in rendered output"); +} + +#[test] +fn regression_bug9_code_blocks_as_paragraph_boundary() { + // Bug #9: Code blocks should be tracked as paragraph boundaries + let result = render_test("Before\n\n```\ncode\n```\n\nAfter"); + + let has_code = result + .iter() + .any(|p| p.html.contains("
      ") || p.html.contains(""));
      +    assert!(has_code, "code block should be present in rendered output");
      +}
      +
      +#[test]
      +fn regression_bug11_gap_paragraphs_for_whitespace() {
      +    // Bug #11: Gap paragraphs should be created for inter-block whitespace
      +    let result = render_test("# Title\n\nContent");
      +
      +    // Check that char ranges cover the full document without gaps
      +    let mut prev_end = 0;
      +    for para in &result {
      +        // Allow gaps to be filled by gap paragraphs
      +        if para.char_range.0 > prev_end {
      +            // This would be a gap - but gap paragraphs should fill it
      +            panic!(
      +                "Gap in char ranges: {}..{} missing coverage",
      +                prev_end, para.char_range.0
      +            );
      +        }
      +        prev_end = para.char_range.1;
      +    }
      +}
      +
      +// =============================================================================
      +// Char Range Coverage Tests
      +// =============================================================================
      +
      +#[test]
      +fn test_char_range_full_coverage() {
      +    // Verify that char ranges cover entire document
      +    let input = "Hello\n\nWorld";
      +    let rope = JumpRopeBuf::from(input);
      +    let paragraphs = render_paragraphs(&rope);
      +
      +    let doc_len = rope.len_chars();
      +
      +    // Collect all ranges
      +    let mut ranges: Vec<_> = paragraphs.iter().map(|p| p.char_range.clone()).collect();
      +    ranges.sort_by_key(|r| r.start);
      +
      +    // Check coverage
      +    let mut covered = 0;
      +    for range in &ranges {
      +        assert!(
      +            range.start <= covered,
      +            "Gap at position {}, next range starts at {}",
      +            covered,
      +            range.start
      +        );
      +        covered = covered.max(range.end);
      +    }
      +
      +    assert!(
      +        covered >= doc_len,
      +        "Ranges don't cover full document: covered {} of {}",
      +        covered,
      +        doc_len
      +    );
      +}
      +
      +#[test]
      +fn test_node_ids_unique_across_paragraphs() {
      +    // Verify HTML id attributes are unique across paragraphs
      +    let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item");
      +
      +    // Print rendered output for debugging failures
      +    for (i, para) in result.iter().enumerate() {
      +        eprintln!("--- Paragraph {} ---", i);
      +        eprintln!("char_range: {:?}", para.char_range);
      +        eprintln!("html: {}", para.html);
      +        eprintln!(
      +            "offset_map node_ids: {:?}",
      +            para.offset_map
      +                .iter()
      +                .map(|m| &m.node_id)
      +                .collect::>()
      +        );
      +    }
      +
      +    // Extract all id and data-node-id attributes from HTML
      +    let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap();
      +
      +    let mut all_html_ids = std::collections::HashSet::new();
      +    for (para_idx, para) in result.iter().enumerate() {
      +        for cap in id_regex.captures_iter(¶.html) {
      +            let id = cap.get(1).unwrap().as_str();
      +            assert!(
      +                all_html_ids.insert(id.to_string()),
      +                "Duplicate HTML id '{}' in paragraph {}",
      +                id,
      +                para_idx
      +            );
      +        }
      +    }
      +}
      +
      +#[test]
      +fn test_offset_mappings_reference_own_paragraph() {
      +    // Verify offset mappings only reference node IDs that exist in their paragraph's HTML
      +    let result = render_test("# Heading\n\nParagraph with **bold**\n\n- List item");
      +
      +    let id_regex = regex::Regex::new(r#"(?:id|data-node-id)="([^"]+)""#).unwrap();
      +
      +    for (para_idx, para) in result.iter().enumerate() {
      +        // Collect all node IDs in this paragraph's HTML
      +        let html_ids: std::collections::HashSet<_> = id_regex
      +            .captures_iter(¶.html)
      +            .map(|cap| cap.get(1).unwrap().as_str().to_string())
      +            .collect();
      +
      +        // Verify each offset mapping references a node in this paragraph
      +        for mapping in ¶.offset_map {
      +            assert!(
      +                html_ids.contains(&mapping.node_id),
      +                "Paragraph {} has offset mapping referencing '{}' but HTML only has {:?}\nHTML: {}",
      +                para_idx,
      +                mapping.node_id,
      +                html_ids,
      +                para.html
      +            );
      +        }
      +    }
      +}
      +
      +// =============================================================================
      +// Incremental Rendering Tests
      +// =============================================================================
      +
      +use super::render::render_paragraphs_incremental;
      +
      +#[test]
      +fn test_incremental_renders_same_as_full() {
      +    // Incremental render with no cache should produce same result as full render
      +    let input = "# Heading\n\nParagraph with **bold**\n\n- List item";
      +    let rope = JumpRopeBuf::from(input);
      +
      +    let full = render_paragraphs(&rope);
      +    let (incremental, _cache) = render_paragraphs_incremental(&rope, None, None);
      +
      +    // Compare HTML output (hashes may differ due to caching internals)
      +    assert_eq!(
      +        full.len(),
      +        incremental.len(),
      +        "Different paragraph count: full={}, incr={}",
      +        full.len(),
      +        incremental.len()
      +    );
      +
      +    for (i, (f, inc)) in full.iter().zip(incremental.iter()).enumerate() {
      +        assert_eq!(
      +            f.html, inc.html,
      +            "Paragraph {} HTML differs:\nFull: {}\nIncr: {}",
      +            i, f.html, inc.html
      +        );
      +        assert_eq!(
      +            f.byte_range, inc.byte_range,
      +            "Paragraph {} byte_range differs",
      +            i
      +        );
      +        assert_eq!(
      +            f.char_range, inc.char_range,
      +            "Paragraph {} char_range differs",
      +            i
      +        );
      +    }
      +}
      +
      +#[test]
      +fn test_incremental_cache_reuse() {
      +    // Verify cache is populated and can be reused
      +    let input = "First para\n\nSecond para";
      +    let rope = JumpRopeBuf::from(input);
      +
      +    let (paras1, cache1) = render_paragraphs_incremental(&rope, None, None);
      +    assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
      +
      +    // Second render with same content should reuse cache
      +    let (paras2, _cache2) = render_paragraphs_incremental(&rope, Some(&cache1), None);
      +
      +    // Should produce identical output
      +    assert_eq!(paras1.len(), paras2.len());
      +    for (p1, p2) in paras1.iter().zip(paras2.iter()) {
      +        assert_eq!(p1.html, p2.html);
      +    }
      +}
      diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs
      index f3c9fa9..8dcb0bd 100644
      --- a/crates/weaver-app/src/components/editor/writer.rs
      +++ b/crates/weaver-app/src/components/editor/writer.rs
      @@ -128,6 +128,10 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St
           paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range)
           current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset)
       
      +    /// When true, skip HTML generation and only track paragraph boundaries.
      +    /// Used for fast boundary discovery in incremental rendering.
      +    boundary_only: bool,
      +
           _phantom: std::marker::PhantomData<&'a ()>,
       }
       
      @@ -178,6 +182,47 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                   current_node_child_count: 0,
                   paragraph_ranges: Vec::new(),
                   current_paragraph_start: None,
      +            boundary_only: false,
      +            _phantom: std::marker::PhantomData,
      +        }
      +    }
      +
      +    /// Create a writer that only tracks paragraph boundaries without generating HTML.
      +    /// Used for fast boundary discovery in incremental rendering.
      +    pub fn new_boundary_only(
      +        source: &'a str,
      +        source_rope: &'a JumpRopeBuf,
      +        events: I,
      +        writer: W,
      +    ) -> Self {
      +        Self {
      +            source,
      +            source_rope,
      +            events,
      +            writer,
      +            last_byte_offset: 0,
      +            last_char_offset: 0,
      +            end_newline: true,
      +            in_non_writing_block: false,
      +            table_state: TableState::Head,
      +            table_alignments: vec![],
      +            table_cell_index: 0,
      +            numbers: HashMap::new(),
      +            embed_provider: None,
      +            code_buffer: None,
      +            code_buffer_byte_range: None,
      +            code_buffer_char_range: None,
      +            pending_blockquote_range: None,
      +            render_tables_as_markdown: true,
      +            table_start_offset: None,
      +            offset_maps: Vec::new(),
      +            next_node_id: 0,
      +            current_node_id: None,
      +            current_node_char_offset: 0,
      +            current_node_child_count: 0,
      +            paragraph_ranges: Vec::new(),
      +            current_paragraph_start: None,
      +            boundary_only: true,
                   _phantom: std::marker::PhantomData,
               }
           }
      @@ -211,22 +256,28 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                   current_node_child_count: self.current_node_child_count,
                   paragraph_ranges: self.paragraph_ranges,
                   current_paragraph_start: self.current_paragraph_start,
      +            boundary_only: self.boundary_only,
                   _phantom: std::marker::PhantomData,
               }
           }
           #[inline]
           fn write_newline(&mut self) -> Result<(), W::Error> {
               self.end_newline = true;
      +        if self.boundary_only {
      +            return Ok(());
      +        }
               self.writer.write_str("\n")
           }
       
           #[inline]
           fn write(&mut self, s: &str) -> Result<(), W::Error> {
      -        self.writer.write_str(s)?;
               if !s.is_empty() {
                   self.end_newline = s.ends_with('\n');
               }
      -        Ok(())
      +        if self.boundary_only {
      +            return Ok(());
      +        }
      +        self.writer.write_str(s)
           }
       
           /// Emit syntax span for a given range and record offset mapping
      @@ -234,14 +285,21 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
               if range.start < range.end {
                   let syntax = &self.source[range.clone()];
                   if !syntax.is_empty() {
      +                let char_start = self.last_char_offset;
      +                let syntax_char_len = syntax.chars().count();
      +
      +                // In boundary_only mode, just update offsets without HTML
      +                if self.boundary_only {
      +                    self.last_char_offset = char_start + syntax_char_len;
      +                    self.last_byte_offset = range.end;
      +                    return Ok(());
      +                }
      +
                       let class = match classify_syntax(syntax) {
                           SyntaxClass::Inline => "md-syntax-inline",
                           SyntaxClass::Block => "md-syntax-block",
                       };
       
      -                let char_start = self.last_char_offset;
      -                let syntax_char_len = syntax.chars().count();
      -
                       // If we're outside any node, create a wrapper span for tracking
                       let created_node = if self.current_node_id.is_none() {
                           let node_id = self.gen_node_id();
      @@ -281,6 +339,59 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
               Ok(())
           }
       
      +    /// Emit syntax span inside current node with full offset tracking.
      +    ///
      +    /// Use this for syntax markers that appear inside block elements (headings, lists,
      +    /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates
      +    /// wrapper nodes, this assumes we're already inside a tracked node.
      +    ///
      +    /// - Writes `{syntax}`
      +    /// - Records offset mapping (for cursor positioning)
      +    /// - Updates both `last_char_offset` and `last_byte_offset`
      +    fn emit_inner_syntax(
      +        &mut self,
      +        syntax: &str,
      +        byte_start: usize,
      +        class: SyntaxClass,
      +    ) -> Result<(), W::Error> {
      +        if syntax.is_empty() {
      +            return Ok(());
      +        }
      +
      +        let char_start = self.last_char_offset;
      +        let syntax_char_len = syntax.chars().count();
      +        let byte_end = byte_start + syntax.len();
      +
      +        // In boundary_only mode, just update offsets
      +        if self.boundary_only {
      +            self.last_char_offset = char_start + syntax_char_len;
      +            self.last_byte_offset = byte_end;
      +            return Ok(());
      +        }
      +
      +        let class_str = match class {
      +            SyntaxClass::Inline => "md-syntax-inline",
      +            SyntaxClass::Block => "md-syntax-block",
      +        };
      +
      +        self.write("")?;
      +        escape_html(&mut self.writer, syntax)?;
      +        self.write("")?;
      +
      +        // Record offset mapping for cursor positioning
      +        self.record_mapping(
      +            byte_start..byte_end,
      +            char_start..char_start + syntax_char_len,
      +        );
      +
      +        self.last_char_offset = char_start + syntax_char_len;
      +        self.last_byte_offset = byte_end;
      +
      +        Ok(())
      +    }
      +
           /// Emit any gap between last position and next offset
           fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), W::Error> {
               // Skip gap emission if we're inside a table being rendered as markdown
      @@ -835,7 +946,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                       // Emit > syntax if we're inside a blockquote
                       if let Some(bq_range) = self.pending_blockquote_range.take() {
                           if bq_range.start < bq_range.end {
      -                        let raw_text = &self.source[bq_range];
      +                        let raw_text = &self.source[bq_range.clone()];
                               if let Some(gt_pos) = raw_text.find('>') {
                                   // Extract > [!NOTE] or just >
                                   let after_gt = &raw_text[gt_pos + 1..];
      @@ -852,9 +963,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                                   };
       
                                   let syntax = &raw_text[gt_pos..syntax_end];
      -                            self.write("")?;
      -                            escape_html(&mut self.writer, syntax)?;
      -                            self.write(" ")?; // Add space after
      +                            let syntax_byte_start = bq_range.start + gt_pos;
      +                            self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?;
                               }
                           }
                       }
      @@ -938,27 +1048,11 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                           // Find where the # actually starts (might have leading whitespace)
                           if let Some(hash_pos) = raw_text.find(&pattern) {
                               // Extract "# " or "## " etc
      -                        let syntax_start = hash_pos;
                               let syntax_end = (hash_pos + count + 1).min(raw_text.len());
      -                        let syntax = &raw_text[syntax_start..syntax_end];
      -                        let syntax_char_len = syntax.chars().count();
      +                        let syntax = &raw_text[hash_pos..syntax_end];
      +                        let syntax_byte_start = range.start + hash_pos;
       
      -                        // Calculate byte range for this syntax in the source
      -                        let syntax_byte_start = range.start + syntax_start;
      -                        let syntax_byte_end = range.start + syntax_end;
      -                        let char_start = self.last_char_offset;
      -
      -                        self.write("")?;
      -                        escape_html(&mut self.writer, syntax)?;
      -                        self.write("")?;
      -
      -                        // Record offset mapping and update char tracking
      -                        // Note: last_byte_offset is managed by the main event loop
      -                        self.record_mapping(
      -                            syntax_byte_start..syntax_byte_end,
      -                            char_start..char_start + syntax_char_len
      -                        );
      -                        self.last_char_offset = char_start + syntax_char_len;
      +                        self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxClass::Block)?;
                           }
                       }
                       Ok(())
      @@ -1137,24 +1231,36 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon
                                       .map(|pos| pos + 1)
                                       .unwrap_or(1);
                                   let syntax = &trimmed[..marker_end.min(trimmed.len())];
      +                            let char_start = self.last_char_offset;
                                   let syntax_char_len = leading_ws_chars + syntax.chars().count();
                                   let syntax_byte_len = leading_ws_bytes + syntax.len();
                                   self.write("")?;
                                   escape_html(&mut self.writer, syntax)?;
                                   self.write("")?;
      -                            self.last_char_offset += syntax_char_len;
      +                            // Record offset mapping for cursor positioning
      +                            self.record_mapping(
      +                                range.start..range.start + syntax_byte_len,
      +                                char_start..char_start + syntax_char_len,
      +                            );
      +                            self.last_char_offset = char_start + syntax_char_len;
                                   self.last_byte_offset = range.start + syntax_byte_len;
                               } else if marker.is_ascii_digit() {
                                   // Ordered list: extract "1. " or similar
                                   if let Some(dot_pos) = trimmed.find('.') {
                                       let syntax_end = (dot_pos + 2).min(trimmed.len());
                                       let syntax = &trimmed[..syntax_end].trim_end();
      +                                let char_start = self.last_char_offset;
                                       let syntax_char_len = leading_ws_chars + syntax.chars().count();
                                       let syntax_byte_len = leading_ws_bytes + syntax.len();
                                       self.write("")?;
                                       escape_html(&mut self.writer, syntax)?;
                                       self.write("")?;
      -                                self.last_char_offset += syntax_char_len;
      +                                // Record offset mapping for cursor positioning
      +                                self.record_mapping(
      +                                    range.start..range.start + syntax_byte_len,
      +                                    char_start..char_start + syntax_char_len,
      +                                );
      +                                self.last_char_offset = char_start + syntax_char_len;
                                       self.last_byte_offset = range.start + syntax_byte_len;
                                   }
                               }