···611 let _ = div.set_attribute("data-hash", &new_hash);
612 let div_node: &web_sys::Node = div.as_ref();
613 let _ = editor.insert_before(div_node, cursor_node.as_ref());
614- }
615616- if is_cursor_para {
617- cursor_para_updated = true;
000000618 }
619 }
620 }
···611 let _ = div.set_attribute("data-hash", &new_hash);
612 let div_node: &web_sys::Node = div.as_ref();
613 let _ = editor.insert_before(div_node, cursor_node.as_ref());
0614615+ if is_cursor_para {
616+ if let Err(e) =
617+ restore_cursor_position(cursor_offset, &new_para.offset_map, None)
618+ {
619+ tracing::warn!("Cursor restore for new paragraph failed: {:?}", e);
620+ }
621+ cursor_para_updated = true;
622+ }
623 }
624 }
625 }
+81-6
crates/weaver-editor-core/src/writer/events.rs
···4use std::fmt::Write as _;
5use std::ops::Range;
67-use markdown_weaver::{Event, TagEnd};
8use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count};
910use crate::offset_map::OffsetMapping;
···58 // Emit gap from last_byte_offset to range.end
59 self.emit_gap_before(range.end)?;
60 } else if !matches!(&event, Event::End(_)) {
00000000000000000061 // For other events, emit any gap before range.start
62 // (emit_syntax handles char offset tracking)
63 self.emit_gap_before(range.start)?;
···79 // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
80 }
8182- // Emit any trailing syntax
83- self.emit_gap_before(self.source.len())?;
00000000000008485 // Handle unmapped trailing content (stripped by parser)
86 // This includes trailing spaces that markdown ignores
87 let doc_byte_len = self.source.len();
88 let doc_char_len = self.text_buffer.len_chars();
8990- if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
91- // Emit the trailing content as visible syntax
0092 if self.last_byte_offset < doc_byte_len {
93 let trailing = &self.source[self.last_byte_offset..];
94 if !trailing.is_empty() {
···125 }
126 }
127128- // Add any remaining accumulated data for the last paragraph
129 // (content that wasn't followed by a paragraph boundary)
130 if !self.current_para.offset_maps.is_empty()
131 || !self.current_para.syntax_spans.is_empty()
···137 .push(std::mem::take(&mut self.current_para.syntax_spans));
138 self.refs_by_para
139 .push(std::mem::take(&mut self.ref_collector.refs));
000000000000000000000000000000000000000000140 }
141142 // Get HTML segments from writer
···4use std::fmt::Write as _;
5use std::ops::Range;
67+use markdown_weaver::{Event, Tag, TagEnd};
8use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count};
910use crate::offset_map::OffsetMapping;
···58 // Emit gap from last_byte_offset to range.end
59 self.emit_gap_before(range.end)?;
60 } else if !matches!(&event, Event::End(_)) {
61+ // For paragraph-level start events, capture pre-gap position so the
62+ // paragraph's char_range includes leading whitespace/gap content.
63+ let is_para_start = matches!(
64+ &event,
65+ Event::Start(
66+ Tag::Paragraph(_)
67+ | Tag::Heading { .. }
68+ | Tag::CodeBlock(_)
69+ | Tag::List(_)
70+ | Tag::BlockQuote(_)
71+ | Tag::HtmlBlock
72+ )
73+ );
74+ if is_para_start && self.paragraphs.should_track_boundaries() {
75+ self.paragraphs.pre_gap_start =
76+ Some((self.last_byte_offset, self.last_char_offset));
77+ }
78+79 // For other events, emit any gap before range.start
80 // (emit_syntax handles char offset tracking)
81 self.emit_gap_before(range.start)?;
···97 // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
98 }
99100+ // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing.
101+ // If so, we'll reserve the final newline for a synthetic trailing paragraph.
102+ let ends_with_para_break = self.source.ends_with("\n\n")
103+ || self.source.ends_with("\n\u{200C}\n");
104+105+ // Determine where to stop emitting trailing syntax
106+ let trailing_emit_end = if ends_with_para_break {
107+ // Don't emit the final newline - save it for synthetic paragraph
108+ self.source.len().saturating_sub(1)
109+ } else {
110+ self.source.len()
111+ };
112+113+ // Emit trailing syntax up to the determined point
114+ self.emit_gap_before(trailing_emit_end)?;
115116 // Handle unmapped trailing content (stripped by parser)
117 // This includes trailing spaces that markdown ignores
118 let doc_byte_len = self.source.len();
119 let doc_char_len = self.text_buffer.len_chars();
120121+ if !ends_with_para_break
122+ && (self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len)
123+ {
124+ // Emit the trailing content as visible syntax (only if not creating synthetic para)
125 if self.last_byte_offset < doc_byte_len {
126 let trailing = &self.source[self.last_byte_offset..];
127 if !trailing.is_empty() {
···158 }
159 }
160161+ // Add any remaining accumulated data for the last paragraph FIRST
162 // (content that wasn't followed by a paragraph boundary)
163 if !self.current_para.offset_maps.is_empty()
164 || !self.current_para.syntax_spans.is_empty()
···170 .push(std::mem::take(&mut self.current_para.syntax_spans));
171 self.refs_by_para
172 .push(std::mem::take(&mut self.ref_collector.refs));
173+ }
174+175+ // Now create a synthetic trailing paragraph if needed
176+ if ends_with_para_break {
177+ // Get the trailing content we reserved (the final newline)
178+ let trailing_content = &self.source[trailing_emit_end..];
179+ let trailing_char_len = trailing_content.chars().count();
180+181+ let trailing_start_char = self.last_char_offset;
182+ let trailing_start_byte = self.last_byte_offset;
183+ let trailing_end_char = trailing_start_char + trailing_char_len;
184+ let trailing_end_byte = self.source.len();
185+186+ // Create paragraph range that includes the trailing content
187+ self.paragraphs.ranges.push((
188+ trailing_start_byte..trailing_end_byte,
189+ trailing_start_char..trailing_end_char,
190+ ));
191+192+ // Start a new HTML segment for this trailing paragraph
193+ self.writer.new_segment();
194+ let node_id = self.gen_node_id();
195+196+ // Write the actual trailing content plus ZWSP for cursor positioning
197+ write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
198+ escape_html(&mut self.writer, trailing_content)?;
199+ self.write("\u{200B}</span>")?;
200+201+ // Record offset mapping for the trailing content
202+ let mapping = OffsetMapping {
203+ byte_range: trailing_start_byte..trailing_end_byte,
204+ char_range: trailing_start_char..trailing_end_char,
205+ node_id,
206+ char_offset_in_node: 0,
207+ child_index: None,
208+ utf16_len: trailing_char_len + 1, // Content + ZWSP
209+ };
210+211+ // Create offset_maps/syntax_spans/refs for this trailing paragraph
212+ self.offset_maps_by_para.push(vec![mapping]);
213+ self.syntax_spans_by_para.push(vec![]);
214+ self.refs_by_para.push(vec![]);
215 }
216217 // Get HTML segments from writer
···163 pub ranges: Vec<(Range<usize>, Range<usize>)>,
164 /// Start of current paragraph: (byte_offset, char_offset)
165 pub current_start: Option<(usize, usize)>,
166+ /// Pre-gap position for paragraph start (captured before gap emission).
167+ /// This ensures the paragraph's char_range includes leading whitespace.
168+ pub pre_gap_start: Option<(usize, usize)>,
169 /// List nesting depth (suppress paragraph boundaries inside lists)
170 pub list_depth: usize,
171 /// In footnote definition (suppress inner paragraph boundaries)
+52-24
crates/weaver-editor-core/src/writer/tags.rs
···147 match tag {
148 // HTML blocks get their own paragraph to try and corral them better
149 Tag::HtmlBlock => {
150- // Record paragraph start for boundary tracking
151- // Skip if inside a list or footnote def - they own their paragraph boundary
0152 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
153- self.paragraphs.current_start =
154- Some((self.last_byte_offset, self.last_char_offset));
000155 }
156 let node_id = self.gen_node_id();
157···189 // Handle wrapper before block
190 self.emit_wrapper_start()?;
191192- // Record paragraph start for boundary tracking
193- // Skip if inside a list or footnote def - they own their paragraph boundary
0194 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
195- self.paragraphs.current_start =
196- Some((self.last_byte_offset, self.last_char_offset));
000197 }
198199 let node_id = self.gen_node_id();
···273 // Emit wrapper if pending (but don't close on heading end - wraps following block too)
274 self.emit_wrapper_start()?;
275276- // Record paragraph start for boundary tracking
277- // Treat headings as paragraph-level blocks
278- self.paragraphs.current_start =
279- Some((self.last_byte_offset, self.last_char_offset));
0000280281 if !self.end_newline {
282 self.write_newline()?;
···435 Tag::CodeBlock(info) => {
436 self.emit_wrapper_start()?;
437438- // Track code block as paragraph-level block
439- self.paragraphs.current_start =
440- Some((self.last_byte_offset, self.last_char_offset));
0000441442 if !self.end_newline {
443 self.write_newline()?;
···511 }
512 Tag::List(Some(1)) => {
513 self.emit_wrapper_start()?;
514- // Track list as paragraph-level block
515- self.paragraphs.current_start =
516- Some((self.last_byte_offset, self.last_char_offset));
0000517 self.paragraphs.list_depth += 1;
518 if self.end_newline {
519 self.write("<ol>")
···523 }
524 Tag::List(Some(start)) => {
525 self.emit_wrapper_start()?;
526- // Track list as paragraph-level block
527- self.paragraphs.current_start =
528- Some((self.last_byte_offset, self.last_char_offset));
0000529 self.paragraphs.list_depth += 1;
530 if self.end_newline {
531 self.write("<ol start=\"")?;
···537 }
538 Tag::List(None) => {
539 self.emit_wrapper_start()?;
540- // Track list as paragraph-level block
541- self.paragraphs.current_start =
542- Some((self.last_byte_offset, self.last_char_offset));
0000543 self.paragraphs.list_depth += 1;
544 if self.end_newline {
545 self.write("<ul>")
···147 match tag {
148 // HTML blocks get their own paragraph to try and corral them better
149 Tag::HtmlBlock => {
150+ // Record paragraph start for boundary tracking.
151+ // Use pre_gap_start if available to include leading whitespace in char_range.
152+ // Skip if inside a list or footnote def - they own their paragraph boundary.
153 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
154+ self.paragraphs.current_start = self
155+ .paragraphs
156+ .pre_gap_start
157+ .take()
158+ .or(Some((self.last_byte_offset, self.last_char_offset)));
159 }
160 let node_id = self.gen_node_id();
161···193 // Handle wrapper before block
194 self.emit_wrapper_start()?;
195196+ // Record paragraph start for boundary tracking.
197+ // Use pre_gap_start if available to include leading whitespace in char_range.
198+ // Skip if inside a list or footnote def - they own their paragraph boundary.
199 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
200+ self.paragraphs.current_start = self
201+ .paragraphs
202+ .pre_gap_start
203+ .take()
204+ .or(Some((self.last_byte_offset, self.last_char_offset)));
205 }
206207 let node_id = self.gen_node_id();
···281 // Emit wrapper if pending (but don't close on heading end - wraps following block too)
282 self.emit_wrapper_start()?;
283284+ // Record paragraph start for boundary tracking.
285+ // Use pre_gap_start if available to include leading whitespace in char_range.
286+ // Treat headings as paragraph-level blocks.
287+ self.paragraphs.current_start = self
288+ .paragraphs
289+ .pre_gap_start
290+ .take()
291+ .or(Some((self.last_byte_offset, self.last_char_offset)));
292293 if !self.end_newline {
294 self.write_newline()?;
···447 Tag::CodeBlock(info) => {
448 self.emit_wrapper_start()?;
449450+ // Track code block as paragraph-level block.
451+ // Use pre_gap_start if available to include leading whitespace in char_range.
452+ self.paragraphs.current_start = self
453+ .paragraphs
454+ .pre_gap_start
455+ .take()
456+ .or(Some((self.last_byte_offset, self.last_char_offset)));
457458 if !self.end_newline {
459 self.write_newline()?;
···527 }
528 Tag::List(Some(1)) => {
529 self.emit_wrapper_start()?;
530+ // Track list as paragraph-level block.
531+ // Use pre_gap_start if available to include leading whitespace in char_range.
532+ self.paragraphs.current_start = self
533+ .paragraphs
534+ .pre_gap_start
535+ .take()
536+ .or(Some((self.last_byte_offset, self.last_char_offset)));
537 self.paragraphs.list_depth += 1;
538 if self.end_newline {
539 self.write("<ol>")
···543 }
544 Tag::List(Some(start)) => {
545 self.emit_wrapper_start()?;
546+ // Track list as paragraph-level block.
547+ // Use pre_gap_start if available to include leading whitespace in char_range.
548+ self.paragraphs.current_start = self
549+ .paragraphs
550+ .pre_gap_start
551+ .take()
552+ .or(Some((self.last_byte_offset, self.last_char_offset)));
553 self.paragraphs.list_depth += 1;
554 if self.end_newline {
555 self.write("<ol start=\"")?;
···561 }
562 Tag::List(None) => {
563 self.emit_wrapper_start()?;
564+ // Track list as paragraph-level block.
565+ // Use pre_gap_start if available to include leading whitespace in char_range.
566+ self.paragraphs.current_start = self
567+ .paragraphs
568+ .pre_gap_start
569+ .take()
570+ .or(Some((self.last_byte_offset, self.last_char_offset)));
571 self.paragraphs.list_depth += 1;
572 if self.end_newline {
573 self.write("<ul>")
+1-1
crates/weaver-renderer/src/lib.rs
···35pub mod math;
36#[cfg(feature = "pckt")]
37pub mod pckt;
38-#[cfg(not(target_family = "wasm"))]
39pub mod static_site;
40pub mod theme;
41pub mod types;
···35pub mod math;
36#[cfg(feature = "pckt")]
37pub mod pckt;
38+#[cfg(all(not(target_family = "wasm"), feature = "syntax-highlighting"))]
39pub mod static_site;
40pub mod theme;
41pub mod types;