···56use super::offset_map::OffsetMapping;
7use super::writer::SyntaxSpanInfo;
8-use jumprope::JumpRopeBuf;
9use std::ops::Range;
1011/// A rendered paragraph with its source range and offset mappings.
···40 hasher.finish()
41}
4243-/// Extract substring from rope as String
44-pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range<usize>) -> String {
45- let rope_borrow = rope.borrow();
46- let mut result = String::new();
47-48- for substr in rope_borrow.slice_substrings(range) {
49- result.push_str(substr);
50- }
51-52- result
53}
54
···56use super::offset_map::OffsetMapping;
7use super::writer::SyntaxSpanInfo;
8+use loro::LoroText;
9use std::ops::Range;
1011/// A rendered paragraph with its source range and offset mappings.
···40 hasher.finish()
41}
4243+/// Extract substring from LoroText as String
44+pub fn text_slice_to_string(text: &LoroText, range: Range<usize>) -> String {
45+ text.slice(range.start, range.end).unwrap_or_default()
000000046}
47
+15-12
crates/weaver-app/src/components/editor/render.rs
···67use super::document::EditInfo;
8use super::offset_map::{OffsetMapping, RenderResult};
9-use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
10use super::writer::{EditorWriter, SyntaxSpanInfo};
11-use jumprope::JumpRopeBuf;
12use markdown_weaver::Parser;
13use std::ops::Range;
14···105/// # Returns
106/// Tuple of (rendered paragraphs, updated cache)
107pub fn render_paragraphs_incremental(
108- rope: &JumpRopeBuf,
109 cache: Option<&RenderCache>,
110 edit: Option<&EditInfo>,
111) -> (Vec<ParagraphRender>, RenderCache) {
112- let source = rope.to_string();
113114 // Handle empty document
115 if source.is_empty() {
···190191 match EditorWriter::<_, _, ()>::new_boundary_only(
192 &source,
193- rope,
194 parser,
195 &mut scratch_output,
196 )
···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!(
···250251 (cached.html.clone(), adjusted_map, adjusted_syntax)
252 } else {
253- // Fresh render needed
254- let para_rope = JumpRopeBuf::from(para_source.as_str());
000255 let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options())
256 .into_offset_iter();
257 let mut output = String::new();
···259 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,
···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 }
380···386 // Add trailing gap if needed
387 let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
388 if has_trailing_newlines {
389- let doc_end_char = rope.len_chars();
390- let doc_end_byte = rope.len_bytes();
391392 if doc_end_char > prev_end_char {
393 // Position-based ID for trailing gap
···67use super::document::EditInfo;
8use super::offset_map::{OffsetMapping, RenderResult};
9+use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string};
10use super::writer::{EditorWriter, SyntaxSpanInfo};
11+use loro::LoroText;
12use markdown_weaver::Parser;
13use std::ops::Range;
14···105/// # Returns
106/// Tuple of (rendered paragraphs, updated cache)
107pub fn render_paragraphs_incremental(
108+ text: &LoroText,
109 cache: Option<&RenderCache>,
110 edit: Option<&EditInfo>,
111) -> (Vec<ParagraphRender>, RenderCache) {
112+ let source = text.to_string();
113114 // Handle empty document
115 if source.is_empty() {
···190191 match EditorWriter::<_, _, ()>::new_boundary_only(
192 &source,
193+ text,
194 parser,
195 &mut scratch_output,
196 )
···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 = text_slice_to_string(text, char_range.clone());
217 let source_hash = hash_source(¶_source);
218219 tracing::debug!(
···250251 (cached.html.clone(), adjusted_map, adjusted_syntax)
252 } else {
253+ // Fresh render needed - create detached LoroDoc for this paragraph
254+ let para_doc = loro::LoroDoc::new();
255+ let para_text = para_doc.get_text("content");
256+ let _ = para_text.insert(0, ¶_source);
257+258 let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options())
259 .into_offset_iter();
260 let mut output = String::new();
···262 let (mut offset_map, mut syntax_spans) =
263 match EditorWriter::<_, _, ()>::new_with_offsets(
264 ¶_source,
265+ ¶_text,
266 parser,
267 &mut output,
268 node_id_offset,
···377 utf16_len: 1,
378 }],
379 syntax_spans: vec![],
380+ source_hash: hash_source(&text_slice_to_string(text, gap_start_char..gap_end_char)),
381 });
382 }
383···389 // Add trailing gap if needed
390 let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
391 if has_trailing_newlines {
392+ let doc_end_char = text.len_unicode();
393+ let doc_end_byte = text.len_utf8();
394395 if doc_end_char > prev_end_char {
396 // Position-based ID for trailing gap
···3use super::offset_map::{OffsetMapping, find_mapping_for_char};
4use super::paragraph::ParagraphRender;
5use super::render::render_paragraphs_incremental;
6-use jumprope::JumpRopeBuf;
7use serde::Serialize;
89/// Serializable version of ParagraphRender for snapshot testing.
···5455/// Helper: render markdown and convert to serializable test output.
56fn render_test(input: &str) -> Vec<TestParagraph> {
57- let rope = JumpRopeBuf::from(input);
58- let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
0059 paragraphs.iter().map(TestParagraph::from).collect()
60}
61···434 // cursor snaps to adjacent paragraphs for standard breaks.
435 // Only EXTRA whitespace beyond \n\n gets gap elements.
436 let input = "Hello\n\nWorld";
437- let rope = JumpRopeBuf::from(input);
438- let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
00439440 // With standard \n\n break, we expect 2 paragraphs (no gap element)
441 // Paragraph ranges include some trailing whitespace from markdown parsing
···453 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
454 // Plain paragraphs don't consume trailing newlines like headings do
455 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
456- let rope = JumpRopeBuf::from(input);
457- let (paragraphs, _cache) = render_paragraphs_incremental(&rope, None, None);
00458459 // With extra newlines, we expect 3 elements: para, gap, para
460 assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace");
···542fn test_incremental_cache_reuse() {
543 // Verify cache is populated and can be reused
544 let input = "First para\n\nSecond para";
545- let rope = JumpRopeBuf::from(input);
00546547- let (paras1, cache1) = render_paragraphs_incremental(&rope, None, None);
548 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
549550 // Second render with same content should reuse cache
551- let (paras2, _cache2) = render_paragraphs_incremental(&rope, Some(&cache1), None);
552553 // Should produce identical output
554 assert_eq!(paras1.len(), paras2.len());
···3use super::offset_map::{OffsetMapping, find_mapping_for_char};
4use super::paragraph::ParagraphRender;
5use super::render::render_paragraphs_incremental;
6+use loro::LoroDoc;
7use serde::Serialize;
89/// Serializable version of ParagraphRender for snapshot testing.
···5455/// Helper: render markdown and convert to serializable test output.
56fn render_test(input: &str) -> Vec<TestParagraph> {
57+ let doc = LoroDoc::new();
58+ let text = doc.get_text("content");
59+ text.insert(0, input).unwrap();
60+ let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
61 paragraphs.iter().map(TestParagraph::from).collect()
62}
63···436 // cursor snaps to adjacent paragraphs for standard breaks.
437 // Only EXTRA whitespace beyond \n\n gets gap elements.
438 let input = "Hello\n\nWorld";
439+ let doc = LoroDoc::new();
440+ let text = doc.get_text("content");
441+ text.insert(0, input).unwrap();
442+ let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
443444 // With standard \n\n break, we expect 2 paragraphs (no gap element)
445 // Paragraph ranges include some trailing whitespace from markdown parsing
···457 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
458 // Plain paragraphs don't consume trailing newlines like headings do
459 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
460+ let doc = LoroDoc::new();
461+ let text = doc.get_text("content");
462+ text.insert(0, input).unwrap();
463+ let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
464465 // With extra newlines, we expect 3 elements: para, gap, para
466 assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace");
···548fn test_incremental_cache_reuse() {
549 // Verify cache is populated and can be reused
550 let input = "First para\n\nSecond para";
551+ let doc = LoroDoc::new();
552+ let text = doc.get_text("content");
553+ text.insert(0, input).unwrap();
554555+ let (paras1, cache1) = render_paragraphs_incremental(&text, None, None);
556 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
557558 // Second render with same content should reuse cache
559+ let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None);
560561 // Should produce identical output
562 assert_eq!(paras1.len(), paras2.len());
···33 let mut visible = HashSet::new();
3435 for span in syntax_spans {
00036 let should_show = match span.syntax_type {
37 SyntaxType::Inline => {
38 // Show if cursor within formatted span content OR adjacent to markers
39- // "Adjacent" means within 1 char of the syntax boundaries
40- let extended_range = span.char_range.start.saturating_sub(1)
41- ..span.char_range.end.saturating_add(1);
000004243 // Also show if cursor is anywhere in the formatted_range
44 // (the region between paired opening/closing markers)
0045 let in_formatted_region = span
46 .formatted_range
47 .as_ref()
48- .map(|r| r.contains(&cursor_offset))
000049 .unwrap_or(false);
5051- extended_range.contains(&cursor_offset)
052 || in_formatted_region
53 || selection_overlaps(selection, &span.char_range)
54 || span
55 .formatted_range
56 .as_ref()
57 .map(|r| selection_overlaps(selection, r))
58- .unwrap_or(false)
000000000000059 }
60 SyntaxType::Block => {
61 // Show if cursor anywhere in same paragraph
···116 false
117}
118000000000000000000000000000000000000119#[cfg(test)]
120mod tests {
121 use super::*;
···197198 #[test]
199 fn test_inline_visibility_cursor_adjacent() {
00200 let spans = vec![
201 make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
202 ];
203- let paras = vec![make_para(0, 20, spans.clone())];
204205 // Cursor at position 4 (one before ** which starts at 5)
206 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
···216 let spans = vec![
217 make_span("s0", 10, 12, SyntaxType::Inline),
218 ];
219- let paras = vec![make_para(0, 30, spans.clone())];
220221 // Cursor at position 0 (far from **)
222 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
···259 let spans = vec![
260 make_span("s0", 5, 7, SyntaxType::Inline),
261 ];
262- let paras = vec![make_para(0, 20, spans.clone())];
263264 // Selection overlaps the syntax span
265 let selection = Selection { anchor: 3, head: 10 };
266 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
267 assert!(vis.is_visible("s0"), "** should be visible when selection overlaps");
000000000000000000000000000000000268 }
269}
···33 let mut visible = HashSet::new();
3435 for span in syntax_spans {
36+ // Find the paragraph containing this span for boundary clamping
37+ let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
38+39 let should_show = match span.syntax_type {
40 SyntaxType::Inline => {
41 // Show if cursor within formatted span content OR adjacent to markers
42+ // "Adjacent" means within 1 char of the syntax boundaries,
43+ // clamped to paragraph bounds (paragraphs are split by newlines,
44+ // so clamping to para bounds prevents cross-line extension)
45+ let extended_start =
46+ safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
47+ let extended_end =
48+ safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
49+ let extended_range = extended_start..extended_end;
5051 // Also show if cursor is anywhere in the formatted_range
52 // (the region between paired opening/closing markers)
53+ // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
54+ // clamped to paragraph bounds.
55 let in_formatted_region = span
56 .formatted_range
57 .as_ref()
58+ .map(|r| {
59+ let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
60+ let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
61+ cursor_offset >= ext_start && cursor_offset <= ext_end
62+ })
63 .unwrap_or(false);
6465+ let in_extended = extended_range.contains(&cursor_offset);
66+ let result = in_extended
67 || in_formatted_region
68 || selection_overlaps(selection, &span.char_range)
69 || span
70 .formatted_range
71 .as_ref()
72 .map(|r| selection_overlaps(selection, r))
73+ .unwrap_or(false);
74+75+ tracing::debug!(
76+ "[VISIBILITY] span {} char_range {:?} formatted_range {:?} cursor {} -> in_extended={} in_formatted={} visible={}",
77+ span.syn_id,
78+ span.char_range,
79+ span.formatted_range,
80+ cursor_offset,
81+ in_extended,
82+ in_formatted_region,
83+ result
84+ );
85+86+ result
87 }
88 SyntaxType::Block => {
89 // Show if cursor anywhere in same paragraph
···144 false
145}
146147+/// Find the paragraph bounds containing a syntax span.
148+fn find_paragraph_bounds(
149+ syntax_range: &Range<usize>,
150+ paragraphs: &[ParagraphRender],
151+) -> Option<Range<usize>> {
152+ for para in paragraphs {
153+ // Skip gap paragraphs
154+ if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
155+ continue;
156+ }
157+158+ if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
159+ return Some(para.char_range.clone());
160+ }
161+ }
162+ None
163+}
164+165+/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
166+///
167+/// Paragraphs are already split by newlines, so clamping to paragraph bounds
168+/// naturally prevents extending across line boundaries.
169+fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
170+ let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
171+ pos.saturating_sub(amount).max(min_pos)
172+}
173+174+/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
175+///
176+/// Paragraphs are already split by newlines, so clamping to paragraph bounds
177+/// naturally prevents extending across line boundaries.
178+fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
179+ let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
180+ pos.saturating_add(amount).min(max_pos)
181+}
182+183#[cfg(test)]
184mod tests {
185 use super::*;
···261262 #[test]
263 fn test_inline_visibility_cursor_adjacent() {
264+ // "test **bold** after"
265+ // 5 7
266 let spans = vec![
267 make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
268 ];
269+ let paras = vec![make_para(0, 19, spans.clone())];
270271 // Cursor at position 4 (one before ** which starts at 5)
272 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
···282 let spans = vec![
283 make_span("s0", 10, 12, SyntaxType::Inline),
284 ];
285+ let paras = vec![make_para(0, 33, spans.clone())];
286287 // Cursor at position 0 (far from **)
288 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
···325 let spans = vec![
326 make_span("s0", 5, 7, SyntaxType::Inline),
327 ];
328+ let paras = vec![make_para(0, 24, spans.clone())];
329330 // Selection overlaps the syntax span
331 let selection = Selection { anchor: 3, head: 10 };
332 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
333 assert!(vis.is_visible("s0"), "** should be visible when selection overlaps");
334+ }
335+336+ #[test]
337+ fn test_paragraph_boundary_blocks_extension() {
338+ // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
339+ // even if cursor is only 1 char after the paragraph boundary
340+ // (paragraph bounds clamp the extension)
341+ let spans = vec![
342+ make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
343+ make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
344+ ];
345+ let paras = vec![
346+ make_para(0, 8, spans.clone()), // "**bold**"
347+ make_para(9, 13, vec![]), // "text" (after newline)
348+ ];
349+350+ // Cursor at position 9 (start of second paragraph)
351+ // Should NOT reveal the closing ** because para bounds clamp extension
352+ let vis = VisibilityState::calculate(9, None, &spans, ¶s);
353+ assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph");
354+ }
355+356+ #[test]
357+ fn test_extension_clamps_to_paragraph() {
358+ // Syntax at very start of paragraph - extension left should stop at para start
359+ let spans = vec![
360+ make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8),
361+ ];
362+ let paras = vec![make_para(0, 8, spans.clone())];
363+364+ // Cursor at position 0 - should still see the opening **
365+ let vis = VisibilityState::calculate(0, None, &spans, ¶s);
366+ assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0");
367 }
368}
+95-26
crates/weaver-app/src/components/editor/writer.rs
···7//! represent consumed formatting characters.
89use super::offset_map::{OffsetMapping, RenderResult};
10-use jumprope::JumpRopeBuf;
11use markdown_weaver::{
12 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
13};
···109/// and emits them as styled spans for visibility in the editor.
110pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> {
111 source: &'a str,
112- source_rope: &'a JumpRopeBuf,
113 events: I,
114 writer: W,
115 last_byte_offset: usize,
···141 current_node_id: Option<String>, // node ID for current text container
142 current_node_char_offset: usize, // UTF-16 offset within current node
143 current_node_child_count: usize, // number of child elements/text nodes in current container
00000144145 // Paragraph boundary tracking for incremental rendering
146 paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range)
···170impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider>
171 EditorWriter<'a, I, W, E>
172{
173- pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self {
174- Self::new_with_node_offset(source, source_rope, events, writer, 0)
175 }
176177 pub fn new_with_node_offset(
178 source: &'a str,
179- source_rope: &'a JumpRopeBuf,
180 events: I,
181 writer: W,
182 node_id_offset: usize,
183 ) -> Self {
184- Self::new_with_offsets(source, source_rope, events, writer, node_id_offset, 0)
185 }
186187 pub fn new_with_offsets(
188 source: &'a str,
189- source_rope: &'a JumpRopeBuf,
190 events: I,
191 writer: W,
192 node_id_offset: usize,
···194 ) -> Self {
195 Self {
196 source,
197- source_rope,
198 events,
199 writer,
200 last_byte_offset: 0,
···217 current_node_id: None,
218 current_node_char_offset: 0,
219 current_node_child_count: 0,
0220 paragraph_ranges: Vec::new(),
221 current_paragraph_start: None,
222 list_depth: 0,
···232 /// Used for fast boundary discovery in incremental rendering.
233 pub fn new_boundary_only(
234 source: &'a str,
235- source_rope: &'a JumpRopeBuf,
236 events: I,
237 writer: W,
238 ) -> Self {
239 Self {
240 source,
241- source_rope,
242 events,
243 writer,
244 last_byte_offset: 0,
···261 current_node_id: None,
262 current_node_char_offset: 0,
263 current_node_child_count: 0,
0264 syntax_spans: Vec::new(),
265 next_syn_id: 0,
266 pending_inline_formats: Vec::new(),
···276 pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> {
277 EditorWriter {
278 source: self.source,
279- source_rope: self.source_rope,
280 events: self.events,
281 writer: self.writer,
282 last_byte_offset: self.last_byte_offset,
···299 current_node_id: self.current_node_id,
300 current_node_char_offset: self.current_node_char_offset,
301 current_node_child_count: self.current_node_child_count,
0302 paragraph_ranges: self.paragraph_ranges,
303 current_paragraph_start: self.current_paragraph_start,
304 list_depth: self.list_depth,
···343 let format_end = self.last_char_offset;
344 let formatted_range = format_start..format_end;
345000000346 // Update the opening span's formatted_range
347 if let Some(opening_span) = self
348 .syntax_spans
···350 .find(|s| s.syn_id == opening_syn_id)
351 {
352 opening_span.formatted_range = Some(formatted_range.clone());
000353 }
354355 // Update the closing span's formatted_range (the most recent one)
···358 // Only update if it's an inline span (closing syntax should be inline)
359 if closing_span.syntax_type == SyntaxType::Inline {
360 closing_span.formatted_range = Some(formatted_range);
0361 }
362 }
363 }
···544 self.current_node_child_count = 0;
545 }
546000000000000000547 /// Record an offset mapping for the given byte and char ranges.
548 ///
549- /// Computes UTF-16 length efficiently using the rope's internal indexing.
550 fn record_mapping(&mut self, byte_range: Range<usize>, char_range: Range<usize>) {
551 if let Some(ref node_id) = self.current_node_id {
552- // Use rope to convert char offsets to UTF-16 (wchar) offsets - O(log n)
553- let rope = self.source_rope.borrow();
554- let wchar_start = rope.chars_to_wchars(char_range.start);
555- let wchar_end = rope.chars_to_wchars(char_range.end);
556- let utf16_len = wchar_end - wchar_start;
0000000557558 let mapping = OffsetMapping {
559 byte_range: byte_range.clone(),
···601602 // For End events, emit any trailing content within the event's range
603 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
604- if matches!(&event, Event::End(_)) {
00000000000605 // Emit gap from last_byte_offset to range.end
606 // (emit_syntax handles char offset tracking)
607 self.emit_gap_before(range.end)?;
608- } else {
609 // For other events, emit any gap before range.start
610 // (emit_syntax handles char offset tracking)
611 self.emit_gap_before(range.start)?;
612 }
0613614 // Store last_byte before processing
615 let last_byte_before = self.last_byte_offset;
···632 // Handle unmapped trailing content (stripped by parser)
633 // This includes trailing spaces that markdown ignores
634 let doc_byte_len = self.source.len();
635- let doc_char_len = self.source_rope.len_chars();
636637 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
638 // Emit the trailing content as visible syntax
···1173 syntax_type,
1174 formatted_range: None, // Will be updated when closing tag is emitted
1175 });
000000011761177 // For paired inline syntax (Strong, Emphasis, Strikethrough),
1178 // track the opening span so we can set formatted_range when closing
···1990 self.write("</dd>\n")
1991 }
1992 TagEnd::Emphasis => {
0001993 self.finalize_paired_inline_format();
1994- self.write("</em>")
1995 }
1996 TagEnd::Superscript => self.write("</sup>"),
1997 TagEnd::Subscript => self.write("</sub>"),
1998 TagEnd::Strong => {
0001999 self.finalize_paired_inline_format();
2000- self.write("</strong>")
2001 }
2002 TagEnd::Strikethrough => {
0002003 self.finalize_paired_inline_format();
2004- self.write("</s>")
2005 }
2006 TagEnd::Link => self.write("</a>"),
2007 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
···20192020 result?;
20212022- // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled
2023- // by emit_gap_before(range.end) which is called before end_tag() in the main loop.
2024- // No need for manual emission here anymore.
020252026 Ok(())
2027 }
···7//! represent consumed formatting characters.
89use super::offset_map::{OffsetMapping, RenderResult};
10+use loro::LoroText;
11use markdown_weaver::{
12 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
13};
···109/// and emits them as styled spans for visibility in the editor.
110pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> {
111 source: &'a str,
112+ source_text: &'a LoroText,
113 events: I,
114 writer: W,
115 last_byte_offset: usize,
···141 current_node_id: Option<String>, // node ID for current text container
142 current_node_char_offset: usize, // UTF-16 offset within current node
143 current_node_child_count: usize, // number of child elements/text nodes in current container
144+145+ // Incremental UTF-16 offset tracking (replaces rope.chars_to_wchars)
146+ // Maps char_offset -> utf16_offset at checkpoints we've traversed.
147+ // Can be reused for future lookups or passed to subsequent writers.
148+ utf16_checkpoints: Vec<(usize, usize)>, // (char_offset, utf16_offset)
149150 // Paragraph boundary tracking for incremental rendering
151 paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range)
···175impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider>
176 EditorWriter<'a, I, W, E>
177{
178+ pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self {
179+ Self::new_with_node_offset(source, source_text, events, writer, 0)
180 }
181182 pub fn new_with_node_offset(
183 source: &'a str,
184+ source_text: &'a LoroText,
185 events: I,
186 writer: W,
187 node_id_offset: usize,
188 ) -> Self {
189+ Self::new_with_offsets(source, source_text, events, writer, node_id_offset, 0)
190 }
191192 pub fn new_with_offsets(
193 source: &'a str,
194+ source_text: &'a LoroText,
195 events: I,
196 writer: W,
197 node_id_offset: usize,
···199 ) -> Self {
200 Self {
201 source,
202+ source_text,
203 events,
204 writer,
205 last_byte_offset: 0,
···222 current_node_id: None,
223 current_node_char_offset: 0,
224 current_node_child_count: 0,
225+ utf16_checkpoints: vec![(0, 0)],
226 paragraph_ranges: Vec::new(),
227 current_paragraph_start: None,
228 list_depth: 0,
···238 /// Used for fast boundary discovery in incremental rendering.
239 pub fn new_boundary_only(
240 source: &'a str,
241+ source_text: &'a LoroText,
242 events: I,
243 writer: W,
244 ) -> Self {
245 Self {
246 source,
247+ source_text,
248 events,
249 writer,
250 last_byte_offset: 0,
···267 current_node_id: None,
268 current_node_char_offset: 0,
269 current_node_child_count: 0,
270+ utf16_checkpoints: vec![(0, 0)],
271 syntax_spans: Vec::new(),
272 next_syn_id: 0,
273 pending_inline_formats: Vec::new(),
···283 pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> {
284 EditorWriter {
285 source: self.source,
286+ source_text: self.source_text,
287 events: self.events,
288 writer: self.writer,
289 last_byte_offset: self.last_byte_offset,
···306 current_node_id: self.current_node_id,
307 current_node_char_offset: self.current_node_char_offset,
308 current_node_child_count: self.current_node_child_count,
309+ utf16_checkpoints: self.utf16_checkpoints,
310 paragraph_ranges: self.paragraph_ranges,
311 current_paragraph_start: self.current_paragraph_start,
312 list_depth: self.list_depth,
···351 let format_end = self.last_char_offset;
352 let formatted_range = format_start..format_end;
353354+ tracing::debug!(
355+ "[FINALIZE_PAIRED] Setting formatted_range {:?} for opening '{}' and closing (last span)",
356+ formatted_range,
357+ opening_syn_id
358+ );
359+360 // Update the opening span's formatted_range
361 if let Some(opening_span) = self
362 .syntax_spans
···364 .find(|s| s.syn_id == opening_syn_id)
365 {
366 opening_span.formatted_range = Some(formatted_range.clone());
367+ tracing::debug!("[FINALIZE_PAIRED] Updated opening span {}", opening_syn_id);
368+ } else {
369+ tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id);
370 }
371372 // Update the closing span's formatted_range (the most recent one)
···375 // Only update if it's an inline span (closing syntax should be inline)
376 if closing_span.syntax_type == SyntaxType::Inline {
377 closing_span.formatted_range = Some(formatted_range);
378+ tracing::debug!("[FINALIZE_PAIRED] Updated closing span {}", closing_span.syn_id);
379 }
380 }
381 }
···562 self.current_node_child_count = 0;
563 }
564565+ /// Compute UTF-16 length for a text slice with fast path for ASCII.
566+ #[inline]
567+ fn utf16_len_for_slice(text: &str) -> usize {
568+ let byte_len = text.len();
569+ let char_len = text.chars().count();
570+571+ // Fast path: if byte_len == char_len, all ASCII, so utf16_len == char_len
572+ if byte_len == char_len {
573+ char_len
574+ } else {
575+ // Slow path: has multi-byte chars, need to count UTF-16 code units
576+ text.encode_utf16().count()
577+ }
578+ }
579+580 /// Record an offset mapping for the given byte and char ranges.
581 ///
582+ /// Builds up utf16_checkpoints incrementally for efficient lookups.
583 fn record_mapping(&mut self, byte_range: Range<usize>, char_range: Range<usize>) {
584 if let Some(ref node_id) = self.current_node_id {
585+ // Get UTF-16 length using fast path
586+ let text_slice = &self.source[byte_range.clone()];
587+ let utf16_len = Self::utf16_len_for_slice(text_slice);
588+589+ // Record checkpoint at end of this range for future lookups
590+ let last_checkpoint = self.utf16_checkpoints.last().copied().unwrap_or((0, 0));
591+ let new_utf16_offset = last_checkpoint.1 + utf16_len;
592+593+ // Only add checkpoint if we've advanced
594+ if char_range.end > last_checkpoint.0 {
595+ self.utf16_checkpoints.push((char_range.end, new_utf16_offset));
596+ }
597598 let mapping = OffsetMapping {
599 byte_range: byte_range.clone(),
···641642 // For End events, emit any trailing content within the event's range
643 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
644+ //
645+ // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough),
646+ // the closing syntax must be emitted AFTER the closing HTML tag, not before.
647+ // Otherwise the closing `**` span ends up INSIDE the <strong> element.
648+ // These tags handle their own closing syntax in end_tag().
649+ use markdown_weaver::TagEnd;
650+ let is_inline_format_end = matches!(
651+ &event,
652+ Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough)
653+ );
654+655+ if matches!(&event, Event::End(_)) && !is_inline_format_end {
656 // Emit gap from last_byte_offset to range.end
657 // (emit_syntax handles char offset tracking)
658 self.emit_gap_before(range.end)?;
659+ } else if !matches!(&event, Event::End(_)) {
660 // For other events, emit any gap before range.start
661 // (emit_syntax handles char offset tracking)
662 self.emit_gap_before(range.start)?;
663 }
664+ // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML
665666 // Store last_byte before processing
667 let last_byte_before = self.last_byte_offset;
···684 // Handle unmapped trailing content (stripped by parser)
685 // This includes trailing spaces that markdown ignores
686 let doc_byte_len = self.source.len();
687+ let doc_char_len = self.source_text.len_unicode();
688689 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
690 // Emit the trailing content as visible syntax
···1225 syntax_type,
1226 formatted_range: None, // Will be updated when closing tag is emitted
1227 });
1228+1229+ // Record offset mapping for cursor positioning
1230+ // This is critical - without it, current_node_char_offset is wrong
1231+ // and all subsequent cursor positions are shifted
1232+ let byte_start = range.start;
1233+ let byte_end = range.start + syntax_byte_len;
1234+ self.record_mapping(byte_start..byte_end, char_start..char_end);
12351236 // For paired inline syntax (Strong, Emphasis, Strikethrough),
1237 // track the opening span so we can set formatted_range when closing
···2049 self.write("</dd>\n")
2050 }
2051 TagEnd::Emphasis => {
2052+ // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
2053+ self.write("</em>")?;
2054+ self.emit_gap_before(range.end)?;
2055 self.finalize_paired_inline_format();
2056+ Ok(())
2057 }
2058 TagEnd::Superscript => self.write("</sup>"),
2059 TagEnd::Subscript => self.write("</sub>"),
2060 TagEnd::Strong => {
2061+ // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
2062+ self.write("</strong>")?;
2063+ self.emit_gap_before(range.end)?;
2064 self.finalize_paired_inline_format();
2065+ Ok(())
2066 }
2067 TagEnd::Strikethrough => {
2068+ // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
2069+ self.write("</s>")?;
2070+ self.emit_gap_before(range.end)?;
2071 self.finalize_paired_inline_format();
2072+ Ok(())
2073 }
2074 TagEnd::Link => self.write("</a>"),
2075 TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
···20872088 result?;
20892090+ // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough)
2091+ // is handled INSIDE their respective match arms above, AFTER writing the closing HTML.
2092+ // This ensures the closing syntax span appears OUTSIDE the formatted element.
2093+ // Other End events have their closing syntax emitted by emit_gap_before() in the main loop.
20942095 Ok(())
2096 }