diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 9be046d..567edb2 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -86,16 +86,39 @@ .md-syntax-inline { color: var(--color-muted); opacity: 0.6; - user-select: none; +} + +.md-syntax-inline:[hidden] { + color: var(--color-muted); + opacity: 0.6; + width: 0; + user-select: none; /* idk if we want this when its hidden or not */ } /* Markdown syntax characters - block level (#, >, -, etc) */ .md-syntax-block { color: var(--color-muted); opacity: 0.7; - user-select: none; + width: 0; font-weight: normal; } +.md-syntax-block:[hidden] { + content: attr(data-syntax); + display: inline-block; + margin-right: 4px; + user-select: none; /* idk if we want this when its hidden or not */ +} + +/* Cursor positioning helper after
*/ +.br-cursor { + display: inline-block; + font-size: 0; + width: 0; + height: 1em; /* force height so cursor is visible */ + line-height: 1em; + vertical-align: baseline; +} + /* Future: contextual hiding based on cursor position */ /* .cursor-active .md-syntax-inline { display: none; } */ diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 99e24e2..b734972 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -48,6 +48,10 @@ pub fn restore_cursor_position( let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset) .ok_or("no mapping found for cursor offset")?; + tracing::info!("[CURSOR] Restoring cursor at offset {}", char_offset); + tracing::info!("[CURSOR] found mapping: char_range {:?}, node_id '{}', char_offset_in_node {}", + mapping.char_range, mapping.node_id, mapping.char_offset_in_node); + // If cursor is in invisible content, snap to next visible position // For now, we'll still use the mapping but this is a future enhancement if should_snap { @@ -58,9 +62,13 @@ pub fn restore_cursor_position( let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; - // Get the container element by node ID + // Get the container element by node ID (try id attribute first, then data-node-id) let container = document .get_element_by_id(&mapping.node_id) + .or_else(|| { + let selector = format!("[data-node-id='{}']", mapping.node_id); + document.query_selector(&selector).ok().flatten() + }) .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; // Set selection using Range API @@ -116,15 +124,19 @@ fn find_text_node_at_offset( let mut accumulated_utf16 = 0; let mut last_node: Option = None; + tracing::info!("[CURSOR] Walking text nodes, target_utf16_offset = {}", target_utf16_offset); while let Some(node) = walker.next_node()? { last_node = Some(node.clone()); if let Some(text) = node.text_content() { let text_len = text.encode_utf16().count(); + tracing::info!("[CURSOR] text node: '{}' (utf16_len {}), accumulated = {}", + text.chars().take(20).collect::(), text_len, accumulated_utf16); // Found the node containing target offset if accumulated_utf16 + text_len >= target_utf16_offset { let offset_in_node = target_utf16_offset - accumulated_utf16; + tracing::info!("[CURSOR] -> FOUND at offset {} in this node", offset_in_node); return Ok((node, offset_in_node)); } diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 72b3809..4233c26 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -8,7 +8,7 @@ mod cursor; mod document; mod formatting; mod offset_map; -mod offsets; +mod paragraph; mod render; mod rope_writer; mod storage; @@ -18,10 +18,12 @@ mod writer; pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; -pub use render::render_markdown_simple; +pub use paragraph::ParagraphRender; +pub use render::{render_markdown_simple, render_paragraphs}; pub use rope_writer::RopeWriter; pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; pub use toolbar::EditorToolbar; +pub use writer::WriterResult; use dioxus::prelude::*; @@ -58,10 +60,57 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let mut document = use_signal(|| EditorDocument::new(restored())); let editor_id = "markdown-editor"; - // Render markdown to HTML with offset mappings - let render_result = use_memo(move || render::render_markdown_simple(&document().to_string())); - let rendered_html = use_memo(move || render_result.read().html.clone()); - let offset_map = use_memo(move || render_result.read().offset_map.clone()); + // Render paragraphs for incremental updates + let paragraphs = use_memo(move || render::render_paragraphs(&document().rope)); + + // Flatten offset maps from all paragraphs + let offset_map = use_memo(move || { + paragraphs() + .iter() + .flat_map(|p| p.offset_map.iter().cloned()) + .collect::>() + }); + + // Track previous paragraphs for change detection (outside effect so it persists) + let mut prev_paragraphs = use_signal(|| Vec::::new()); + + // Update DOM when paragraphs change (incremental rendering) + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + 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(); + + let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); + + // Only restore cursor if we actually re-rendered the paragraph it's in + if cursor_para_updated { + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + + let rope = document().rope.clone(); + let map = offset_map(); + + // Use requestAnimationFrame to wait for browser paint + if let Some(window) = web_sys::window() { + let closure = Closure::once(move || { + if let Err(e) = + cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) + { + tracing::warn!("Cursor restoration failed: {:?}", e); + } + }); + + let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); + closure.forget(); + } + } + + // Store for next comparison (write-only, no reactive read) + prev_paragraphs.set(new_paras); + }); // Auto-save with debounce #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -75,29 +124,6 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { timer.forget(); }); - // Restore cursor after re-render - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - use_effect(move || { - use wasm_bindgen::prelude::*; - use wasm_bindgen::JsCast; - - let cursor_offset = document().cursor.offset; - let rope = document().rope.clone(); - let map = offset_map.read().clone(); - - // Use requestAnimationFrame to wait for browser paint - let window = web_sys::window().expect("no window"); - - let closure = Closure::once(move || { - if let Err(e) = cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) { - tracing::warn!("Cursor restoration failed: {:?}", e); - } - }); - - let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); - closure.forget(); - }); - rsx! { Stylesheet { href: asset!("/assets/styling/editor.css") } div { class: "markdown-editor-container", @@ -111,20 +137,31 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { id: "{editor_id}", class: "editor-content", contenteditable: "true", - dangerous_inner_html: "{rendered_html}", + // DOM populated via web-sys in use_effect for incremental updates onkeydown: move |evt| { - evt.prevent_default(); - handle_keydown(evt, &mut document); + // Only prevent default for operations that modify content + // Let browser handle arrow keys, Home/End naturally + if should_intercept_key(&evt) { + evt.prevent_default(); + handle_keydown(evt, &mut document); + } + }, + + onkeyup: move |evt| { + // After any key (including arrow keys), sync cursor from DOM + sync_cursor_from_dom(&mut document, editor_id); + }, + + onclick: move |_evt| { + // After mouse click, sync cursor from DOM + sync_cursor_from_dom(&mut document, editor_id); }, onpaste: move |evt| { evt.prevent_default(); handle_paste(evt, &mut document); }, - - // Phase 1: Accept that cursor position will jump - // Phase 2: Restore cursor properly } @@ -141,6 +178,164 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } } +/// Check if we need to intercept this key event +/// Returns true for content-modifying operations, false for navigation +fn should_intercept_key(evt: &Event) -> bool { + use dioxus::prelude::keyboard_types::Key; + + let key = evt.key(); + let mods = evt.modifiers(); + + // Intercept shortcuts + if mods.ctrl() || mods.meta() { + return true; + } + + // Intercept content modifications + matches!( + key, + Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab + ) +} + +/// Sync internal cursor state from browser DOM selection +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn sync_cursor_from_dom(document: &mut Signal, editor_id: &str) { + use wasm_bindgen::JsCast; + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + + let dom_document = match window.document() { + Some(d) => d, + 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, + }; + + let selection = match window.get_selection() { + Ok(Some(sel)) => sel, + _ => return, + }; + + // Get cursor position from selection + let focus_node = match selection.focus_node() { + Some(node) => node, + None => return, + }; + + 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(); + let node_id = loop { + if let Some(element) = current_node.dyn_ref::() { + // Stop if we've reached the editor boundary + 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, + }; + }; + + let node_id = match node_id { + Some(id) => id, + None => { + tracing::warn!("Could not find node_id for cursor position"); + return; + } + }; + + let container = match 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 + 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; + break; + } + + // Accumulate length of previous text nodes + if let Some(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; + } + } + } + } + + tracing::warn!("Could not map DOM cursor position to rope offset"); + }); +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +fn sync_cursor_from_dom(_document: &mut Signal, _editor_id: &str) { + // 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 @@ -213,10 +408,52 @@ fn handle_keydown(evt: Event, document: &mut Signal 0 { - // Delete previous char - let prev = doc.cursor.offset - 1; - doc.rope.remove(prev..doc.cursor.offset); - doc.cursor.offset = prev; + // Check if we're about to delete a newline + let prev_char = get_char_at(&doc.rope, doc.cursor.offset - 1); + + if prev_char == Some('\n') { + let newline_pos = doc.cursor.offset - 1; + let mut delete_start = newline_pos; + let mut delete_end = doc.cursor.offset; + + // Check if there's another newline before this one (empty paragraph) + // If so, delete both newlines to merge paragraphs + if newline_pos > 0 { + let prev_prev_char = get_char_at(&doc.rope, newline_pos - 1); + if prev_prev_char == Some('\n') { + // Empty paragraph case: delete both newlines + delete_start = newline_pos - 1; + } + } + + // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) + if let Some(ch) = get_char_at(&doc.rope, delete_end) { + if ch == '\u{200C}' || ch == '\u{200B}' { + delete_end += 1; + } + } + + // Scan backwards through whitespace before the newline(s) + while delete_start > 0 { + let ch = get_char_at(&doc.rope, delete_start - 1); + match ch { + Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => { + delete_start -= 1; + } + Some('\n') => break, // stop at another newline + _ => break, // stop at actual content + } + } + + // Delete from where we stopped to end (including any trailing zero-width) + doc.rope.remove(delete_start..delete_end); + doc.cursor.offset = delete_start; + } else { + // Normal backspace - delete one char + let prev = doc.cursor.offset - 1; + doc.rope.remove(prev..doc.cursor.offset); + doc.cursor.offset = prev; + } } } @@ -233,28 +470,9 @@ fn handle_keydown(evt: Event, document: &mut Signal { - if mods.ctrl() { - // Word boundary (implement later) - if doc.cursor.offset > 0 { - doc.cursor.offset -= 1; - } - } else if doc.cursor.offset > 0 { - doc.cursor.offset -= 1; - } - doc.selection = None; - } - - Key::ArrowRight => { - if mods.ctrl() { - // Word boundary (implement later) - if doc.cursor.offset < doc.len_chars() { - doc.cursor.offset += 1; - } - } else if doc.cursor.offset < doc.len_chars() { - doc.cursor.offset += 1; - } - doc.selection = None; + // Arrow keys handled by browser, synced in onkeyup + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { + // Browser handles these naturally } Key::Enter => { @@ -265,21 +483,31 @@ fn handle_keydown(evt: Event, document: &mut Signal { - let line_start = find_line_start(&doc.rope, doc.cursor.offset); - doc.cursor.offset = line_start; - doc.selection = None; + if mods.shift() { + // Shift+Enter: hard line break (soft break) + doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); + doc.cursor.offset += 3; + } else { + // Enter: paragraph break (much cleaner, less jank) + tracing::info!( + "[ENTER] Before insert - cursor at {}, rope len {}", + doc.cursor.offset, + doc.len_chars() + ); + doc.rope.insert(doc.cursor.offset, "\n\n"); + doc.cursor.offset += 2; + tracing::info!( + "[ENTER] After insert - cursor at {}, rope len {}", + doc.cursor.offset, + doc.len_chars() + ); + } } - Key::End => { - let line_end = find_line_end(&doc.rope, doc.cursor.offset); - doc.cursor.offset = line_end; - doc.selection = None; + // Home/End handled by browser, synced in onkeyup + Key::Home | Key::End => { + // Browser handles these naturally } _ => {} @@ -287,6 +515,25 @@ fn handle_keydown(evt: Event, document: &mut Signal Option { + if offset >= rope.len_chars() { + return None; + } + + let rope = rope.borrow(); + let mut current = 0; + for substr in rope.slice_substrings(offset..offset + 1) { + for c in substr.chars() { + if current == 0 { + return Some(c); + } + current += 1; + } + } + None +} + /// Find start of line containing offset fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { // Search backwards from cursor for newline @@ -326,3 +573,113 @@ fn find_line_end(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { rope.len_chars() } + +/// Update paragraph DOM elements incrementally. +/// +/// Only modifies paragraphs that changed (by comparing source_hash). +/// Browser preserves cursor naturally in unchanged paragraphs. +/// +/// Returns true if the paragraph containing the cursor was updated. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn update_paragraph_dom( + editor_id: &str, + old_paragraphs: &[ParagraphRender], + new_paragraphs: &[ParagraphRender], + cursor_offset: usize, +) -> bool { + use wasm_bindgen::JsCast; + + let window = match web_sys::window() { + Some(w) => w, + None => return false, + }; + + let document = match window.document() { + Some(d) => d, + None => return false, + }; + + let editor = match document.get_element_by_id(editor_id) { + Some(e) => e, + None => return false, + }; + + // Find which paragraph contains cursor + // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) + // This handles typing at end of paragraph, which is the common case + // The empty paragraph at document end catches any trailing cursor positions + let cursor_para_idx = new_paragraphs + .iter() + .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); + + tracing::info!( + "[DOM] cursor_offset = {}, cursor_para_idx = {:?}", + cursor_offset, + cursor_para_idx + ); + for (idx, para) in new_paragraphs.iter().enumerate() { + let matches = + para.char_range.start <= cursor_offset && cursor_offset <= para.char_range.end; + tracing::info!( + "[DOM] para {}: char_range {:?}, matches cursor? {}", + idx, + para.char_range, + matches + ); + } + + let mut cursor_para_updated = false; + + // Update or create paragraphs + for (idx, new_para) in new_paragraphs.iter().enumerate() { + let para_id = format!("para-{}", idx); + + if let Some(old_para) = old_paragraphs.get(idx) { + // Paragraph exists - check if changed + if new_para.source_hash != old_para.source_hash { + // Changed - update innerHTML + if let Some(elem) = document.get_element_by_id(¶_id) { + elem.set_inner_html(&new_para.html); + } + + // Track if we updated the cursor's paragraph + if Some(idx) == cursor_para_idx { + cursor_para_updated = true; + } + } + // Unchanged - do nothing, browser preserves cursor + } else { + // New paragraph - create div + if let Ok(div) = document.create_element("div") { + div.set_id(¶_id); + div.set_inner_html(&new_para.html); + let _ = editor.append_child(&div); + } + + // Track if we created the cursor's paragraph + if Some(idx) == cursor_para_idx { + cursor_para_updated = true; + } + } + } + + // Remove extra paragraphs if document got shorter + for idx in new_paragraphs.len()..old_paragraphs.len() { + let para_id = format!("para-{}", idx); + if let Some(elem) = document.get_element_by_id(¶_id) { + let _ = elem.remove(); + } + } + + cursor_para_updated +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +fn update_paragraph_dom( + _editor_id: &str, + _old_paragraphs: &[ParagraphRender], + _new_paragraphs: &[ParagraphRender], + _cursor_offset: usize, +) -> bool { + false +} diff --git a/crates/weaver-app/src/components/editor/offsets.rs b/crates/weaver-app/src/components/editor/offsets.rs deleted file mode 100644 index 2882e50..0000000 --- a/crates/weaver-app/src/components/editor/offsets.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Offset conversion utilities for converting between different offset systems. -//! -//! The editor deals with multiple offset systems: -//! 1. **JumpRope**: Unicode scalar values (Rust `char` count) -//! 2. **markdown-weaver**: UTF-8 byte offsets -//! 3. **Rust strings**: UTF-8 byte indexing -//! 4. **JavaScript DOM**: UTF-16 code units (Phase 2+) -//! -//! # Performance Notes -//! -//! **Prefer JumpRope's built-in methods:** -//! - `rope.len_chars()` - O(1) character count -//! - `rope.len_bytes()` - O(1) byte count -//! - `rope.len_wchars()` - O(1) UTF-16 code unit count (Phase 2 with wchar_conversion) -//! -//! **Only use these conversion functions when:** -//! - Converting markdown-weaver byte offsets to char offsets -//! - Converting char offsets to byte offsets for markdown parsing -//! -//! For Phase 2+, use JumpRope's O(log n) UTF-16 conversions via the helpers below: -//! - `char_to_utf16()` - O(log n) -//! - `utf16_to_char()` - O(log n) - -/// Convert JumpRope char offset to UTF-8 byte offset. -/// -/// This is O(n) but acceptable for Phase 1 since we only render once per keystroke. -/// For Phase 2+, we can optimize by caching or using string-offsets crate. -/// -/// # Example -/// ``` -/// let text = "Hello 🐻‍❄️ World"; -/// // "Hello " = 6 chars, 6 bytes -/// // "🐻‍❄️" = 4 chars, 13 bytes -/// // Total at char 6 = byte 6 -/// assert_eq!(char_to_byte(text, 6), 6); -/// // Total at char 10 (after emoji) = byte 19 -/// assert_eq!(char_to_byte(text, 10), 19); -/// ``` -pub fn char_to_byte(text: &str, char_offset: usize) -> usize { - text.char_indices() - .nth(char_offset) - .map(|(byte_idx, _)| byte_idx) - .unwrap_or(text.len()) -} - -/// Convert UTF-8 byte offset to JumpRope char offset. -/// -/// Used when we need to map markdown-weaver byte offsets back to rope positions. -/// -/// # Example -/// ``` -/// let text = "Hello 🐻‍❄️ World"; -/// assert_eq!(byte_to_char(text, 6), 6); -/// assert_eq!(byte_to_char(text, 19), 10); -/// ``` -pub fn byte_to_char(text: &str, byte_offset: usize) -> usize { - text.char_indices() - .take_while(|(idx, _)| *idx < byte_offset) - .count() -} - -/// Convert JumpRope char offset to UTF-16 code units (for DOM Selection API). -/// -/// O(log n) - uses JumpRope's internal index. -/// -/// # Example -/// ``` -/// let rope = JumpRopeBuf::from("🐻‍❄️"); -/// // Polar bear is 4 chars, 5 UTF-16 code units -/// assert_eq!(char_to_utf16(&rope, 0), 0); -/// assert_eq!(char_to_utf16(&rope, 4), 5); -/// ``` -pub fn char_to_utf16(rope: &jumprope::JumpRopeBuf, char_offset: usize) -> usize { - rope.borrow().chars_to_wchars(char_offset) -} - -/// Convert UTF-16 code units (from DOM) to JumpRope char offset. -/// -/// O(log n) - uses JumpRope's internal index. -/// -/// # Example -/// ``` -/// let rope = JumpRopeBuf::from("🐻‍❄️"); -/// assert_eq!(utf16_to_char(&rope, 0), 0); -/// assert_eq!(utf16_to_char(&rope, 5), 4); -/// ``` -pub fn utf16_to_char(rope: &jumprope::JumpRopeBuf, utf16_offset: usize) -> usize { - rope.borrow().wchars_to_chars(utf16_offset) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ascii() { - let text = "hello"; - assert_eq!(char_to_byte(text, 0), 0); - assert_eq!(char_to_byte(text, 2), 2); - assert_eq!(byte_to_char(text, 0), 0); - assert_eq!(byte_to_char(text, 2), 2); - } - - #[test] - fn test_emoji() { - // Polar bear: 4 chars, 13 bytes - let text = "🐻‍❄️"; - assert_eq!(text.chars().count(), 4); - assert_eq!(text.len(), 13); - - assert_eq!(char_to_byte(text, 0), 0); - assert_eq!(char_to_byte(text, 4), 13); - - assert_eq!(byte_to_char(text, 0), 0); - assert_eq!(byte_to_char(text, 13), 4); - } - - #[test] - fn test_mixed() { - let text = "Hello 🐻‍❄️ World"; - // "Hello " = 6 chars, 6 bytes - // "🐻‍❄️" = 4 chars, 13 bytes - // " World" = 6 chars, 6 bytes - // Total: 16 chars, 25 bytes - - assert_eq!(text.chars().count(), 16); - assert_eq!(text.len(), 25); - - // Char 6 is start of emoji (byte 6) - assert_eq!(char_to_byte(text, 6), 6); - // Char 10 is after emoji (byte 19) - assert_eq!(char_to_byte(text, 10), 19); - } -} diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs new file mode 100644 index 0000000..4f5921f --- /dev/null +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -0,0 +1,50 @@ +//! Paragraph-level rendering for incremental updates. +//! +//! Paragraphs are discovered during markdown rendering by tracking +//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. + +use super::offset_map::OffsetMapping; +use jumprope::JumpRopeBuf; +use std::ops::Range; + +/// A rendered paragraph with its source range and offset mappings. +#[derive(Debug, Clone, PartialEq)] +pub struct ParagraphRender { + /// Source byte range in the rope + pub byte_range: Range, + + /// Source char range in the rope + pub char_range: Range, + + /// Rendered HTML content (without wrapper div) + pub html: String, + + /// Offset mappings for this paragraph + pub offset_map: Vec, + + /// Hash of source text for quick change detection + pub source_hash: u64, +} + +/// Simple hash function for source text comparison +pub fn hash_source(text: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +/// Extract substring from rope as String +pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range) -> String { + let rope_borrow = rope.borrow(); + let mut result = String::new(); + + for substr in rope_borrow.slice_substrings(range) { + result.push_str(substr); + } + + result +} + diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index f6af268..3c7bf1b 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -1,13 +1,14 @@ //! Markdown rendering for the editor. //! -//! Phase 2: Full-document rendering with formatting characters visible as styled spans. -//! Future: Incremental paragraph rendering and contextual formatting visibility. +//! Phase 2: Paragraph-level incremental rendering with formatting characters visible. //! //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. -use markdown_weaver::Parser; -use super::offset_map::RenderResult; +use super::offset_map::{OffsetMapping, RenderResult}; +use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string}; use super::writer::EditorWriter; +use jumprope::JumpRopeBuf; +use markdown_weaver::Parser; /// Render markdown to HTML with visible formatting characters and offset mappings. /// @@ -24,21 +25,16 @@ use super::writer::EditorWriter; /// - Offset map generation for cursor restoration /// - Full document re-render (fast enough for current needs) /// -/// # Future improvements -/// - Paragraph-level incremental rendering -/// - Contextual formatting hiding based on cursor position +/// # Deprecated: Use `render_paragraphs()` for incremental rendering pub fn render_markdown_simple(source: &str) -> RenderResult { - use jumprope::JumpRopeBuf; - let source_rope = JumpRopeBuf::from(source); - let parser = Parser::new_ext(source, weaver_renderer::default_md_options()) - .into_offset_iter(); + let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter(); let mut output = String::new(); match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() { - Ok(offset_map) => RenderResult { + Ok(result) => RenderResult { html: output, - offset_map, + offset_map: result.offset_maps, }, Err(_) => { // Fallback to empty result on error @@ -49,3 +45,149 @@ pub fn render_markdown_simple(source: &str) -> RenderResult { } } } + +/// Render markdown in paragraph chunks for incremental DOM updates. +/// +/// First renders the whole document to discover paragraph boundaries via +/// markdown events (Tag::Paragraph), then re-renders each paragraph separately. +/// This allows updating only changed paragraphs in the DOM, preserving cursor +/// position naturally. +/// +/// # Returns +/// +/// A vector of `ParagraphRender` structs, each containing: +/// - Source byte and char ranges +/// - Rendered HTML (without wrapper div) +/// - Offset mappings for that paragraph +/// - Source hash for change detection +/// +/// # Phase 2 Benefits +/// - Only re-render changed paragraphs +/// - Browser preserves cursor in unchanged paragraphs naturally +/// - Faster for large documents +/// - No manual cursor restoration needed for most edits +pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec { + let source = rope.to_string(); + + // Handle empty rope - return single empty paragraph for cursor positioning + if source.is_empty() { + let empty_node_id = "n0".to_string(); + let empty_html = format!(r#"{}"#, empty_node_id, '\u{200B}'); + + return vec![ParagraphRender { + byte_range: 0..0, + char_range: 0..0, + html: empty_html, + offset_map: vec![], + source_hash: 0, + }]; + } + + // First pass: render whole document to get paragraph boundaries + // TODO: CACHE THIS! + let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter(); + let mut scratch_output = String::new(); + + let paragraph_ranges = + match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() { + Ok(result) => result.paragraph_ranges, + Err(_) => return Vec::new(), + }; + + // Second pass: render each paragraph separately + 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); + + // Render this paragraph with unique node IDs + let para_rope = JumpRopeBuf::from(para_source.as_str()); + let parser = + Parser::new_ext(¶_source, weaver_renderer::default_md_options()).into_offset_iter(); + let mut output = String::new(); + + let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset( + ¶_source, + ¶_rope, + parser, + &mut output, + node_id_offset, + ) + .run() + { + Ok(result) => { + // Update node ID offset for next paragraph + // Count how many unique node IDs were used in this paragraph + let max_node_id = result + .offset_maps + .iter() + .filter_map(|m| { + m.node_id + .strip_prefix("n") + .and_then(|s| s.parse::().ok()) + }) + .max() + .unwrap_or(node_id_offset); + node_id_offset = max_node_id + 1; + + result.offset_maps + } + Err(_) => Vec::new(), + }; + + // Adjust offset map to be relative to document, not paragraph + // Each mapping's ranges need to be shifted by paragraph start + let para_char_start = char_range.start; + let para_byte_start = byte_range.start; + + for mapping in &mut offset_map { + mapping.byte_range.start += para_byte_start; + mapping.byte_range.end += para_byte_start; + mapping.char_range.start += para_char_start; + mapping.char_range.end += para_char_start; + } + + paragraphs.push(ParagraphRender { + byte_range: byte_range.clone(), + char_range: char_range.clone(), + html: output, + offset_map, + source_hash, + }); + } + + // 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(); + let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n"); + + if has_trailing_newlines { + 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 + }); + } + + paragraphs +} diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index e854a39..68b3530 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -7,7 +7,6 @@ //! represent consumed formatting characters. use super::offset_map::{OffsetMapping, RenderResult}; -use super::offsets::{byte_to_char, char_to_byte}; use jumprope::JumpRopeBuf; use markdown_weaver::{ Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, @@ -19,6 +18,17 @@ use markdown_weaver_escape::{ use std::collections::HashMap; use std::ops::Range; +/// Result of rendering with the EditorWriter. +#[derive(Debug, Clone)] +pub struct WriterResult { + /// Offset mappings from source to DOM positions + pub offset_maps: Vec, + + /// Paragraph boundaries in source: (byte_range, char_range) + /// These are extracted during rendering by tracking Tag::Paragraph events + pub paragraph_ranges: Vec<(Range, Range)>, +} + /// Classification of markdown syntax characters #[derive(Debug, Clone, Copy, PartialEq)] enum SyntaxClass { @@ -100,6 +110,7 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St code_buffer: Option<(Option, String)>, // (lang, content) code_buffer_byte_range: Option>, // byte range of buffered code content + code_buffer_char_range: Option>, // char range of buffered code content pending_blockquote_range: Option>, // range for emitting > inside next paragraph // Table rendering mode @@ -113,6 +124,10 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, W: St current_node_char_offset: usize, // UTF-16 offset within current node current_node_child_count: usize, // number of child elements/text nodes in current container + // Paragraph boundary tracking for incremental rendering + paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range) + current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) + _phantom: std::marker::PhantomData<&'a ()>, } @@ -126,6 +141,16 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon EditorWriter<'a, I, W, E> { pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self { + Self::new_with_node_offset(source, source_rope, events, writer, 0) + } + + pub fn new_with_node_offset( + source: &'a str, + source_rope: &'a JumpRopeBuf, + events: I, + writer: W, + node_id_offset: usize, + ) -> Self { Self { source, source_rope, @@ -142,14 +167,17 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon embed_provider: None, code_buffer: None, code_buffer_byte_range: None, + code_buffer_char_range: None, pending_blockquote_range: None, render_tables_as_markdown: true, // Default to markdown rendering table_start_offset: None, offset_maps: Vec::new(), - next_node_id: 0, + next_node_id: node_id_offset, current_node_id: None, current_node_char_offset: 0, current_node_child_count: 0, + paragraph_ranges: Vec::new(), + current_paragraph_start: None, _phantom: std::marker::PhantomData, } } @@ -172,6 +200,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon embed_provider: Some(provider), code_buffer: self.code_buffer, code_buffer_byte_range: self.code_buffer_byte_range, + code_buffer_char_range: self.code_buffer_char_range, pending_blockquote_range: self.pending_blockquote_range, render_tables_as_markdown: self.render_tables_as_markdown, table_start_offset: self.table_start_offset, @@ -180,6 +209,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon current_node_id: self.current_node_id, current_node_char_offset: self.current_node_char_offset, current_node_child_count: self.current_node_child_count, + paragraph_ranges: self.paragraph_ranges, + current_paragraph_start: self.current_paragraph_start, _phantom: std::marker::PhantomData, } } @@ -211,14 +242,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let char_start = self.last_char_offset; let syntax_char_len = syntax.chars().count(); - tracing::debug!( - "emit_syntax: range={:?}, chars={}..{}, syntax={:?}", - range, - char_start, - char_start + syntax_char_len, - syntax - ); - // If we're outside any node, create a wrapper span for tracking let created_node = if self.current_node_id.is_none() { let node_id = self.gen_node_id(); @@ -241,8 +264,12 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Record offset mapping for this syntax self.record_mapping(range.clone(), char_start..char_start + syntax_char_len); - self.last_char_offset = char_start + syntax_char_len; - self.last_byte_offset = range.end; // Mark bytes as processed + let new_char = char_start + syntax_char_len; + let new_byte = range.end; + tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", + self.last_char_offset, new_char, self.last_byte_offset, new_byte); + self.last_char_offset = new_char; + self.last_byte_offset = new_byte; // Mark bytes as processed // Close wrapper if we created one if created_node { @@ -300,8 +327,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let utf16_len = wchar_end - wchar_start; let mapping = OffsetMapping { - byte_range, - char_range, + byte_range: byte_range.clone(), + char_range: char_range.clone(), node_id: node_id.clone(), char_offset_in_node: self.current_node_char_offset, child_index: None, // text-based position @@ -309,15 +336,40 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon }; self.offset_maps.push(mapping); self.current_node_char_offset += utf16_len; + } else { + tracing::warn!("[RECORD_MAPPING] SKIPPED - current_node_id is None!"); } } /// Process markdown events and write HTML. /// - /// Returns the offset mappings. The HTML is written to the writer - /// passed in the constructor. - pub fn run(mut self) -> Result, W::Error> { + /// Returns offset mappings and paragraph boundaries. The HTML is written + /// to the writer passed in the constructor. + pub fn run(mut self) -> Result { while let Some((event, range)) = self.events.next() { + // Log events for debugging + tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", + match &event { + Event::Start(tag) => format!("Start({:?})", tag), + Event::End(tag) => format!("End({:?})", tag), + Event::Text(t) => format!("Text('{}')", t), + Event::Code(t) => format!("Code('{}')", t), + Event::Html(t) => format!("Html('{}')", t), + Event::InlineHtml(t) => format!("InlineHtml('{}')", t), + Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), + Event::SoftBreak => "SoftBreak".to_string(), + Event::HardBreak => "HardBreak".to_string(), + Event::Rule => "Rule".to_string(), + Event::TaskListMarker(b) => format!("TaskListMarker({})", b), + Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), + Event::InlineMath(t) => format!("InlineMath('{}')", t), + Event::DisplayMath(t) => format!("DisplayMath('{}')", t), + }, + &range, + self.last_byte_offset, + self.last_char_offset + ); + // For End events, emit any trailing content within the event's range // BEFORE calling end_tag (which calls end_node and clears current_node_id) if matches!(&event, Event::End(_)) { @@ -330,11 +382,19 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.emit_gap_before(range.start)?; } + // Store last_byte before processing + let last_byte_before = self.last_byte_offset; + // Process the event (passing range for tag syntax) self.process_event(event, range.clone())?; - // Update tracking - self.last_byte_offset = range.end; + // Update tracking - but don't override if start_tag manually updated it + // (for inline formatting tags that emit opening syntax) + if self.last_byte_offset == last_byte_before { + // Event didn't update offset, so we update it + self.last_byte_offset = range.end; + } + // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value } // Emit any trailing syntax @@ -346,14 +406,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let doc_char_len = self.source_rope.len_chars(); if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { - tracing::debug!( - "Unmapped trailing content: bytes {}..{}, chars {}..{}", - self.last_byte_offset, - doc_byte_len, - self.last_char_offset, - doc_char_len - ); - // Emit the trailing content as visible syntax if self.last_byte_offset < doc_byte_len { let trailing = &self.source[self.last_byte_offset..]; @@ -384,7 +436,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } - Ok(self.offset_maps) + Ok(WriterResult { + offset_maps: self.offset_maps, + paragraph_ranges: self.paragraph_ranges, + }) } // Consume raw text events until end tag, for alt attributes @@ -436,16 +491,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), W::Error> { use Event::*; - tracing::debug!( - "Event: {:?}, range: {:?}", - match &event { - Start(tag) => format!("Start({:?})", tag), - End(tag) => format!("End({:?})", tag), - Text(t) => format!("Text({:?})", &t[..t.len().min(20)]), - _ => format!("{:?}", event), - }, - range - ); match event { Start(tag) => self.start_tag(tag, range)?, End(tag) => self.end_tag(tag, range)?, @@ -454,13 +499,18 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if let Some((_, ref mut buffer)) = self.code_buffer { buffer.push_str(&text); - // Track byte range for code block content - if let Some(ref mut code_range) = self.code_buffer_byte_range { - // Extend existing range - code_range.end = range.end; + // Track byte and char ranges for code block content + let text_char_len = text.chars().count(); + if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { + // Extend existing ranges + code_byte_range.end = range.end; + if let Some(ref mut code_char_range) = self.code_buffer_char_range { + code_char_range.end = self.last_char_offset + text_char_len; + } } else { // First text in code block - start tracking 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); } } else if !self.in_non_writing_block { // Escape HTML and count chars in one pass @@ -469,14 +519,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon escape_html_body_text_with_char_count(&mut self.writer, &text)?; let char_end = char_start + text_char_len; - tracing::debug!( - "Text event: range={:?}, chars={}..{}, text={:?}", - range, - char_start, - char_end, - &text[..text.len().min(40)] - ); - // Text becomes a text node child of the current container if text_char_len > 0 { self.current_node_child_count += 1; @@ -580,7 +622,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let gap = &self.source[range.clone()]; if gap.ends_with('\n') { let spaces = &gap[..gap.len() - 1]; // everything except the \n - let char_start = byte_to_char(self.source, range.start); + let char_start = self.last_char_offset; let spaces_char_len = spaces.chars().count(); // Emit and map the visible spaces @@ -602,22 +644,31 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Count the
as a child self.current_node_child_count += 1; - // Map the newline to an element-based position (after the
) - // The binary search is end-inclusive, so cursor at position N+1 - // will match a mapping with range N..N+1 + // After
, emit plain zero-width space for cursor positioning + self.write("\u{200B}")?; + + // Count the zero-width space text node as a child + self.current_node_child_count += 1; + + // Map the newline position to the zero-width space text node if let Some(ref node_id) = self.current_node_id { let newline_char_offset = char_start + spaces_char_len; let mapping = OffsetMapping { byte_range: range.start + spaces.len()..range.end, char_range: newline_char_offset..newline_char_offset + 1, node_id: node_id.clone(), - char_offset_in_node: 0, - child_index: Some(self.current_node_child_count), - utf16_len: 0, + char_offset_in_node: self.current_node_char_offset, + child_index: None, // text node - TreeWalker will find it + utf16_len: 1, // zero-width space is 1 UTF-16 unit }; self.offset_maps.push(mapping); + + // Increment char offset - TreeWalker will encounter this text node + self.current_node_char_offset += 1; } + // DO NOT increment last_char_offset - zero-width space is not in source + // The \n itself IS in source, so we already accounted for it self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n } else { // Fallback: just
@@ -724,11 +775,23 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon SyntaxClass::Inline => "md-syntax-inline", SyntaxClass::Block => "md-syntax-block", }; + + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let syntax_byte_len = syntax.len(); + self.write("")?; escape_html(&mut self.writer, syntax)?; self.write("")?; + + // Update tracking - we've consumed this opening syntax + tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", + syntax, self.last_char_offset, char_start + syntax_char_len, + self.last_byte_offset, range.start + syntax_byte_len); + self.last_char_offset = char_start + syntax_char_len; + self.last_byte_offset = range.start + syntax_byte_len; } } @@ -736,6 +799,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon match tag { Tag::HtmlBlock => Ok(()), Tag::Paragraph => { + // Record paragraph start for boundary tracking + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + let node_id = self.gen_node_id(); if self.end_newline { write!(&mut self.writer, "

