···66use markdown_weaver::Event;
77use markdown_weaver_escape::{StrWrite, escape_html};
8899+use crate::offset_map::OffsetMapping;
910use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
1011use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
1112···4142 let is_whitespace_only = syntax.trim().is_empty();
42434344 if is_whitespace_only {
4444- // Emit as plain text with tracking span (not hideable)
4545+ // Check if we need to create a wrapper for standalone gap content.
4546 let created_node = if self.current_node.id.is_none() {
4647 let node_id = self.gen_node_id();
4748 write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
···5152 false
5253 };
53545454- escape_html(&mut self.writer, syntax)?;
5555+ // Only convert newlines to <br /> when this is standalone gap content
5656+ // (created_node = true). Inside paragraphs, keep newlines as-is.
5757+ if created_node {
5858+ // Gap content: the first newline is just the paragraph break (already
5959+ // visual from div structure), so emit only ZWSP. Additional newlines
6060+ // are actual blank lines, so emit <br /> + ZWSP for those.
6161+ let mut byte_offset = range.start;
6262+ let mut char_offset = char_start;
6363+ let mut newline_count = 0usize;
6464+ for ch in syntax.chars() {
6565+ let char_byte_len = ch.len_utf8();
6666+ if ch == '\n' {
6767+ newline_count += 1;
6868+ let utf16_len = if newline_count == 1 {
6969+ // First newline: just ZWSP (paragraph break is already visual).
7070+ self.write("\u{200B}")?;
7171+ 1
7272+ } else {
7373+ // Additional newlines: literal \n + ZWSP.
7474+ // CSS white-space-collapse: break-spaces handles the visual break.
7575+ self.write("\n\u{200B}")?;
7676+ 2
7777+ };
7878+ if let Some(ref node_id) = self.current_node.id {
7979+ let mapping = OffsetMapping {
8080+ byte_range: byte_offset..byte_offset + char_byte_len,
8181+ char_range: char_offset..char_offset + 1,
8282+ node_id: node_id.clone(),
8383+ char_offset_in_node: self.current_node.char_offset,
8484+ child_index: None,
8585+ utf16_len,
8686+ };
8787+ self.current_para.offset_maps.push(mapping);
8888+ self.current_node.char_offset += utf16_len;
8989+ }
9090+ } else {
9191+ escape_html(&mut self.writer, &ch.to_string())?;
9292+ if let Some(ref node_id) = self.current_node.id {
9393+ let mapping = OffsetMapping {
9494+ byte_range: byte_offset..byte_offset + char_byte_len,
9595+ char_range: char_offset..char_offset + 1,
9696+ node_id: node_id.clone(),
9797+ char_offset_in_node: self.current_node.char_offset,
9898+ child_index: None,
9999+ utf16_len: 1,
100100+ };
101101+ self.current_para.offset_maps.push(mapping);
102102+ self.current_node.char_offset += 1;
103103+ }
104104+ }
105105+ byte_offset += char_byte_len;
106106+ char_offset += 1;
107107+ }
108108+ } else {
109109+ // Inside a paragraph: emit whitespace as plain text.
110110+ escape_html(&mut self.writer, syntax)?;
111111+ self.record_mapping(range.clone(), char_start..char_end);
112112+ }
551135656- // Record offset mapping BEFORE end_node (which clears current_node.id)
5757- self.record_mapping(range.clone(), char_start..char_end);
58114 self.last_char_offset = char_end;
59115 self.last_byte_offset = range.end;
60116
+8
crates/weaver-editor-core/src/writer/tests.rs
···221221 let output = render_markdown("line one \nline two");
222222 insta::assert_yaml_snapshot!(output);
223223}
224224+225225+#[test]
226226+fn test_blank_lines_in_paragraph() {
227227+ // Text with ZWSP, then blank lines, then more text
228228+ // This tests the exact case causing cursor jump issues
229229+ let output = render_markdown("test\n\u{200B}d\n\n\ntsdsd");
230230+ insta::assert_yaml_snapshot!(output);
231231+}