From df2ba14f227ad93bdfa8e2b07738df3513fde71f Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 24 Nov 2025 17:26:01 -0500 Subject: [PATCH] more bugs squashed, getting less shit --- .../weaver-app/src/components/editor/mod.rs | 299 +++++++++++------- .../src/components/editor/render.rs | 80 +++-- .../src/components/editor/writer.rs | 160 +++++++--- 3 files changed, 348 insertions(+), 191 deletions(-) diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 4233c26..271bbef 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -71,8 +71,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { .collect::>() }); - // Track previous paragraphs for change detection (outside effect so it persists) - let mut prev_paragraphs = use_signal(|| Vec::::new()); + // Cache paragraphs for change detection AND for event handlers to access + let mut cached_paragraphs = use_signal(|| Vec::::new()); // Update DOM when paragraphs change (incremental rendering) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -80,8 +80,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let new_paras = paragraphs(); let cursor_offset = document().cursor.offset; - // Use peek() to avoid creating reactive dependency on prev_paragraphs - let prev = prev_paragraphs.peek().clone(); + // Use peek() to avoid creating reactive dependency on cached_paragraphs + let prev = cached_paragraphs.peek().clone(); let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); @@ -108,8 +108,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } } - // Store for next comparison (write-only, no reactive read) - prev_paragraphs.set(new_paras); + // Store for next comparison AND for event handlers (write-only, no reactive read) + cached_paragraphs.set(new_paras); }); // Auto-save with debounce @@ -149,19 +149,33 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { }, onkeyup: move |evt| { - // After any key (including arrow keys), sync cursor from DOM - sync_cursor_from_dom(&mut document, editor_id); + use dioxus::prelude::keyboard_types::Key; + // Only sync cursor from DOM after navigation keys + // Content-modifying keys update cursor directly in handle_keydown + let dominated = matches!( + evt.key(), + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | + Key::Home | Key::End | Key::PageUp | Key::PageDown + ); + if dominated { + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + } }, onclick: move |_evt| { // After mouse click, sync cursor from DOM - sync_cursor_from_dom(&mut document, editor_id); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); }, onpaste: move |evt| { - evt.prevent_default(); handle_paste(evt, &mut document); }, + + oncut: move |evt| { + handle_cut(evt, &mut document); + }, } @@ -186,9 +200,14 @@ fn should_intercept_key(evt: &Event) -> bool { let key = evt.key(); let mods = evt.modifiers(); - // Intercept shortcuts + // Handle Ctrl/Cmd shortcuts if mods.ctrl() || mods.meta() { - return true; + if let Key::Character(ch) = &key { + // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I) + return matches!(ch.as_str(), "b" | "i"); + } + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.) + return false; } // Intercept content modifications @@ -198,11 +217,20 @@ fn should_intercept_key(evt: &Event) -> bool { ) } -/// Sync internal cursor state from browser DOM selection +/// Sync internal cursor and selection state from browser DOM selection #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn sync_cursor_from_dom(document: &mut Signal, editor_id: &str) { +fn sync_cursor_from_dom( + document: &mut Signal, + editor_id: &str, + paragraphs: &[ParagraphRender], +) { use wasm_bindgen::JsCast; + // Early return if paragraphs not yet populated (first render edge case) + if paragraphs.is_empty() { + return; + } + let window = match web_sys::window() { Some(w) => w, None => return, @@ -213,7 +241,6 @@ fn sync_cursor_from_dom(document: &mut Signal, editor_id: &str) None => return, }; - // Get editor element as boundary for search let editor_element = match dom_document.get_element_by_id(editor_id) { Some(e) => e, None => return, @@ -224,143 +251,197 @@ fn sync_cursor_from_dom(document: &mut Signal, editor_id: &str) _ => return, }; - // Get cursor position from selection + // Get both anchor (selection start) and focus (selection end) positions + let anchor_node = match selection.anchor_node() { + Some(node) => node, + None => return, + }; let focus_node = match selection.focus_node() { Some(node) => node, None => return, }; - + let anchor_offset = selection.anchor_offset() as usize; let focus_offset = selection.focus_offset() as usize; - // Find the text node's containing element with an ID (from offset map) - // Walk up but stop at editor boundary to avoid escaping the editor - let mut current_node = focus_node.clone(); + // Convert both DOM positions to rope offsets using cached paragraphs + let anchor_rope = dom_position_to_rope_offset( + &dom_document, + &editor_element, + &anchor_node, + anchor_offset, + paragraphs, + ); + let focus_rope = dom_position_to_rope_offset( + &dom_document, + &editor_element, + &focus_node, + focus_offset, + paragraphs, + ); + + document.with_mut(|doc| { + match (anchor_rope, focus_rope) { + (Some(anchor), Some(focus)) => { + doc.cursor.offset = focus; + if anchor != focus { + // There's an actual selection + doc.selection = Some(Selection { + anchor, + head: focus, + }); + tracing::debug!("[SYNC] Selection {}..{}", anchor, focus); + } else { + // Collapsed selection (just cursor) + doc.selection = None; + tracing::debug!("[SYNC] Cursor at {}", focus); + } + } + _ => { + tracing::warn!("Could not map DOM selection to rope offsets"); + } + } + }); +} + +/// Convert a DOM position (node + offset) to a rope char offset using offset maps +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn dom_position_to_rope_offset( + dom_document: &web_sys::Document, + editor_element: &web_sys::Element, + node: &web_sys::Node, + offset_in_text_node: usize, + paragraphs: &[ParagraphRender], +) -> Option { + use wasm_bindgen::JsCast; + + // Find the containing element with a node ID (walk up from text node) + let mut current_node = node.clone(); let node_id = loop { if let Some(element) = current_node.dyn_ref::() { - // Stop if we've reached the editor boundary - if element == &editor_element { + if element == editor_element { break None; } - // Check both id and data-node-id attributes - // (paragraphs use id, headings use data-node-id to preserve user heading IDs) let id = element .get_attribute("id") .or_else(|| element.get_attribute("data-node-id")); if let Some(id) = id { - // Look for node IDs like "n0", "n1", etc (from offset map) if id.starts_with('n') && id[1..].parse::().is_ok() { break Some(id); } } } - current_node = match current_node.parent_node() { - Some(parent) => parent, - None => break None, - }; + current_node = current_node.parent_node()?; }; - let node_id = match node_id { - Some(id) => id, - None => { - tracing::warn!("Could not find node_id for cursor position"); - return; - } - }; + let node_id = node_id?; - let container = match dom_document.get_element_by_id(&node_id).or_else(|| { + // Get the container element + let container = dom_document.get_element_by_id(&node_id).or_else(|| { let selector = format!("[data-node-id='{}']", node_id); dom_document.query_selector(&selector).ok().flatten() - }) { - Some(e) => e, - None => return, - }; + })?; - // Calculate UTF-16 offset from start of container to focus position + // Calculate UTF-16 offset from start of container to the position let mut utf16_offset_in_container = 0; - // Create tree walker for text nodes in container if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { - while let Ok(Some(node)) = walker.next_node() { - if node == focus_node { - // Found the exact text node, add the offset within it - utf16_offset_in_container += focus_offset; + while let Ok(Some(text_node)) = walker.next_node() { + if &text_node == node { + utf16_offset_in_container += offset_in_text_node; break; } - // Accumulate length of previous text nodes - if let Some(text) = node.text_content() { + if let Some(text) = text_node.text_content() { utf16_offset_in_container += text.encode_utf16().count(); } } } - // Now look up this position in the offset map - // We need to find the mapping with this node_id and calculate rope offset - document.with_mut(|doc| { - // Render to get current offset maps - let paragraphs = render::render_paragraphs(&doc.rope); - - tracing::debug!("[SYNC] Looking for node_id: {}, utf16_offset_in_container: {}", node_id, utf16_offset_in_container); - - // Find mapping with this node_id - for para in paragraphs { - for mapping in para.offset_map { - if mapping.node_id == node_id { - // Check if our utf16 offset falls within this mapping's range - // End-INCLUSIVE to allow cursor at the end of text nodes - let mapping_start = mapping.char_offset_in_node; - let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; - - if utf16_offset_in_container >= mapping_start && utf16_offset_in_container <= mapping_end { - // Calculate rope offset - let offset_in_mapping = utf16_offset_in_container - mapping_start; - let rope_offset = mapping.char_range.start + offset_in_mapping; - - tracing::debug!("[SYNC] -> MATCHED! rope_offset: {} (was {})", rope_offset, doc.cursor.offset); - doc.cursor.offset = rope_offset; - return; - } + // Look up in offset maps + for para in paragraphs { + for mapping in ¶.offset_map { + if mapping.node_id == node_id { + let mapping_start = mapping.char_offset_in_node; + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + + if utf16_offset_in_container >= mapping_start + && utf16_offset_in_container <= mapping_end + { + let offset_in_mapping = utf16_offset_in_container - mapping_start; + return Some(mapping.char_range.start + offset_in_mapping); } } } + } - tracing::warn!("Could not map DOM cursor position to rope offset"); - }); + None } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -fn sync_cursor_from_dom(_document: &mut Signal, _editor_id: &str) { +fn sync_cursor_from_dom( + _document: &mut Signal, + _editor_id: &str, + _paragraphs: &[ParagraphRender], +) { // No-op on non-wasm } /// Handle paste events and insert text at cursor fn handle_paste(evt: Event, document: &mut Signal) { - // Downcast to web_sys event to get clipboard data - #[cfg(target_arch = "wasm32")] - if let Some(web_evt) = evt.data().downcast::() { - if let Some(data_transfer) = web_evt.clipboard_data() { - if let Ok(text) = data_transfer.get_data("text/plain") { - document.with_mut(|doc| { - // Delete selection if present - if let Some(sel) = doc.selection { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - doc.rope.remove(start..end); - doc.cursor.offset = start; - doc.selection = None; - } + tracing::info!("[PASTE] handle_paste called"); - // Insert pasted text - doc.rope.insert(doc.cursor.offset, &text); - doc.cursor.offset += text.chars().count(); - }); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + if let Ok(text) = data_transfer.get_data("text/plain") { + tracing::info!("[PASTE] Got text: {} chars", text.len()); + document.with_mut(|doc| { + // Delete selection if present + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + + // Insert pasted text + doc.rope.insert(doc.cursor.offset, &text); + doc.cursor.offset += text.chars().count(); + }); + } } + } else { + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); } } } +/// Handle cut events - browser copies selection, we delete it from rope +/// Selection is synced via onkeyup/onclick, so doc.selection should be current +fn handle_cut(_evt: Event, document: &mut Signal) { + tracing::info!("[CUT] handle_cut called"); + + document.with_mut(|doc| { + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + tracing::info!("[CUT] Deleting selection {}..{}", start, end); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + } + }); +} + /// Handle keyboard events and update document state fn handle_keydown(evt: Event, document: &mut Signal) { use dioxus::prelude::keyboard_types::Key; @@ -489,19 +570,9 @@ fn handle_keydown(evt: Event, document: &mut Signal Vec { let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); let mut node_id_offset = 0; // Track total nodes used so far for unique IDs - tracing::info!("[RENDER] Rendering {} paragraphs", paragraph_ranges.len()); for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { - tracing::info!("[RENDER] Paragraph {}: char_range {:?}", idx, char_range); // Extract paragraph source let para_source = rope_slice_to_string(rope, char_range.clone()); let source_hash = hash_source(¶_source); @@ -161,6 +159,45 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { }); } + // Insert gap paragraphs for whitespace between blocks + // This gives the cursor somewhere to land when positioned in newlines + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); + let mut prev_end_char = 0usize; + let mut prev_end_byte = 0usize; + + for para in paragraphs { + // Check for gap before this paragraph + if para.char_range.start > prev_end_char { + let gap_start_char = prev_end_char; + let gap_end_char = para.char_range.start; + let gap_start_byte = prev_end_byte; + let gap_end_byte = para.byte_range.start; + + let gap_node_id = format!("n{}", node_id_offset); + node_id_offset += 1; + let gap_html = format!(r#"{}"#, gap_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + byte_range: gap_start_byte..gap_end_byte, + char_range: gap_start_char..gap_end_char, + html: gap_html, + offset_map: vec![OffsetMapping { + byte_range: gap_start_byte..gap_end_byte, + char_range: gap_start_char..gap_end_char, + node_id: gap_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, // zero-width space represents the gap + }], + source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), + }); + } + + prev_end_char = para.char_range.end; + prev_end_byte = para.byte_range.end; + paragraphs_with_gaps.push(para); + } + // Check if rope ends with trailing newlines (empty paragraph at end) // If so, add an empty paragraph div for cursor positioning let source = rope.to_string(); @@ -170,24 +207,27 @@ pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { let doc_end_char = rope.len_chars(); let doc_end_byte = rope.len_bytes(); - let empty_node_id = format!("n{}", node_id_offset); - let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); - - paragraphs.push(ParagraphRender { - byte_range: doc_end_byte..doc_end_byte, - char_range: doc_end_char..doc_end_char + 1, // range for the zero-width space - html: empty_html, - offset_map: vec![OffsetMapping { - byte_range: doc_end_byte..doc_end_byte, - char_range: doc_end_char..doc_end_char + 1, - node_id: empty_node_id, - char_offset_in_node: 0, - child_index: None, - utf16_len: 1, // zero-width space is 1 UTF-16 code unit - }], - source_hash: 0, // always render this paragraph - }); + // Only add if there's actually a gap at the end + if doc_end_char > prev_end_char { + let empty_node_id = format!("n{}", node_id_offset); + let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); + + paragraphs_with_gaps.push(ParagraphRender { + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + html: empty_html, + offset_map: vec![OffsetMapping { + byte_range: prev_end_byte..doc_end_byte, + char_range: prev_end_char..doc_end_char, + node_id: empty_node_id, + char_offset_in_node: 0, + child_index: None, + utf16_len: 1, // zero-width space is 1 UTF-16 code unit + }], + source_hash: 0, // always render this paragraph + }); + } } - paragraphs + paragraphs_with_gaps } diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 68b3530..f3c9fa9 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -288,6 +288,12 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon return Ok(()); } + // Skip gap emission if we're buffering code block content + // The code block handler manages its own syntax emission + if self.code_buffer.is_some() { + return Ok(()); + } + if next_offset > self.last_byte_offset { self.emit_syntax(self.last_byte_offset..next_offset)?; } @@ -501,6 +507,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Track byte and char ranges for code block content let text_char_len = text.chars().count(); + let text_byte_len = text.len(); if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { // Extend existing ranges code_byte_range.end = range.end; @@ -512,6 +519,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.code_buffer_byte_range = Some(range.clone()); self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len); } + // Update offsets so paragraph boundary is correct + self.last_char_offset += text_char_len; + self.last_byte_offset += text_byte_len; } else if !self.in_non_writing_block { // Escape HTML and count chars in one pass let char_start = self.last_char_offset; @@ -533,75 +543,74 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } Code(text) => { - // Emit opening backtick - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if raw_text.starts_with('`') { - self.write("`")?; - } + let char_start = self.last_char_offset; + let raw_text = &self.source[range.clone()]; + + // Emit opening backtick and track it + if raw_text.starts_with('`') { + self.write("`")?; + self.last_char_offset += 1; } self.write("")?; // Track offset mapping for code content - let char_start = self.last_char_offset; + let content_char_start = self.last_char_offset; let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; - let char_end = char_start + text_char_len; + let content_char_end = content_char_start + text_char_len; // Record offset mapping (code content is visible) - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; + self.record_mapping(range.clone(), content_char_start..content_char_end); + self.last_char_offset = content_char_end; self.write("")?; - // Emit closing backtick - if range.start < range.end { - let raw_text = &self.source[range]; - if raw_text.ends_with('`') { - self.write("`")?; - } + // Emit closing backtick and track it + if raw_text.ends_with('`') { + self.write("`")?; + self.last_char_offset += 1; } } InlineMath(text) => { - // Emit opening $ - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if raw_text.starts_with('$') { - self.write("$")?; - } + let raw_text = &self.source[range.clone()]; + + // Emit opening $ and track it + if raw_text.starts_with('$') { + self.write("$")?; + self.last_char_offset += 1; } self.write(r#""#)?; + let text_char_len = text.chars().count(); escape_html(&mut self.writer, &text)?; + self.last_char_offset += text_char_len; self.write("")?; - // Emit closing $ - if range.start < range.end { - let raw_text = &self.source[range]; - if raw_text.ends_with('$') { - self.write("$")?; - } + // Emit closing $ and track it + if raw_text.ends_with('$') { + self.write("$")?; + self.last_char_offset += 1; } } DisplayMath(text) => { - // Emit opening $$ - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if raw_text.starts_with("$$") { - self.write("$$")?; - } + let raw_text = &self.source[range.clone()]; + + // Emit opening $$ and track it + if raw_text.starts_with("$$") { + self.write("$$")?; + self.last_char_offset += 2; } self.write(r#""#)?; + let text_char_len = text.chars().count(); escape_html(&mut self.writer, &text)?; + self.last_char_offset += text_char_len; self.write("")?; - // Emit closing $$ - if range.start < range.end { - let raw_text = &self.source[range]; - if raw_text.ends_with("$$") { - self.write("$$")?; - } + // Emit closing $$ and track it + if raw_text.ends_with("$$") { + self.write("$$")?; + self.last_char_offset += 2; } } Html(html) | InlineHtml(html) => { @@ -1018,6 +1027,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Ok(()) } Tag::CodeBlock(info) => { + // Track code block as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + if !self.end_newline { self.write_newline()?; } @@ -1027,15 +1039,19 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon match info { CodeBlockKind::Fenced(info) => { - // Emit opening ```language + // Emit opening ```language and track both char and byte offsets if range.start < range.end { - let raw_text = &self.source[range]; + let raw_text = &self.source[range.clone()]; if let Some(fence_pos) = raw_text.find("```") { let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); let syntax = &raw_text[fence_pos..fence_end]; + let syntax_char_len = syntax.chars().count() + 1; // +1 for newline + let syntax_byte_len = syntax.len() + 1; // +1 for newline self.write("")?; escape_html(&mut self.writer, syntax)?; self.write("\n")?; + self.last_char_offset += syntax_char_len; + self.last_byte_offset = range.start + fence_pos + syntax_byte_len; } } @@ -1063,6 +1079,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } Tag::List(Some(1)) => { + // Track list as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); if self.end_newline { self.write("
    \n") } else { @@ -1070,6 +1088,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } Tag::List(Some(start)) => { + // Track list as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); if self.end_newline { self.write("
      , Range)>, W: StrWrite, E: EmbedCon self.write("\">\n") } Tag::List(None) => { + // Track list as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); if self.end_newline { self.write("
        \n") } else { @@ -1098,12 +1120,15 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Begin node tracking self.begin_node(node_id); - // Emit list marker syntax inside the
      • tag + // Emit list marker syntax inside the
      • tag and track both offsets if range.start < range.end { - let raw_text = &self.source[range]; + let raw_text = &self.source[range.clone()]; // Try to find the list marker (-, *, or digit.) let trimmed = raw_text.trim_start(); + let leading_ws_bytes = raw_text.len() - trimmed.len(); + let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count(); + if let Some(marker) = trimmed.chars().next() { if marker == '-' || marker == '*' { // Unordered list: extract "- " or "* " @@ -1112,17 +1137,25 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon .map(|pos| pos + 1) .unwrap_or(1); let syntax = &trimmed[..marker_end.min(trimmed.len())]; + let syntax_char_len = leading_ws_chars + syntax.chars().count(); + let syntax_byte_len = leading_ws_bytes + syntax.len(); self.write("")?; escape_html(&mut self.writer, syntax)?; self.write("")?; + self.last_char_offset += syntax_char_len; + self.last_byte_offset = range.start + syntax_byte_len; } else if marker.is_ascii_digit() { // Ordered list: extract "1. " or similar if let Some(dot_pos) = trimmed.find('.') { let syntax_end = (dot_pos + 2).min(trimmed.len()); let syntax = &trimmed[..syntax_end].trim_end(); + let syntax_char_len = leading_ws_chars + syntax.chars().count(); + let syntax_byte_len = leading_ws_bytes + syntax.len(); self.write("")?; escape_html(&mut self.writer, syntax)?; self.write("")?; + self.last_char_offset += syntax_char_len; + self.last_byte_offset = range.start + syntax_byte_len; } } } @@ -1407,20 +1440,49 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.write("\n")?; } - // Emit closing ``` + // Emit closing ``` (emit_gap_before is skipped while buffering) if range.start < range.end { let raw_text = &self.source[range.clone()]; if let Some(fence_line) = raw_text.lines().last() { - if fence_line.trim() == "```" { - self.write("```")?; + if fence_line.trim().starts_with("```") { + let fence = fence_line.trim(); + let fence_char_len = fence.chars().count(); + self.write("")?; + escape_html(&mut self.writer, fence)?; + self.write("")?; + self.last_char_offset += fence_char_len; + self.last_byte_offset += fence.len(); } } } + // Record code block end for paragraph boundary tracking + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.paragraph_ranges.push((byte_range, char_range)); + } + Ok(()) } - TagEnd::List(true) => self.write("
    \n"), - TagEnd::List(false) => self.write("\n"), + TagEnd::List(true) => { + // Record list end for paragraph boundary tracking + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.paragraph_ranges.push((byte_range, char_range)); + } + self.write("
\n") + } + TagEnd::List(false) => { + // Record list end for paragraph boundary tracking + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.paragraph_ranges.push((byte_range, char_range)); + } + self.write("\n") + } TagEnd::Item => { self.end_node(); self.write("\n") -- 2.51.2