···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
0000000000000133 } else {
134- // Cursor is within [start, end)
135- std::cmp::Ordering::Equal
136 }
137- })
138- .ok()?;
139140- let mapping = &offset_map[idx];
141 let should_snap = mapping.is_invisible();
142-143 Some((mapping, should_snap))
144}
145
···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 result = offset_map.binary_search_by(|mapping| {
126+ if mapping.char_range.end <= char_offset {
127+ // Cursor is at or after end of this mapping - look forward
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)
134+ std::cmp::Ordering::Equal
135+ }
136+ });
137+138+ let mapping = match result {
139+ Ok(idx) => &offset_map[idx],
140+ Err(idx) => {
141+ // No exact match - cursor is at boundary between mappings (or past end)
142+ // If cursor is exactly at end of previous mapping, return that mapping
143+ // This handles cursor at end of document or end of last mapping
144+ if idx > 0 && offset_map[idx - 1].char_range.end == char_offset {
145+ &offset_map[idx - 1]
146 } else {
147+ return None;
0148 }
149+ }
150+ };
1510152 let should_snap = mapping.is_invisible();
0153 Some((mapping, should_snap))
154}
155
···4//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
56use super::offset_map::OffsetMapping;
07use jumprope::JumpRopeBuf;
8use std::ops::Range;
9···2122 /// Offset mappings for this paragraph
23 pub offset_map: Vec<OffsetMapping>,
0002425 /// Hash of source text for quick change detection
26 pub source_hash: u64,
···4//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
56use super::offset_map::OffsetMapping;
7+use super::writer::SyntaxSpanInfo;
8use jumprope::JumpRopeBuf;
9use std::ops::Range;
10···2223 /// Offset mappings for this paragraph
24 pub offset_map: Vec<OffsetMapping>,
25+26+ /// Syntax spans for conditional visibility
27+ pub syntax_spans: Vec<SyntaxSpanInfo>,
2829 /// Hash of source text for quick change detection
30 pub source_hash: u64,
+97-230
crates/weaver-app/src/components/editor/render.rs
···7use super::document::EditInfo;
8use super::offset_map::{OffsetMapping, RenderResult};
9use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
10-use super::writer::EditorWriter;
11use jumprope::JumpRopeBuf;
12use markdown_weaver::Parser;
13use std::ops::Range;
···20 pub paragraphs: Vec<CachedParagraph>,
21 /// Next available node ID for fresh renders
22 pub next_node_id: usize,
0023}
2425/// A cached paragraph render that can be reused if source hasn't changed.
···35 pub html: String,
36 /// Offset mappings for cursor positioning
37 pub offset_map: Vec<OffsetMapping>,
38-}
39-40-/// Render markdown in paragraph chunks for incremental DOM updates.
41-///
42-/// First renders the whole document to discover paragraph boundaries via
43-/// markdown events (Tag::Paragraph), then re-renders each paragraph separately.
44-/// This allows updating only changed paragraphs in the DOM, preserving cursor
45-/// position naturally.
46-///
47-/// # Returns
48-///
49-/// A vector of `ParagraphRender` structs, each containing:
50-/// - Source byte and char ranges
51-/// - Rendered HTML (without wrapper div)
52-/// - Offset mappings for that paragraph
53-/// - Source hash for change detection
54-///
55-/// # Phase 2 Benefits
56-/// - Only re-render changed paragraphs
57-/// - Browser preserves cursor in unchanged paragraphs naturally
58-/// - Faster for large documents
59-/// - No manual cursor restoration needed for most edits
60-pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec<ParagraphRender> {
61- let source = rope.to_string();
62-63- // Handle empty rope - return single empty paragraph for cursor positioning
64- if source.is_empty() {
65- let empty_node_id = "n0".to_string();
66- let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
67-68- return vec![ParagraphRender {
69- byte_range: 0..0,
70- char_range: 0..0,
71- html: empty_html,
72- offset_map: vec![],
73- source_hash: 0,
74- }];
75- }
76-77- // First pass: render whole document to get paragraph boundaries
78- // TODO: CACHE THIS!
79- let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
80- let mut scratch_output = String::new();
81-82- let paragraph_ranges =
83- match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() {
84- Ok(result) => result.paragraph_ranges,
85- Err(_) => return Vec::new(),
86- };
87-88- // Second pass: render each paragraph separately
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
91-92- 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);
96-97- // Render this paragraph with unique node IDs
98- let para_rope = JumpRopeBuf::from(para_source.as_str());
99- let parser =
100- Parser::new_ext(¶_source, weaver_renderer::default_md_options()).into_offset_iter();
101- let mut output = String::new();
102-103- let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset(
104- ¶_source,
105- ¶_rope,
106- parser,
107- &mut output,
108- node_id_offset,
109- )
110- .run()
111- {
112- Ok(result) => {
113- // Update node ID offset for next paragraph
114- // Count how many unique node IDs were used in this paragraph
115- let max_node_id = result
116- .offset_maps
117- .iter()
118- .filter_map(|m| {
119- m.node_id
120- .strip_prefix("n")
121- .and_then(|s| s.parse::<usize>().ok())
122- })
123- .max()
124- .unwrap_or(node_id_offset);
125- node_id_offset = max_node_id + 1;
126-127- result.offset_maps
128- }
129- Err(_) => Vec::new(),
130- };
131-132- // Adjust offset map to be relative to document, not paragraph
133- // Each mapping's ranges need to be shifted by paragraph start
134- let para_char_start = char_range.start;
135- let para_byte_start = byte_range.start;
136-137- for mapping in &mut offset_map {
138- mapping.byte_range.start += para_byte_start;
139- mapping.byte_range.end += para_byte_start;
140- mapping.char_range.start += para_char_start;
141- mapping.char_range.end += para_char_start;
142- }
143-144- paragraphs.push(ParagraphRender {
145- byte_range: byte_range.clone(),
146- char_range: char_range.clone(),
147- html: output,
148- offset_map,
149- source_hash,
150- });
151- }
152-153- // Insert gap paragraphs for EXTRA whitespace between blocks.
154- // Standard paragraph break is 2 newlines (\n\n) - no gap needed for that.
155- // Gaps are only for whitespace BEYOND the minimum, giving cursor a landing spot.
156- // Gap IDs are position-based for stability across renders.
157- const MIN_PARAGRAPH_BREAK: usize = 2; // \n\n
158-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;
162-163- 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;
172-173- // 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}');
176-177- paragraphs_with_gaps.push(ParagraphRender {
178- byte_range: gap_start_byte..gap_end_byte,
179- char_range: gap_start_char..gap_end_char,
180- html: gap_html,
181- offset_map: vec![OffsetMapping {
182- byte_range: gap_start_byte..gap_end_byte,
183- char_range: gap_start_char..gap_end_char,
184- node_id: gap_node_id,
185- char_offset_in_node: 0,
186- child_index: None,
187- utf16_len: 1, // zero-width space represents the gap
188- }],
189- source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
190- });
191- }
192-193- prev_end_char = para.char_range.end;
194- prev_end_byte = para.byte_range.end;
195- paragraphs_with_gaps.push(para);
196- }
197-198- // Check if rope ends with trailing newlines (empty paragraph at end)
199- // If so, add an empty paragraph div for cursor positioning
200- let source = rope.to_string();
201- let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
202-203- if has_trailing_newlines {
204- let doc_end_char = rope.len_chars();
205- let doc_end_byte = rope.len_bytes();
206-207- // 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}');
212-213- 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
224- }],
225- source_hash: 0, // always render this paragraph
226- });
227- }
228- }
229-230- paragraphs_with_gaps
231}
232233/// Check if an edit affects paragraph boundaries.
···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;
000000264 }
265266 ParagraphRender {
···270 ..(cached.char_range.end as isize + char_delta) as usize,
271 html: cached.html.clone(),
272 offset_map: adjusted_map,
0273 source_hash: cached.source_hash,
274 }
275}
···303 char_range: 0..0,
304 html: empty_html.clone(),
305 offset_map: vec![],
0306 source_hash: 0,
307 };
308···313 char_range: 0..0,
314 html: empty_html,
315 offset_map: vec![],
0316 }],
317 next_node_id: 1,
0318 };
319320 return (vec![para], new_cache);
···380 }
381 };
38200000383 // 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);
0387388- 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);
3910000000392 // 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));
395396- 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···408 mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
409 }
410411- (cached.html.clone(), adjusted_map)
000000412 } else {
413 // Fresh render needed
414 let para_rope = JumpRopeBuf::from(para_source.as_str());
···416 .into_offset_iter();
417 let mut output = String::new();
418419- 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- };
0000000000000000445446 // Adjust offsets to document coordinates
447 let para_char_start = char_range.start;
···452 mapping.char_range.start += para_char_start;
453 mapping.char_range.end += para_char_start;
454 }
0000455456- (output, offset_map)
457 };
458459 // Store in cache
···463 char_range: char_range.clone(),
464 html: html.clone(),
465 offset_map: offset_map.clone(),
0466 });
467468 paragraphs.push(ParagraphRender {
···470 char_range: char_range.clone(),
471 html,
472 offset_map,
0473 source_hash,
474 });
475 }
···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;
···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}');
49900500 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 }],
0512 source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
513 });
514 }
···541 child_index: None,
542 utf16_len: 1,
543 }],
0544 source_hash: 0,
545 });
546 }
···549 let new_cache = RenderCache {
550 paragraphs: new_cached,
551 next_node_id: node_id_offset,
0552 };
553554 (paragraphs_with_gaps, new_cache)
···7use super::document::EditInfo;
8use super::offset_map::{OffsetMapping, RenderResult};
9use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
10+use super::writer::{EditorWriter, SyntaxSpanInfo};
11use jumprope::JumpRopeBuf;
12use markdown_weaver::Parser;
13use std::ops::Range;
···20 pub paragraphs: Vec<CachedParagraph>,
21 /// Next available node ID for fresh renders
22 pub next_node_id: usize,
23+ /// Next available syntax span ID for fresh renders
24+ pub next_syn_id: usize,
25}
2627/// A cached paragraph render that can be reused if source hasn't changed.
···37 pub html: String,
38 /// Offset mappings for cursor positioning
39 pub offset_map: Vec<OffsetMapping>,
40+ /// Syntax spans for conditional visibility
41+ pub syntax_spans: Vec<SyntaxSpanInfo>,
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042}
4344/// Check if an edit affects paragraph boundaries.
···72 mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize;
73 mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize;
74 mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
75+ }
76+77+ let mut adjusted_syntax = cached.syntax_spans.clone();
78+ for span in &mut adjusted_syntax {
79+ span.char_range.start = (span.char_range.start as isize + char_delta) as usize;
80+ span.char_range.end = (span.char_range.end as isize + char_delta) as usize;
81 }
8283 ParagraphRender {
···87 ..(cached.char_range.end as isize + char_delta) as usize,
88 html: cached.html.clone(),
89 offset_map: adjusted_map,
90+ syntax_spans: adjusted_syntax,
91 source_hash: cached.source_hash,
92 }
93}
···121 char_range: 0..0,
122 html: empty_html.clone(),
123 offset_map: vec![],
124+ syntax_spans: vec![],
125 source_hash: 0,
126 };
127···132 char_range: 0..0,
133 html: empty_html,
134 offset_map: vec![],
135+ syntax_spans: vec![],
136 }],
137 next_node_id: 1,
138+ next_syn_id: 0,
139 };
140141 return (vec![para], new_cache);
···201 }
202 };
203204+ tracing::debug!("[RENDER] Discovered {} paragraph ranges", paragraph_ranges.len());
205+ for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
206+ tracing::debug!("[RENDER] Range {}: bytes {:?}, chars {:?}", i, byte_range, char_range);
207+ }
208+209 // Render paragraphs, reusing cache where possible
210 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
211 let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
212 let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0);
213+ let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0);
214215+ for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
216 let para_source = rope_slice_to_string(rope, char_range.clone());
217 let source_hash = hash_source(¶_source);
218219+ tracing::debug!(
220+ "[RENDER] Para {}: char_range {:?}, source preview: {:?}",
221+ idx,
222+ char_range,
223+ ¶_source[..para_source.len().min(50)]
224+ );
225+226 // Check if we have a cached render with matching hash
227 let cached_match =
228 cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash));
229230+ let (html, offset_map, syntax_spans) = if let Some(cached) = cached_match {
231+ // Reuse cached HTML, offset map, and syntax spans (adjusted for position)
232 let char_delta = char_range.start as isize - cached.char_range.start as isize;
233 let byte_delta = byte_range.start as isize - cached.byte_range.start as isize;
234···242 mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
243 }
244245+ let mut adjusted_syntax = cached.syntax_spans.clone();
246+ for span in &mut adjusted_syntax {
247+ span.char_range.start = (span.char_range.start as isize + char_delta) as usize;
248+ span.char_range.end = (span.char_range.end as isize + char_delta) as usize;
249+ }
250+251+ (cached.html.clone(), adjusted_map, adjusted_syntax)
252 } else {
253 // Fresh render needed
254 let para_rope = JumpRopeBuf::from(para_source.as_str());
···256 .into_offset_iter();
257 let mut output = String::new();
258259+ let (mut offset_map, mut syntax_spans) =
260+ match EditorWriter::<_, _, ()>::new_with_offsets(
261+ ¶_source,
262+ ¶_rope,
263+ parser,
264+ &mut output,
265+ node_id_offset,
266+ syn_id_offset,
267+ )
268+ .run()
269+ {
270+ Ok(result) => {
271+ // Update node ID offset
272+ let max_node_id = result
273+ .offset_maps
274+ .iter()
275+ .filter_map(|m| {
276+ m.node_id
277+ .strip_prefix("n")
278+ .and_then(|s| s.parse::<usize>().ok())
279+ })
280+ .max()
281+ .unwrap_or(node_id_offset);
282+ node_id_offset = max_node_id + 1;
283+284+ // Update syn ID offset
285+ let max_syn_id = result
286+ .syntax_spans
287+ .iter()
288+ .filter_map(|s| {
289+ s.syn_id
290+ .strip_prefix("s")
291+ .and_then(|id| id.parse::<usize>().ok())
292+ })
293+ .max()
294+ .unwrap_or(syn_id_offset.saturating_sub(1));
295+ syn_id_offset = max_syn_id + 1;
296+297+ (result.offset_maps, result.syntax_spans)
298+ }
299+ Err(_) => (Vec::new(), Vec::new()),
300+ };
301302 // Adjust offsets to document coordinates
303 let para_char_start = char_range.start;
···308 mapping.char_range.start += para_char_start;
309 mapping.char_range.end += para_char_start;
310 }
311+ for span in &mut syntax_spans {
312+ span.char_range.start += para_char_start;
313+ span.char_range.end += para_char_start;
314+ }
315316+ (output, offset_map, syntax_spans)
317 };
318319 // Store in cache
···323 char_range: char_range.clone(),
324 html: html.clone(),
325 offset_map: offset_map.clone(),
326+ syntax_spans: syntax_spans.clone(),
327 });
328329 paragraphs.push(ParagraphRender {
···331 char_range: char_range.clone(),
332 html,
333 offset_map,
334+ syntax_spans,
335 source_hash,
336 });
337 }
···349 // Check for gap before this paragraph - only if MORE than minimum break
350 let gap_size = para.char_range.start.saturating_sub(prev_end_char);
351 if gap_size > MIN_PARAGRAPH_BREAK_INCR {
352+ // Visible gap element covers EXTRA whitespace beyond minimum break
353 let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR;
354 let gap_end_char = para.char_range.start;
355 let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR;
···359 let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char);
360 let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
361362+ // Gap paragraph covers ALL whitespace (like trailing gaps do)
363+ // so cursor anywhere in the inter-paragraph zone triggers restoration
364 paragraphs_with_gaps.push(ParagraphRender {
365+ byte_range: prev_end_byte..gap_end_byte,
366+ char_range: prev_end_char..gap_end_char,
367 html: gap_html,
368 offset_map: vec![OffsetMapping {
369+ byte_range: prev_end_byte..gap_end_byte,
370+ char_range: prev_end_char..gap_end_char,
371 node_id: gap_node_id,
372 char_offset_in_node: 0,
373 child_index: None,
374 utf16_len: 1,
375 }],
376+ syntax_spans: vec![],
377 source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
378 });
379 }
···406 child_index: None,
407 utf16_len: 1,
408 }],
409+ syntax_spans: vec![],
410 source_hash: 0,
411 });
412 }
···415 let new_cache = RenderCache {
416 paragraphs: new_cached,
417 next_node_id: node_id_offset,
418+ next_syn_id: syn_id_offset,
419 };
420421 (paragraphs_with_gaps, new_cache)