", node_id)?; @@ -791,6 +857,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon classes, attrs, } => { + // Record paragraph start for boundary tracking + // Treat headings as paragraph-level blocks + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + if !self.end_newline { self.write("\n")?; } @@ -835,11 +905,24 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.write(">")?; // Begin node tracking for offset mapping - self.begin_node(node_id); + self.begin_node(node_id.clone()); + + // Map the start position of the heading (before any content) + // This allows cursor to be placed at the very beginning + let heading_start_char = self.last_char_offset; + let mapping = OffsetMapping { + byte_range: range.start..range.start, + char_range: heading_start_char..heading_start_char, + node_id: node_id.clone(), + char_offset_in_node: 0, + child_index: Some(0), // position before first child + utf16_len: 0, + }; + self.offset_maps.push(mapping); // Emit # syntax inside the heading tag if range.start < range.end { - let raw_text = &self.source[range]; + let raw_text = &self.source[range.clone()]; let count = level as usize; let pattern = "#".repeat(count); @@ -849,10 +932,24 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syntax_start = hash_pos; let syntax_end = (hash_pos + count + 1).min(raw_text.len()); let syntax = &raw_text[syntax_start..syntax_end]; + let syntax_char_len = syntax.chars().count(); + + // Calculate byte range for this syntax in the source + let syntax_byte_start = range.start + syntax_start; + let syntax_byte_end = range.start + syntax_end; + let char_start = self.last_char_offset; self.write("")?; escape_html(&mut self.writer, syntax)?; self.write("")?; + + // Record offset mapping and update char tracking + // Note: last_byte_offset is managed by the main event loop + self.record_mapping( + syntax_byte_start..syntax_byte_end, + char_start..char_start + syntax_char_len + ); + self.last_char_offset = char_start + syntax_char_len; } } Ok(()) @@ -1193,10 +1290,24 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let result = match tag { TagEnd::HtmlBlock => Ok(()), TagEnd::Paragraph => { + // Record paragraph end for 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.end_node(); self.write("

