···611611 let _ = div.set_attribute("data-hash", &new_hash);
612612 let div_node: &web_sys::Node = div.as_ref();
613613 let _ = editor.insert_before(div_node, cursor_node.as_ref());
614614- }
615614616616- if is_cursor_para {
617617- cursor_para_updated = true;
615615+ if is_cursor_para {
616616+ if let Err(e) =
617617+ restore_cursor_position(cursor_offset, &new_para.offset_map, None)
618618+ {
619619+ tracing::warn!("Cursor restore for new paragraph failed: {:?}", e);
620620+ }
621621+ cursor_para_updated = true;
622622+ }
618623 }
619624 }
620625 }
+81-6
crates/weaver-editor-core/src/writer/events.rs
···44use std::fmt::Write as _;
55use std::ops::Range;
6677-use markdown_weaver::{Event, TagEnd};
77+use markdown_weaver::{Event, Tag, TagEnd};
88use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count};
991010use crate::offset_map::OffsetMapping;
···5858 // Emit gap from last_byte_offset to range.end
5959 self.emit_gap_before(range.end)?;
6060 } else if !matches!(&event, Event::End(_)) {
6161+ // For paragraph-level start events, capture pre-gap position so the
6262+ // paragraph's char_range includes leading whitespace/gap content.
6363+ let is_para_start = matches!(
6464+ &event,
6565+ Event::Start(
6666+ Tag::Paragraph(_)
6767+ | Tag::Heading { .. }
6868+ | Tag::CodeBlock(_)
6969+ | Tag::List(_)
7070+ | Tag::BlockQuote(_)
7171+ | Tag::HtmlBlock
7272+ )
7373+ );
7474+ if is_para_start && self.paragraphs.should_track_boundaries() {
7575+ self.paragraphs.pre_gap_start =
7676+ Some((self.last_byte_offset, self.last_char_offset));
7777+ }
7878+6179 // For other events, emit any gap before range.start
6280 // (emit_syntax handles char offset tracking)
6381 self.emit_gap_before(range.start)?;
···7997 // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
8098 }
81998282- // Emit any trailing syntax
8383- self.emit_gap_before(self.source.len())?;
100100+ // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing.
101101+ // If so, we'll reserve the final newline for a synthetic trailing paragraph.
102102+ let ends_with_para_break = self.source.ends_with("\n\n")
103103+ || self.source.ends_with("\n\u{200C}\n");
104104+105105+ // Determine where to stop emitting trailing syntax
106106+ let trailing_emit_end = if ends_with_para_break {
107107+ // Don't emit the final newline - save it for synthetic paragraph
108108+ self.source.len().saturating_sub(1)
109109+ } else {
110110+ self.source.len()
111111+ };
112112+113113+ // Emit trailing syntax up to the determined point
114114+ self.emit_gap_before(trailing_emit_end)?;
8411585116 // Handle unmapped trailing content (stripped by parser)
86117 // This includes trailing spaces that markdown ignores
87118 let doc_byte_len = self.source.len();
88119 let doc_char_len = self.text_buffer.len_chars();
891209090- if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
9191- // Emit the trailing content as visible syntax
121121+ if !ends_with_para_break
122122+ && (self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len)
123123+ {
124124+ // Emit the trailing content as visible syntax (only if not creating synthetic para)
92125 if self.last_byte_offset < doc_byte_len {
93126 let trailing = &self.source[self.last_byte_offset..];
94127 if !trailing.is_empty() {
···125158 }
126159 }
127160128128- // Add any remaining accumulated data for the last paragraph
161161+ // Add any remaining accumulated data for the last paragraph FIRST
129162 // (content that wasn't followed by a paragraph boundary)
130163 if !self.current_para.offset_maps.is_empty()
131164 || !self.current_para.syntax_spans.is_empty()
···137170 .push(std::mem::take(&mut self.current_para.syntax_spans));
138171 self.refs_by_para
139172 .push(std::mem::take(&mut self.ref_collector.refs));
173173+ }
174174+175175+ // Now create a synthetic trailing paragraph if needed
176176+ if ends_with_para_break {
177177+ // Get the trailing content we reserved (the final newline)
178178+ let trailing_content = &self.source[trailing_emit_end..];
179179+ let trailing_char_len = trailing_content.chars().count();
180180+181181+ let trailing_start_char = self.last_char_offset;
182182+ let trailing_start_byte = self.last_byte_offset;
183183+ let trailing_end_char = trailing_start_char + trailing_char_len;
184184+ let trailing_end_byte = self.source.len();
185185+186186+ // Create paragraph range that includes the trailing content
187187+ self.paragraphs.ranges.push((
188188+ trailing_start_byte..trailing_end_byte,
189189+ trailing_start_char..trailing_end_char,
190190+ ));
191191+192192+ // Start a new HTML segment for this trailing paragraph
193193+ self.writer.new_segment();
194194+ let node_id = self.gen_node_id();
195195+196196+ // Write the actual trailing content plus ZWSP for cursor positioning
197197+ write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
198198+ escape_html(&mut self.writer, trailing_content)?;
199199+ self.write("\u{200B}</span>")?;
200200+201201+ // Record offset mapping for the trailing content
202202+ let mapping = OffsetMapping {
203203+ byte_range: trailing_start_byte..trailing_end_byte,
204204+ char_range: trailing_start_char..trailing_end_char,
205205+ node_id,
206206+ char_offset_in_node: 0,
207207+ child_index: None,
208208+ utf16_len: trailing_char_len + 1, // Content + ZWSP
209209+ };
210210+211211+ // Create offset_maps/syntax_spans/refs for this trailing paragraph
212212+ self.offset_maps_by_para.push(vec![mapping]);
213213+ self.syntax_spans_by_para.push(vec![]);
214214+ self.refs_by_para.push(vec![]);
140215 }
141216142217 // Get HTML segments from writer
+3
crates/weaver-editor-core/src/writer/state.rs
···163163 pub ranges: Vec<(Range<usize>, Range<usize>)>,
164164 /// Start of current paragraph: (byte_offset, char_offset)
165165 pub current_start: Option<(usize, usize)>,
166166+ /// Pre-gap position for paragraph start (captured before gap emission).
167167+ /// This ensures the paragraph's char_range includes leading whitespace.
168168+ pub pre_gap_start: Option<(usize, usize)>,
166169 /// List nesting depth (suppress paragraph boundaries inside lists)
167170 pub list_depth: usize,
168171 /// In footnote definition (suppress inner paragraph boundaries)
+52-24
crates/weaver-editor-core/src/writer/tags.rs
···147147 match tag {
148148 // HTML blocks get their own paragraph to try and corral them better
149149 Tag::HtmlBlock => {
150150- // Record paragraph start for boundary tracking
151151- // Skip if inside a list or footnote def - they own their paragraph boundary
150150+ // Record paragraph start for boundary tracking.
151151+ // Use pre_gap_start if available to include leading whitespace in char_range.
152152+ // Skip if inside a list or footnote def - they own their paragraph boundary.
152153 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
153153- self.paragraphs.current_start =
154154- Some((self.last_byte_offset, self.last_char_offset));
154154+ self.paragraphs.current_start = self
155155+ .paragraphs
156156+ .pre_gap_start
157157+ .take()
158158+ .or(Some((self.last_byte_offset, self.last_char_offset)));
155159 }
156160 let node_id = self.gen_node_id();
157161···189193 // Handle wrapper before block
190194 self.emit_wrapper_start()?;
191195192192- // Record paragraph start for boundary tracking
193193- // Skip if inside a list or footnote def - they own their paragraph boundary
196196+ // Record paragraph start for boundary tracking.
197197+ // Use pre_gap_start if available to include leading whitespace in char_range.
198198+ // Skip if inside a list or footnote def - they own their paragraph boundary.
194199 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def {
195195- self.paragraphs.current_start =
196196- Some((self.last_byte_offset, self.last_char_offset));
200200+ self.paragraphs.current_start = self
201201+ .paragraphs
202202+ .pre_gap_start
203203+ .take()
204204+ .or(Some((self.last_byte_offset, self.last_char_offset)));
197205 }
198206199207 let node_id = self.gen_node_id();
···273281 // Emit wrapper if pending (but don't close on heading end - wraps following block too)
274282 self.emit_wrapper_start()?;
275283276276- // Record paragraph start for boundary tracking
277277- // Treat headings as paragraph-level blocks
278278- self.paragraphs.current_start =
279279- Some((self.last_byte_offset, self.last_char_offset));
284284+ // Record paragraph start for boundary tracking.
285285+ // Use pre_gap_start if available to include leading whitespace in char_range.
286286+ // Treat headings as paragraph-level blocks.
287287+ self.paragraphs.current_start = self
288288+ .paragraphs
289289+ .pre_gap_start
290290+ .take()
291291+ .or(Some((self.last_byte_offset, self.last_char_offset)));
280292281293 if !self.end_newline {
282294 self.write_newline()?;
···435447 Tag::CodeBlock(info) => {
436448 self.emit_wrapper_start()?;
437449438438- // Track code block as paragraph-level block
439439- self.paragraphs.current_start =
440440- Some((self.last_byte_offset, self.last_char_offset));
450450+ // Track code block as paragraph-level block.
451451+ // Use pre_gap_start if available to include leading whitespace in char_range.
452452+ self.paragraphs.current_start = self
453453+ .paragraphs
454454+ .pre_gap_start
455455+ .take()
456456+ .or(Some((self.last_byte_offset, self.last_char_offset)));
441457442458 if !self.end_newline {
443459 self.write_newline()?;
···511527 }
512528 Tag::List(Some(1)) => {
513529 self.emit_wrapper_start()?;
514514- // Track list as paragraph-level block
515515- self.paragraphs.current_start =
516516- Some((self.last_byte_offset, self.last_char_offset));
530530+ // Track list as paragraph-level block.
531531+ // Use pre_gap_start if available to include leading whitespace in char_range.
532532+ self.paragraphs.current_start = self
533533+ .paragraphs
534534+ .pre_gap_start
535535+ .take()
536536+ .or(Some((self.last_byte_offset, self.last_char_offset)));
517537 self.paragraphs.list_depth += 1;
518538 if self.end_newline {
519539 self.write("<ol>")
···523543 }
524544 Tag::List(Some(start)) => {
525545 self.emit_wrapper_start()?;
526526- // Track list as paragraph-level block
527527- self.paragraphs.current_start =
528528- Some((self.last_byte_offset, self.last_char_offset));
546546+ // Track list as paragraph-level block.
547547+ // Use pre_gap_start if available to include leading whitespace in char_range.
548548+ self.paragraphs.current_start = self
549549+ .paragraphs
550550+ .pre_gap_start
551551+ .take()
552552+ .or(Some((self.last_byte_offset, self.last_char_offset)));
529553 self.paragraphs.list_depth += 1;
530554 if self.end_newline {
531555 self.write("<ol start=\"")?;
···537561 }
538562 Tag::List(None) => {
539563 self.emit_wrapper_start()?;
540540- // Track list as paragraph-level block
541541- self.paragraphs.current_start =
542542- Some((self.last_byte_offset, self.last_char_offset));
564564+ // Track list as paragraph-level block.
565565+ // Use pre_gap_start if available to include leading whitespace in char_range.
566566+ self.paragraphs.current_start = self
567567+ .paragraphs
568568+ .pre_gap_start
569569+ .take()
570570+ .or(Some((self.last_byte_offset, self.last_char_offset)));
543571 self.paragraphs.list_depth += 1;
544572 if self.end_newline {
545573 self.write("<ul>")
+1-1
crates/weaver-renderer/src/lib.rs
···3535pub mod math;
3636#[cfg(feature = "pckt")]
3737pub mod pckt;
3838-#[cfg(not(target_family = "wasm"))]
3838+#[cfg(all(not(target_family = "wasm"), feature = "syntax-highlighting"))]
3939pub mod static_site;
4040pub mod theme;
4141pub mod types;