···119 char_offset: usize,
120) -> Option<(&OffsetMapping, bool)> {
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
0124 let idx = offset_map
125 .binary_search_by(|mapping| {
126- if mapping.char_range.end < char_offset {
127- // Cursor is after this mapping
128 std::cmp::Ordering::Less
129 } else if mapping.char_range.start > char_offset {
130 // Cursor is before this mapping
131 std::cmp::Ordering::Greater
132 } else {
133- // Cursor is within [start, end] OR exactly at end (inclusive)
134- // This handles cursor at position N matching range N-1..N
135 std::cmp::Ordering::Equal
136 }
137 })
···119 char_offset: usize,
120) -> Option<(&OffsetMapping, bool)> {
121 // Binary search for the mapping
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.
125 let idx = offset_map
126 .binary_search_by(|mapping| {
127+ if mapping.char_range.end <= char_offset {
128+ // Cursor is at or after end of this mapping - look forward
129 std::cmp::Ordering::Less
130 } else if mapping.char_range.start > char_offset {
131 // Cursor is before this mapping
132 std::cmp::Ordering::Greater
133 } else {
134+ // Cursor is within [start, end)
0135 std::cmp::Ordering::Equal
136 }
137 })
+368-46
crates/weaver-app/src/components/editor/render.rs
···4//!
5//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
607use super::offset_map::{OffsetMapping, RenderResult};
8use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
9use super::writer::EditorWriter;
10use jumprope::JumpRopeBuf;
11use markdown_weaver::Parser;
01213-/// 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();
3334- 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- }
47}
4849/// Render markdown in paragraph chunks for incremental DOM updates.
···98 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
99 let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
100101- for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
102 // Extract paragraph source
103 let para_source = rope_slice_to_string(rope, char_range.clone());
104 let source_hash = hash_source(¶_source);
···159 });
160 }
161162- // Insert gap paragraphs for whitespace between blocks
163- // This gives the cursor somewhere to land when positioned in newlines
0000164 let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
165 let mut prev_end_char = 0usize;
166 let mut prev_end_byte = 0usize;
167168 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;
00172 let gap_end_char = para.char_range.start;
173- let gap_start_byte = prev_end_byte;
174 let gap_end_byte = para.byte_range.start;
175176- let gap_node_id = format!("n{}", node_id_offset);
177- node_id_offset += 1;
178 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
179180 paragraphs_with_gaps.push(ParagraphRender {
···209210 // Only add if there's actually a gap at the end
211 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}');
0214215 paragraphs_with_gaps.push(ParagraphRender {
216 byte_range: prev_end_byte..doc_end_byte,
217 char_range: prev_end_char..doc_end_char,
218- html: empty_html,
219 offset_map: vec![OffsetMapping {
220 byte_range: prev_end_byte..doc_end_byte,
221 char_range: prev_end_char..doc_end_char,
222- node_id: empty_node_id,
223 char_offset_in_node: 0,
224 child_index: None,
225 utf16_len: 1, // zero-width space is 1 UTF-16 code unit
···231232 paragraphs_with_gaps
233}
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···4//!
5//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
67+use super::document::EditInfo;
8use super::offset_map::{OffsetMapping, RenderResult};
9use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
10use super::writer::EditorWriter;
11use jumprope::JumpRopeBuf;
12use markdown_weaver::Parser;
13+use std::ops::Range;
1415+/// 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+}
000000000002425+/// 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>,
38}
3940/// Render markdown in paragraph chunks for incremental DOM updates.
···89 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
90 let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
9192+ for (_idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
93 // Extract paragraph source
94 let para_source = rope_slice_to_string(rope, char_range.clone());
95 let source_hash = hash_source(¶_source);
···150 });
151 }
152153+ // 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+159 let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
160 let mut prev_end_char = 0usize;
161 let mut prev_end_byte = 0usize;
162163 for para in paragraphs {
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;
169 let gap_end_char = para.char_range.start;
170+ let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK;
171 let gap_end_byte = para.byte_range.start;
172173+ // Position-based ID: deterministic, stable across cache states
174+ let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
175 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
176177 paragraphs_with_gaps.push(ParagraphRender {
···206207 // Only add if there's actually a gap at the end
208 if doc_end_char > prev_end_char {
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}');
212213 paragraphs_with_gaps.push(ParagraphRender {
214 byte_range: prev_end_byte..doc_end_byte,
215 char_range: prev_end_char..doc_end_char,
216+ html: trailing_html,
217 offset_map: vec![OffsetMapping {
218 byte_range: prev_end_byte..doc_end_byte,
219 char_range: prev_end_char..doc_end_char,
220+ node_id: trailing_node_id,
221 char_offset_in_node: 0,
222 child_index: None,
223 utf16_len: 1, // zero-width space is 1 UTF-16 code unit
···229230 paragraphs_with_gaps
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(¶_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(¶_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+ ¶_source,
421+ ¶_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+}