\n") } TagEnd::Heading(level) => { + // Record paragraph end for 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.end_node(); self.write(", Range)>, W: StrWrite, E: EmbedCon LazyLock::new(|| SyntaxSet::load_defaults_newlines()); if let Some((lang, buffer)) = self.code_buffer.take() { - // Create offset mapping for code block content if we tracked a range - if let Some(code_byte_range) = self.code_buffer_byte_range.take() { - // Calculate char range from the tracked byte range - let char_start = byte_to_char(self.source, code_byte_range.start); - let char_end = byte_to_char(self.source, code_byte_range.end); - let char_range = char_start..char_end; - + // Create offset mapping for code block content if we tracked ranges + if let (Some(code_byte_range), Some(code_char_range)) = + (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) { // Record mapping before writing HTML // (current_node_id should be set by start_tag for CodeBlock) - self.record_mapping(code_byte_range, char_range); + self.record_mapping(code_byte_range, code_char_range); } if let Some(ref lang_str) = lang { @@ -1348,65 +1455,9 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon result?; - // Extract and emit closing syntax based on tag type - if range.start < range.end { - let raw_text = &self.source[range]; - let closing_syntax = match &tag { - TagEnd::Strong => { - if raw_text.ends_with("**") { - Some("**") - } else if raw_text.ends_with("__") { - Some("__") - } else { - None - } - } - TagEnd::Emphasis => { - if raw_text.ends_with("*") { - Some("*") - } else if raw_text.ends_with("_") { - Some("_") - } else { - None - } - } - TagEnd::Strikethrough => { - if raw_text.ends_with("~~") { - Some("~~") - } else { - None - } - } - TagEnd::Link => { - // Extract ](url) part - if let Some(idx) = raw_text.rfind("](") { - Some(&raw_text[idx..]) - } else { - None - } - } - TagEnd::CodeBlock => { - if raw_text.ends_with("```") { - raw_text.lines().last() - } else { - None - } - } - _ => None, - }; - - if let Some(syntax) = closing_syntax { - let class = match classify_syntax(syntax) { - SyntaxClass::Inline => "md-syntax-inline", - SyntaxClass::Block => "md-syntax-block", - }; - self.write("")?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; - } - } + // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled + // by emit_gap_before(range.end) which is called before end_tag() in the main loop. + // No need for manual emission here anymore. Ok(()) }