···6use markdown_weaver::Event;
7use markdown_weaver_escape::{StrWrite, escape_html};
809use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
10use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
11···41 let is_whitespace_only = syntax.trim().is_empty();
4243 if is_whitespace_only {
44- // Emit as plain text with tracking span (not hideable)
45 let created_node = if self.current_node.id.is_none() {
46 let node_id = self.gen_node_id();
47 write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
···51 false
52 };
5354- escape_html(&mut self.writer, syntax)?;
0000000000000000000000000000000000000000000000000000000005556- // Record offset mapping BEFORE end_node (which clears current_node.id)
57- self.record_mapping(range.clone(), char_start..char_end);
58 self.last_char_offset = char_end;
59 self.last_byte_offset = range.end;
60
···6use markdown_weaver::Event;
7use markdown_weaver_escape::{StrWrite, escape_html};
89+use crate::offset_map::OffsetMapping;
10use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
11use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
12···42 let is_whitespace_only = syntax.trim().is_empty();
4344 if is_whitespace_only {
45+ // Check if we need to create a wrapper for standalone gap content.
46 let created_node = if self.current_node.id.is_none() {
47 let node_id = self.gen_node_id();
48 write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
···52 false
53 };
5455+ // Only convert newlines to <br /> when this is standalone gap content
56+ // (created_node = true). Inside paragraphs, keep newlines as-is.
57+ if created_node {
58+ // Gap content: the first newline is just the paragraph break (already
59+ // visual from div structure), so emit only ZWSP. Additional newlines
60+ // are actual blank lines, so emit <br /> + ZWSP for those.
61+ let mut byte_offset = range.start;
62+ let mut char_offset = char_start;
63+ let mut newline_count = 0usize;
64+ for ch in syntax.chars() {
65+ let char_byte_len = ch.len_utf8();
66+ if ch == '\n' {
67+ newline_count += 1;
68+ let utf16_len = if newline_count == 1 {
69+ // First newline: just ZWSP (paragraph break is already visual).
70+ self.write("\u{200B}")?;
71+ 1
72+ } else {
73+ // Additional newlines: literal \n + ZWSP.
74+ // CSS white-space-collapse: break-spaces handles the visual break.
75+ self.write("\n\u{200B}")?;
76+ 2
77+ };
78+ if let Some(ref node_id) = self.current_node.id {
79+ let mapping = OffsetMapping {
80+ byte_range: byte_offset..byte_offset + char_byte_len,
81+ char_range: char_offset..char_offset + 1,
82+ node_id: node_id.clone(),
83+ char_offset_in_node: self.current_node.char_offset,
84+ child_index: None,
85+ utf16_len,
86+ };
87+ self.current_para.offset_maps.push(mapping);
88+ self.current_node.char_offset += utf16_len;
89+ }
90+ } else {
91+ escape_html(&mut self.writer, &ch.to_string())?;
92+ if let Some(ref node_id) = self.current_node.id {
93+ let mapping = OffsetMapping {
94+ byte_range: byte_offset..byte_offset + char_byte_len,
95+ char_range: char_offset..char_offset + 1,
96+ node_id: node_id.clone(),
97+ char_offset_in_node: self.current_node.char_offset,
98+ child_index: None,
99+ utf16_len: 1,
100+ };
101+ self.current_para.offset_maps.push(mapping);
102+ self.current_node.char_offset += 1;
103+ }
104+ }
105+ byte_offset += char_byte_len;
106+ char_offset += 1;
107+ }
108+ } else {
109+ // Inside a paragraph: emit whitespace as plain text.
110+ escape_html(&mut self.writer, syntax)?;
111+ self.record_mapping(range.clone(), char_start..char_end);
112+ }
11300114 self.last_char_offset = char_end;
115 self.last_byte_offset = range.end;
116
+8
crates/weaver-editor-core/src/writer/tests.rs
···221 let output = render_markdown("line one \nline two");
222 insta::assert_yaml_snapshot!(output);
223}
00000000
···221 let output = render_markdown("line one \nline two");
222 insta::assert_yaml_snapshot!(output);
223}
224+225+#[test]
226+fn test_blank_lines_in_paragraph() {
227+ // Text with ZWSP, then blank lines, then more text
228+ // This tests the exact case causing cursor jump issues
229+ let output = render_markdown("test\n\u{200B}d\n\n\ntsdsd");
230+ insta::assert_yaml_snapshot!(output);
231+}