···8686.md-syntax-inline {
8787 color: var(--color-muted);
8888 opacity: 0.6;
8989- user-select: none;
8989+}
9090+9191+.md-syntax-inline:[hidden] {
9292+ color: var(--color-muted);
9393+ opacity: 0.6;
9494+ width: 0;
9595+ user-select: none; /* idk if we want this when its hidden or not */
9096}
91979298/* Markdown syntax characters - block level (#, >, -, etc) */
9399.md-syntax-block {
94100 color: var(--color-muted);
95101 opacity: 0.7;
9696- user-select: none;
102102+ width: 0;
97103 font-weight: normal;
104104+}
105105+106106+.md-syntax-block:[hidden] {
107107+ content: attr(data-syntax);
108108+ display: inline-block;
109109+ margin-right: 4px;
110110+ user-select: none; /* idk if we want this when its hidden or not */
111111+}
112112+113113+/* Cursor positioning helper after <br> */
114114+.br-cursor {
115115+ display: inline-block;
116116+ font-size: 0;
117117+ width: 0;
118118+ height: 1em; /* force height so cursor is visible */
119119+ line-height: 1em;
120120+ vertical-align: baseline;
98121}
99122100123/* Future: contextual hiding based on cursor position */
+13-1
crates/weaver-app/src/components/editor/cursor.rs
···4848 let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
4949 .ok_or("no mapping found for cursor offset")?;
50505151+ tracing::info!("[CURSOR] Restoring cursor at offset {}", char_offset);
5252+ tracing::info!("[CURSOR] found mapping: char_range {:?}, node_id '{}', char_offset_in_node {}",
5353+ mapping.char_range, mapping.node_id, mapping.char_offset_in_node);
5454+5155 // If cursor is in invisible content, snap to next visible position
5256 // For now, we'll still use the mapping but this is a future enhancement
5357 if should_snap {
···5862 let window = web_sys::window().ok_or("no window")?;
5963 let document = window.document().ok_or("no document")?;
60646161- // Get the container element by node ID
6565+ // Get the container element by node ID (try id attribute first, then data-node-id)
6266 let container = document
6367 .get_element_by_id(&mapping.node_id)
6868+ .or_else(|| {
6969+ let selector = format!("[data-node-id='{}']", mapping.node_id);
7070+ document.query_selector(&selector).ok().flatten()
7171+ })
6472 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
65736674 // Set selection using Range API
···116124 let mut accumulated_utf16 = 0;
117125 let mut last_node: Option<web_sys::Node> = None;
118126127127+ tracing::info!("[CURSOR] Walking text nodes, target_utf16_offset = {}", target_utf16_offset);
119128 while let Some(node) = walker.next_node()? {
120129 last_node = Some(node.clone());
121130122131 if let Some(text) = node.text_content() {
123132 let text_len = text.encode_utf16().count();
133133+ tracing::info!("[CURSOR] text node: '{}' (utf16_len {}), accumulated = {}",
134134+ text.chars().take(20).collect::<String>(), text_len, accumulated_utf16);
124135125136 // Found the node containing target offset
126137 if accumulated_utf16 + text_len >= target_utf16_offset {
127138 let offset_in_node = target_utf16_offset - accumulated_utf16;
139139+ tracing::info!("[CURSOR] -> FOUND at offset {} in this node", offset_in_node);
128140 return Ok((node, offset_in_node));
129141 }
130142
+430-73
crates/weaver-app/src/components/editor/mod.rs
···88mod document;
99mod formatting;
1010mod offset_map;
1111-mod offsets;
1111+mod paragraph;
1212mod render;
1313mod rope_writer;
1414mod storage;
···1818pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection};
1919pub use formatting::{FormatAction, apply_formatting, find_word_boundaries};
2020pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte};
2121-pub use render::render_markdown_simple;
2121+pub use paragraph::ParagraphRender;
2222+pub use render::{render_markdown_simple, render_paragraphs};
2223pub use rope_writer::RopeWriter;
2324pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage};
2425pub use toolbar::EditorToolbar;
2626+pub use writer::WriterResult;
25272628use dioxus::prelude::*;
2729···5860 let mut document = use_signal(|| EditorDocument::new(restored()));
5961 let editor_id = "markdown-editor";
60626161- // Render markdown to HTML with offset mappings
6262- let render_result = use_memo(move || render::render_markdown_simple(&document().to_string()));
6363- let rendered_html = use_memo(move || render_result.read().html.clone());
6464- let offset_map = use_memo(move || render_result.read().offset_map.clone());
6363+ // Render paragraphs for incremental updates
6464+ let paragraphs = use_memo(move || render::render_paragraphs(&document().rope));
6565+6666+ // Flatten offset maps from all paragraphs
6767+ let offset_map = use_memo(move || {
6868+ paragraphs()
6969+ .iter()
7070+ .flat_map(|p| p.offset_map.iter().cloned())
7171+ .collect::<Vec<_>>()
7272+ });
7373+7474+ // Track previous paragraphs for change detection (outside effect so it persists)
7575+ let mut prev_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
7676+7777+ // Update DOM when paragraphs change (incremental rendering)
7878+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
7979+ use_effect(move || {
8080+ let new_paras = paragraphs();
8181+ let cursor_offset = document().cursor.offset;
8282+8383+ // Use peek() to avoid creating reactive dependency on prev_paragraphs
8484+ let prev = prev_paragraphs.peek().clone();
8585+8686+ let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset);
8787+8888+ // Only restore cursor if we actually re-rendered the paragraph it's in
8989+ if cursor_para_updated {
9090+ use wasm_bindgen::JsCast;
9191+ use wasm_bindgen::prelude::*;
9292+9393+ let rope = document().rope.clone();
9494+ let map = offset_map();
9595+9696+ // Use requestAnimationFrame to wait for browser paint
9797+ if let Some(window) = web_sys::window() {
9898+ let closure = Closure::once(move || {
9999+ if let Err(e) =
100100+ cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id)
101101+ {
102102+ tracing::warn!("Cursor restoration failed: {:?}", e);
103103+ }
104104+ });
105105+106106+ let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
107107+ closure.forget();
108108+ }
109109+ }
110110+111111+ // Store for next comparison (write-only, no reactive read)
112112+ prev_paragraphs.set(new_paras);
113113+ });
6511466115 // Auto-save with debounce
67116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···75124 timer.forget();
76125 });
771267878- // Restore cursor after re-render
7979- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
8080- use_effect(move || {
8181- use wasm_bindgen::prelude::*;
8282- use wasm_bindgen::JsCast;
8383-8484- let cursor_offset = document().cursor.offset;
8585- let rope = document().rope.clone();
8686- let map = offset_map.read().clone();
8787-8888- // Use requestAnimationFrame to wait for browser paint
8989- let window = web_sys::window().expect("no window");
9090-9191- let closure = Closure::once(move || {
9292- if let Err(e) = cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) {
9393- tracing::warn!("Cursor restoration failed: {:?}", e);
9494- }
9595- });
9696-9797- let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
9898- closure.forget();
9999- });
100100-101127 rsx! {
102128 Stylesheet { href: asset!("/assets/styling/editor.css") }
103129 div { class: "markdown-editor-container",
···111137 id: "{editor_id}",
112138 class: "editor-content",
113139 contenteditable: "true",
114114- dangerous_inner_html: "{rendered_html}",
140140+ // DOM populated via web-sys in use_effect for incremental updates
115141116142 onkeydown: move |evt| {
117117- evt.prevent_default();
118118- handle_keydown(evt, &mut document);
143143+ // Only prevent default for operations that modify content
144144+ // Let browser handle arrow keys, Home/End naturally
145145+ if should_intercept_key(&evt) {
146146+ evt.prevent_default();
147147+ handle_keydown(evt, &mut document);
148148+ }
149149+ },
150150+151151+ onkeyup: move |evt| {
152152+ // After any key (including arrow keys), sync cursor from DOM
153153+ sync_cursor_from_dom(&mut document, editor_id);
154154+ },
155155+156156+ onclick: move |_evt| {
157157+ // After mouse click, sync cursor from DOM
158158+ sync_cursor_from_dom(&mut document, editor_id);
119159 },
120160121161 onpaste: move |evt| {
122162 evt.prevent_default();
123163 handle_paste(evt, &mut document);
124164 },
125125-126126- // Phase 1: Accept that cursor position will jump
127127- // Phase 2: Restore cursor properly
128165 }
129166130167···141178 }
142179}
143180181181+/// Check if we need to intercept this key event
182182+/// Returns true for content-modifying operations, false for navigation
183183+fn should_intercept_key(evt: &Event<KeyboardData>) -> bool {
184184+ use dioxus::prelude::keyboard_types::Key;
185185+186186+ let key = evt.key();
187187+ let mods = evt.modifiers();
188188+189189+ // Intercept shortcuts
190190+ if mods.ctrl() || mods.meta() {
191191+ return true;
192192+ }
193193+194194+ // Intercept content modifications
195195+ matches!(
196196+ key,
197197+ Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab
198198+ )
199199+}
200200+201201+/// Sync internal cursor state from browser DOM selection
202202+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
203203+fn sync_cursor_from_dom(document: &mut Signal<EditorDocument>, editor_id: &str) {
204204+ use wasm_bindgen::JsCast;
205205+206206+ let window = match web_sys::window() {
207207+ Some(w) => w,
208208+ None => return,
209209+ };
210210+211211+ let dom_document = match window.document() {
212212+ Some(d) => d,
213213+ None => return,
214214+ };
215215+216216+ // Get editor element as boundary for search
217217+ let editor_element = match dom_document.get_element_by_id(editor_id) {
218218+ Some(e) => e,
219219+ None => return,
220220+ };
221221+222222+ let selection = match window.get_selection() {
223223+ Ok(Some(sel)) => sel,
224224+ _ => return,
225225+ };
226226+227227+ // Get cursor position from selection
228228+ let focus_node = match selection.focus_node() {
229229+ Some(node) => node,
230230+ None => return,
231231+ };
232232+233233+ let focus_offset = selection.focus_offset() as usize;
234234+235235+ // Find the text node's containing element with an ID (from offset map)
236236+ // Walk up but stop at editor boundary to avoid escaping the editor
237237+ let mut current_node = focus_node.clone();
238238+ let node_id = loop {
239239+ if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
240240+ // Stop if we've reached the editor boundary
241241+ if element == &editor_element {
242242+ break None;
243243+ }
244244+245245+ // Check both id and data-node-id attributes
246246+ // (paragraphs use id, headings use data-node-id to preserve user heading IDs)
247247+ let id = element
248248+ .get_attribute("id")
249249+ .or_else(|| element.get_attribute("data-node-id"));
250250+251251+ if let Some(id) = id {
252252+ // Look for node IDs like "n0", "n1", etc (from offset map)
253253+ if id.starts_with('n') && id[1..].parse::<usize>().is_ok() {
254254+ break Some(id);
255255+ }
256256+ }
257257+ }
258258+259259+ current_node = match current_node.parent_node() {
260260+ Some(parent) => parent,
261261+ None => break None,
262262+ };
263263+ };
264264+265265+ let node_id = match node_id {
266266+ Some(id) => id,
267267+ None => {
268268+ tracing::warn!("Could not find node_id for cursor position");
269269+ return;
270270+ }
271271+ };
272272+273273+ let container = match dom_document.get_element_by_id(&node_id).or_else(|| {
274274+ let selector = format!("[data-node-id='{}']", node_id);
275275+ dom_document.query_selector(&selector).ok().flatten()
276276+ }) {
277277+ Some(e) => e,
278278+ None => return,
279279+ };
280280+281281+ // Calculate UTF-16 offset from start of container to focus position
282282+ let mut utf16_offset_in_container = 0;
283283+284284+ // Create tree walker for text nodes in container
285285+ if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) {
286286+ while let Ok(Some(node)) = walker.next_node() {
287287+ if node == focus_node {
288288+ // Found the exact text node, add the offset within it
289289+ utf16_offset_in_container += focus_offset;
290290+ break;
291291+ }
292292+293293+ // Accumulate length of previous text nodes
294294+ if let Some(text) = node.text_content() {
295295+ utf16_offset_in_container += text.encode_utf16().count();
296296+ }
297297+ }
298298+ }
299299+300300+ // Now look up this position in the offset map
301301+ // We need to find the mapping with this node_id and calculate rope offset
302302+ document.with_mut(|doc| {
303303+ // Render to get current offset maps
304304+ let paragraphs = render::render_paragraphs(&doc.rope);
305305+306306+ tracing::debug!("[SYNC] Looking for node_id: {}, utf16_offset_in_container: {}", node_id, utf16_offset_in_container);
307307+308308+ // Find mapping with this node_id
309309+ for para in paragraphs {
310310+ for mapping in para.offset_map {
311311+ if mapping.node_id == node_id {
312312+ // Check if our utf16 offset falls within this mapping's range
313313+ // End-INCLUSIVE to allow cursor at the end of text nodes
314314+ let mapping_start = mapping.char_offset_in_node;
315315+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
316316+317317+ if utf16_offset_in_container >= mapping_start && utf16_offset_in_container <= mapping_end {
318318+ // Calculate rope offset
319319+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
320320+ let rope_offset = mapping.char_range.start + offset_in_mapping;
321321+322322+ tracing::debug!("[SYNC] -> MATCHED! rope_offset: {} (was {})", rope_offset, doc.cursor.offset);
323323+ doc.cursor.offset = rope_offset;
324324+ return;
325325+ }
326326+ }
327327+ }
328328+ }
329329+330330+ tracing::warn!("Could not map DOM cursor position to rope offset");
331331+ });
332332+}
333333+334334+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
335335+fn sync_cursor_from_dom(_document: &mut Signal<EditorDocument>, _editor_id: &str) {
336336+ // No-op on non-wasm
337337+}
338338+144339/// Handle paste events and insert text at cursor
145340fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
146341 // Downcast to web_sys event to get clipboard data
···213408 doc.cursor.offset = start;
214409 doc.selection = None;
215410 } else if doc.cursor.offset > 0 {
216216- // Delete previous char
217217- let prev = doc.cursor.offset - 1;
218218- doc.rope.remove(prev..doc.cursor.offset);
219219- doc.cursor.offset = prev;
411411+ // Check if we're about to delete a newline
412412+ let prev_char = get_char_at(&doc.rope, doc.cursor.offset - 1);
413413+414414+ if prev_char == Some('\n') {
415415+ let newline_pos = doc.cursor.offset - 1;
416416+ let mut delete_start = newline_pos;
417417+ let mut delete_end = doc.cursor.offset;
418418+419419+ // Check if there's another newline before this one (empty paragraph)
420420+ // If so, delete both newlines to merge paragraphs
421421+ if newline_pos > 0 {
422422+ let prev_prev_char = get_char_at(&doc.rope, newline_pos - 1);
423423+ if prev_prev_char == Some('\n') {
424424+ // Empty paragraph case: delete both newlines
425425+ delete_start = newline_pos - 1;
426426+ }
427427+ }
428428+429429+ // Also check if there's a zero-width char after cursor (inserted by Shift+Enter)
430430+ if let Some(ch) = get_char_at(&doc.rope, delete_end) {
431431+ if ch == '\u{200C}' || ch == '\u{200B}' {
432432+ delete_end += 1;
433433+ }
434434+ }
435435+436436+ // Scan backwards through whitespace before the newline(s)
437437+ while delete_start > 0 {
438438+ let ch = get_char_at(&doc.rope, delete_start - 1);
439439+ match ch {
440440+ Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => {
441441+ delete_start -= 1;
442442+ }
443443+ Some('\n') => break, // stop at another newline
444444+ _ => break, // stop at actual content
445445+ }
446446+ }
447447+448448+ // Delete from where we stopped to end (including any trailing zero-width)
449449+ doc.rope.remove(delete_start..delete_end);
450450+ doc.cursor.offset = delete_start;
451451+ } else {
452452+ // Normal backspace - delete one char
453453+ let prev = doc.cursor.offset - 1;
454454+ doc.rope.remove(prev..doc.cursor.offset);
455455+ doc.cursor.offset = prev;
456456+ }
220457 }
221458 }
222459···233470 }
234471 }
235472236236- Key::ArrowLeft => {
237237- if mods.ctrl() {
238238- // Word boundary (implement later)
239239- if doc.cursor.offset > 0 {
240240- doc.cursor.offset -= 1;
241241- }
242242- } else if doc.cursor.offset > 0 {
243243- doc.cursor.offset -= 1;
244244- }
245245- doc.selection = None;
246246- }
247247-248248- Key::ArrowRight => {
249249- if mods.ctrl() {
250250- // Word boundary (implement later)
251251- if doc.cursor.offset < doc.len_chars() {
252252- doc.cursor.offset += 1;
253253- }
254254- } else if doc.cursor.offset < doc.len_chars() {
255255- doc.cursor.offset += 1;
256256- }
257257- doc.selection = None;
473473+ // Arrow keys handled by browser, synced in onkeyup
474474+ Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => {
475475+ // Browser handles these naturally
258476 }
259477260478 Key::Enter => {
···265483 doc.cursor.offset = start;
266484 doc.selection = None;
267485 }
268268- // Insert two spaces + newline for hard line break
269269- doc.rope.insert(doc.cursor.offset, " \n");
270270- doc.cursor.offset += 3;
271271- }
272486273273- Key::Home => {
274274- let line_start = find_line_start(&doc.rope, doc.cursor.offset);
275275- doc.cursor.offset = line_start;
276276- doc.selection = None;
487487+ if mods.shift() {
488488+ // Shift+Enter: hard line break (soft break)
489489+ doc.rope.insert(doc.cursor.offset, " \n\u{200C}");
490490+ doc.cursor.offset += 3;
491491+ } else {
492492+ // Enter: paragraph break (much cleaner, less jank)
493493+ tracing::info!(
494494+ "[ENTER] Before insert - cursor at {}, rope len {}",
495495+ doc.cursor.offset,
496496+ doc.len_chars()
497497+ );
498498+ doc.rope.insert(doc.cursor.offset, "\n\n");
499499+ doc.cursor.offset += 2;
500500+ tracing::info!(
501501+ "[ENTER] After insert - cursor at {}, rope len {}",
502502+ doc.cursor.offset,
503503+ doc.len_chars()
504504+ );
505505+ }
277506 }
278507279279- Key::End => {
280280- let line_end = find_line_end(&doc.rope, doc.cursor.offset);
281281- doc.cursor.offset = line_end;
282282- doc.selection = None;
508508+ // Home/End handled by browser, synced in onkeyup
509509+ Key::Home | Key::End => {
510510+ // Browser handles these naturally
283511 }
284512285513 _ => {}
···287515 });
288516}
289517518518+/// Get character at the given offset in the rope
519519+fn get_char_at(rope: &jumprope::JumpRopeBuf, offset: usize) -> Option<char> {
520520+ if offset >= rope.len_chars() {
521521+ return None;
522522+ }
523523+524524+ let rope = rope.borrow();
525525+ let mut current = 0;
526526+ for substr in rope.slice_substrings(offset..offset + 1) {
527527+ for c in substr.chars() {
528528+ if current == 0 {
529529+ return Some(c);
530530+ }
531531+ current += 1;
532532+ }
533533+ }
534534+ None
535535+}
536536+290537/// Find start of line containing offset
291538fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize {
292539 // Search backwards from cursor for newline
···326573327574 rope.len_chars()
328575}
576576+577577+/// Update paragraph DOM elements incrementally.
578578+///
579579+/// Only modifies paragraphs that changed (by comparing source_hash).
580580+/// Browser preserves cursor naturally in unchanged paragraphs.
581581+///
582582+/// Returns true if the paragraph containing the cursor was updated.
583583+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
584584+fn update_paragraph_dom(
585585+ editor_id: &str,
586586+ old_paragraphs: &[ParagraphRender],
587587+ new_paragraphs: &[ParagraphRender],
588588+ cursor_offset: usize,
589589+) -> bool {
590590+ use wasm_bindgen::JsCast;
591591+592592+ let window = match web_sys::window() {
593593+ Some(w) => w,
594594+ None => return false,
595595+ };
596596+597597+ let document = match window.document() {
598598+ Some(d) => d,
599599+ None => return false,
600600+ };
601601+602602+ let editor = match document.get_element_by_id(editor_id) {
603603+ Some(e) => e,
604604+ None => return false,
605605+ };
606606+607607+ // Find which paragraph contains cursor
608608+ // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N)
609609+ // This handles typing at end of paragraph, which is the common case
610610+ // The empty paragraph at document end catches any trailing cursor positions
611611+ let cursor_para_idx = new_paragraphs
612612+ .iter()
613613+ .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end);
614614+615615+ tracing::info!(
616616+ "[DOM] cursor_offset = {}, cursor_para_idx = {:?}",
617617+ cursor_offset,
618618+ cursor_para_idx
619619+ );
620620+ for (idx, para) in new_paragraphs.iter().enumerate() {
621621+ let matches =
622622+ para.char_range.start <= cursor_offset && cursor_offset <= para.char_range.end;
623623+ tracing::info!(
624624+ "[DOM] para {}: char_range {:?}, matches cursor? {}",
625625+ idx,
626626+ para.char_range,
627627+ matches
628628+ );
629629+ }
630630+631631+ let mut cursor_para_updated = false;
632632+633633+ // Update or create paragraphs
634634+ for (idx, new_para) in new_paragraphs.iter().enumerate() {
635635+ let para_id = format!("para-{}", idx);
636636+637637+ if let Some(old_para) = old_paragraphs.get(idx) {
638638+ // Paragraph exists - check if changed
639639+ if new_para.source_hash != old_para.source_hash {
640640+ // Changed - update innerHTML
641641+ if let Some(elem) = document.get_element_by_id(¶_id) {
642642+ elem.set_inner_html(&new_para.html);
643643+ }
644644+645645+ // Track if we updated the cursor's paragraph
646646+ if Some(idx) == cursor_para_idx {
647647+ cursor_para_updated = true;
648648+ }
649649+ }
650650+ // Unchanged - do nothing, browser preserves cursor
651651+ } else {
652652+ // New paragraph - create div
653653+ if let Ok(div) = document.create_element("div") {
654654+ div.set_id(¶_id);
655655+ div.set_inner_html(&new_para.html);
656656+ let _ = editor.append_child(&div);
657657+ }
658658+659659+ // Track if we created the cursor's paragraph
660660+ if Some(idx) == cursor_para_idx {
661661+ cursor_para_updated = true;
662662+ }
663663+ }
664664+ }
665665+666666+ // Remove extra paragraphs if document got shorter
667667+ for idx in new_paragraphs.len()..old_paragraphs.len() {
668668+ let para_id = format!("para-{}", idx);
669669+ if let Some(elem) = document.get_element_by_id(¶_id) {
670670+ let _ = elem.remove();
671671+ }
672672+ }
673673+674674+ cursor_para_updated
675675+}
676676+677677+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
678678+fn update_paragraph_dom(
679679+ _editor_id: &str,
680680+ _old_paragraphs: &[ParagraphRender],
681681+ _new_paragraphs: &[ParagraphRender],
682682+ _cursor_offset: usize,
683683+) -> bool {
684684+ false
685685+}
···11+//! Paragraph-level rendering for incremental updates.
22+//!
33+//! Paragraphs are discovered during markdown rendering by tracking
44+//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
55+66+use super::offset_map::OffsetMapping;
77+use jumprope::JumpRopeBuf;
88+use std::ops::Range;
99+1010+/// A rendered paragraph with its source range and offset mappings.
1111+#[derive(Debug, Clone, PartialEq)]
1212+pub struct ParagraphRender {
1313+ /// Source byte range in the rope
1414+ pub byte_range: Range<usize>,
1515+1616+ /// Source char range in the rope
1717+ pub char_range: Range<usize>,
1818+1919+ /// Rendered HTML content (without wrapper div)
2020+ pub html: String,
2121+2222+ /// Offset mappings for this paragraph
2323+ pub offset_map: Vec<OffsetMapping>,
2424+2525+ /// Hash of source text for quick change detection
2626+ pub source_hash: u64,
2727+}
2828+2929+/// Simple hash function for source text comparison
3030+pub fn hash_source(text: &str) -> u64 {
3131+ use std::collections::hash_map::DefaultHasher;
3232+ use std::hash::{Hash, Hasher};
3333+3434+ let mut hasher = DefaultHasher::new();
3535+ text.hash(&mut hasher);
3636+ hasher.finish()
3737+}
3838+3939+/// Extract substring from rope as String
4040+pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range<usize>) -> String {
4141+ let rope_borrow = rope.borrow();
4242+ let mut result = String::new();
4343+4444+ for substr in rope_borrow.slice_substrings(range) {
4545+ result.push_str(substr);
4646+ }
4747+4848+ result
4949+}
5050+
+155-13
crates/weaver-app/src/components/editor/render.rs
···11//! Markdown rendering for the editor.
22//!
33-//! Phase 2: Full-document rendering with formatting characters visible as styled spans.
44-//! Future: Incremental paragraph rendering and contextual formatting visibility.
33+//! Phase 2: Paragraph-level incremental rendering with formatting characters visible.
54//!
65//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
7688-use markdown_weaver::Parser;
99-use super::offset_map::RenderResult;
77+use super::offset_map::{OffsetMapping, RenderResult};
88+use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
109use super::writer::EditorWriter;
1010+use jumprope::JumpRopeBuf;
1111+use markdown_weaver::Parser;
11121213/// Render markdown to HTML with visible formatting characters and offset mappings.
1314///
···2425/// - Offset map generation for cursor restoration
2526/// - Full document re-render (fast enough for current needs)
2627///
2727-/// # Future improvements
2828-/// - Paragraph-level incremental rendering
2929-/// - Contextual formatting hiding based on cursor position
2828+/// # Deprecated: Use `render_paragraphs()` for incremental rendering
3029pub fn render_markdown_simple(source: &str) -> RenderResult {
3131- use jumprope::JumpRopeBuf;
3232-3330 let source_rope = JumpRopeBuf::from(source);
3434- let parser = Parser::new_ext(source, weaver_renderer::default_md_options())
3535- .into_offset_iter();
3131+ let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter();
3632 let mut output = String::new();
37333834 match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
3939- Ok(offset_map) => RenderResult {
3535+ Ok(result) => RenderResult {
4036 html: output,
4141- offset_map,
3737+ offset_map: result.offset_maps,
4238 },
4339 Err(_) => {
4440 // Fallback to empty result on error
···4945 }
5046 }
5147}
4848+4949+/// Render markdown in paragraph chunks for incremental DOM updates.
5050+///
5151+/// First renders the whole document to discover paragraph boundaries via
5252+/// markdown events (Tag::Paragraph), then re-renders each paragraph separately.
5353+/// This allows updating only changed paragraphs in the DOM, preserving cursor
5454+/// position naturally.
5555+///
5656+/// # Returns
5757+///
5858+/// A vector of `ParagraphRender` structs, each containing:
5959+/// - Source byte and char ranges
6060+/// - Rendered HTML (without wrapper div)
6161+/// - Offset mappings for that paragraph
6262+/// - Source hash for change detection
6363+///
6464+/// # Phase 2 Benefits
6565+/// - Only re-render changed paragraphs
6666+/// - Browser preserves cursor in unchanged paragraphs naturally
6767+/// - Faster for large documents
6868+/// - No manual cursor restoration needed for most edits
6969+pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec<ParagraphRender> {
7070+ let source = rope.to_string();
7171+7272+ // Handle empty rope - return single empty paragraph for cursor positioning
7373+ if source.is_empty() {
7474+ let empty_node_id = "n0".to_string();
7575+ let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
7676+7777+ return vec![ParagraphRender {
7878+ byte_range: 0..0,
7979+ char_range: 0..0,
8080+ html: empty_html,
8181+ offset_map: vec![],
8282+ source_hash: 0,
8383+ }];
8484+ }
8585+8686+ // First pass: render whole document to get paragraph boundaries
8787+ // TODO: CACHE THIS!
8888+ let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
8989+ let mut scratch_output = String::new();
9090+9191+ let paragraph_ranges =
9292+ match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() {
9393+ Ok(result) => result.paragraph_ranges,
9494+ Err(_) => return Vec::new(),
9595+ };
9696+9797+ // Second pass: render each paragraph separately
9898+ let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
9999+ let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
100100+101101+ tracing::info!("[RENDER] Rendering {} paragraphs", paragraph_ranges.len());
102102+ for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
103103+ tracing::info!("[RENDER] Paragraph {}: char_range {:?}", idx, char_range);
104104+ // Extract paragraph source
105105+ let para_source = rope_slice_to_string(rope, char_range.clone());
106106+ let source_hash = hash_source(¶_source);
107107+108108+ // Render this paragraph with unique node IDs
109109+ let para_rope = JumpRopeBuf::from(para_source.as_str());
110110+ let parser =
111111+ Parser::new_ext(¶_source, weaver_renderer::default_md_options()).into_offset_iter();
112112+ let mut output = String::new();
113113+114114+ let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset(
115115+ ¶_source,
116116+ ¶_rope,
117117+ parser,
118118+ &mut output,
119119+ node_id_offset,
120120+ )
121121+ .run()
122122+ {
123123+ Ok(result) => {
124124+ // Update node ID offset for next paragraph
125125+ // Count how many unique node IDs were used in this paragraph
126126+ let max_node_id = result
127127+ .offset_maps
128128+ .iter()
129129+ .filter_map(|m| {
130130+ m.node_id
131131+ .strip_prefix("n")
132132+ .and_then(|s| s.parse::<usize>().ok())
133133+ })
134134+ .max()
135135+ .unwrap_or(node_id_offset);
136136+ node_id_offset = max_node_id + 1;
137137+138138+ result.offset_maps
139139+ }
140140+ Err(_) => Vec::new(),
141141+ };
142142+143143+ // Adjust offset map to be relative to document, not paragraph
144144+ // Each mapping's ranges need to be shifted by paragraph start
145145+ let para_char_start = char_range.start;
146146+ let para_byte_start = byte_range.start;
147147+148148+ for mapping in &mut offset_map {
149149+ mapping.byte_range.start += para_byte_start;
150150+ mapping.byte_range.end += para_byte_start;
151151+ mapping.char_range.start += para_char_start;
152152+ mapping.char_range.end += para_char_start;
153153+ }
154154+155155+ paragraphs.push(ParagraphRender {
156156+ byte_range: byte_range.clone(),
157157+ char_range: char_range.clone(),
158158+ html: output,
159159+ offset_map,
160160+ source_hash,
161161+ });
162162+ }
163163+164164+ // Check if rope ends with trailing newlines (empty paragraph at end)
165165+ // If so, add an empty paragraph div for cursor positioning
166166+ let source = rope.to_string();
167167+ let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
168168+169169+ if has_trailing_newlines {
170170+ let doc_end_char = rope.len_chars();
171171+ let doc_end_byte = rope.len_bytes();
172172+173173+ let empty_node_id = format!("n{}", node_id_offset);
174174+ let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
175175+176176+ paragraphs.push(ParagraphRender {
177177+ byte_range: doc_end_byte..doc_end_byte,
178178+ char_range: doc_end_char..doc_end_char + 1, // range for the zero-width space
179179+ html: empty_html,
180180+ offset_map: vec![OffsetMapping {
181181+ byte_range: doc_end_byte..doc_end_byte,
182182+ char_range: doc_end_char..doc_end_char + 1,
183183+ node_id: empty_node_id,
184184+ char_offset_in_node: 0,
185185+ child_index: None,
186186+ utf16_len: 1, // zero-width space is 1 UTF-16 code unit
187187+ }],
188188+ source_hash: 0, // always render this paragraph
189189+ });
190190+ }
191191+192192+ paragraphs
193193+}
+177-126
crates/weaver-app/src/components/editor/writer.rs
···77//! represent consumed formatting characters.
8899use super::offset_map::{OffsetMapping, RenderResult};
1010-use super::offsets::{byte_to_char, char_to_byte};
1110use jumprope::JumpRopeBuf;
1211use markdown_weaver::{
1312 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
···1817};
1918use std::collections::HashMap;
2019use std::ops::Range;
2020+2121+/// Result of rendering with the EditorWriter.
2222+#[derive(Debug, Clone)]
2323+pub struct WriterResult {
2424+ /// Offset mappings from source to DOM positions
2525+ pub offset_maps: Vec<OffsetMapping>,
2626+2727+ /// Paragraph boundaries in source: (byte_range, char_range)
2828+ /// These are extracted during rendering by tracking Tag::Paragraph events
2929+ pub paragraph_ranges: Vec<(Range<usize>, Range<usize>)>,
3030+}
21312232/// Classification of markdown syntax characters
2333#[derive(Debug, Clone, Copy, PartialEq)]
···100110101111 code_buffer: Option<(Option<String>, String)>, // (lang, content)
102112 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content
113113+ code_buffer_char_range: Option<Range<usize>>, // char range of buffered code content
103114 pending_blockquote_range: Option<Range<usize>>, // range for emitting > inside next paragraph
104115105116 // Table rendering mode
···113124 current_node_char_offset: usize, // UTF-16 offset within current node
114125 current_node_child_count: usize, // number of child elements/text nodes in current container
115126127127+ // Paragraph boundary tracking for incremental rendering
128128+ paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range)
129129+ current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset)
130130+116131 _phantom: std::marker::PhantomData<&'a ()>,
117132}
118133···126141 EditorWriter<'a, I, W, E>
127142{
128143 pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self {
144144+ Self::new_with_node_offset(source, source_rope, events, writer, 0)
145145+ }
146146+147147+ pub fn new_with_node_offset(
148148+ source: &'a str,
149149+ source_rope: &'a JumpRopeBuf,
150150+ events: I,
151151+ writer: W,
152152+ node_id_offset: usize,
153153+ ) -> Self {
129154 Self {
130155 source,
131156 source_rope,
···142167 embed_provider: None,
143168 code_buffer: None,
144169 code_buffer_byte_range: None,
170170+ code_buffer_char_range: None,
145171 pending_blockquote_range: None,
146172 render_tables_as_markdown: true, // Default to markdown rendering
147173 table_start_offset: None,
148174 offset_maps: Vec::new(),
149149- next_node_id: 0,
175175+ next_node_id: node_id_offset,
150176 current_node_id: None,
151177 current_node_char_offset: 0,
152178 current_node_child_count: 0,
179179+ paragraph_ranges: Vec::new(),
180180+ current_paragraph_start: None,
153181 _phantom: std::marker::PhantomData,
154182 }
155183 }
···172200 embed_provider: Some(provider),
173201 code_buffer: self.code_buffer,
174202 code_buffer_byte_range: self.code_buffer_byte_range,
203203+ code_buffer_char_range: self.code_buffer_char_range,
175204 pending_blockquote_range: self.pending_blockquote_range,
176205 render_tables_as_markdown: self.render_tables_as_markdown,
177206 table_start_offset: self.table_start_offset,
···180209 current_node_id: self.current_node_id,
181210 current_node_char_offset: self.current_node_char_offset,
182211 current_node_child_count: self.current_node_child_count,
212212+ paragraph_ranges: self.paragraph_ranges,
213213+ current_paragraph_start: self.current_paragraph_start,
183214 _phantom: std::marker::PhantomData,
184215 }
185216 }
···211242 let char_start = self.last_char_offset;
212243 let syntax_char_len = syntax.chars().count();
213244214214- tracing::debug!(
215215- "emit_syntax: range={:?}, chars={}..{}, syntax={:?}",
216216- range,
217217- char_start,
218218- char_start + syntax_char_len,
219219- syntax
220220- );
221221-222245 // If we're outside any node, create a wrapper span for tracking
223246 let created_node = if self.current_node_id.is_none() {
224247 let node_id = self.gen_node_id();
···241264242265 // Record offset mapping for this syntax
243266 self.record_mapping(range.clone(), char_start..char_start + syntax_char_len);
244244- self.last_char_offset = char_start + syntax_char_len;
245245- self.last_byte_offset = range.end; // Mark bytes as processed
267267+ let new_char = char_start + syntax_char_len;
268268+ let new_byte = range.end;
269269+ tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}",
270270+ self.last_char_offset, new_char, self.last_byte_offset, new_byte);
271271+ self.last_char_offset = new_char;
272272+ self.last_byte_offset = new_byte; // Mark bytes as processed
246273247274 // Close wrapper if we created one
248275 if created_node {
···300327 let utf16_len = wchar_end - wchar_start;
301328302329 let mapping = OffsetMapping {
303303- byte_range,
304304- char_range,
330330+ byte_range: byte_range.clone(),
331331+ char_range: char_range.clone(),
305332 node_id: node_id.clone(),
306333 char_offset_in_node: self.current_node_char_offset,
307334 child_index: None, // text-based position
···309336 };
310337 self.offset_maps.push(mapping);
311338 self.current_node_char_offset += utf16_len;
339339+ } else {
340340+ tracing::warn!("[RECORD_MAPPING] SKIPPED - current_node_id is None!");
312341 }
313342 }
314343315344 /// Process markdown events and write HTML.
316345 ///
317317- /// Returns the offset mappings. The HTML is written to the writer
318318- /// passed in the constructor.
319319- pub fn run(mut self) -> Result<Vec<OffsetMapping>, W::Error> {
346346+ /// Returns offset mappings and paragraph boundaries. The HTML is written
347347+ /// to the writer passed in the constructor.
348348+ pub fn run(mut self) -> Result<WriterResult, W::Error> {
320349 while let Some((event, range)) = self.events.next() {
350350+ // Log events for debugging
351351+ tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}",
352352+ match &event {
353353+ Event::Start(tag) => format!("Start({:?})", tag),
354354+ Event::End(tag) => format!("End({:?})", tag),
355355+ Event::Text(t) => format!("Text('{}')", t),
356356+ Event::Code(t) => format!("Code('{}')", t),
357357+ Event::Html(t) => format!("Html('{}')", t),
358358+ Event::InlineHtml(t) => format!("InlineHtml('{}')", t),
359359+ Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t),
360360+ Event::SoftBreak => "SoftBreak".to_string(),
361361+ Event::HardBreak => "HardBreak".to_string(),
362362+ Event::Rule => "Rule".to_string(),
363363+ Event::TaskListMarker(b) => format!("TaskListMarker({})", b),
364364+ Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t),
365365+ Event::InlineMath(t) => format!("InlineMath('{}')", t),
366366+ Event::DisplayMath(t) => format!("DisplayMath('{}')", t),
367367+ },
368368+ &range,
369369+ self.last_byte_offset,
370370+ self.last_char_offset
371371+ );
372372+321373 // For End events, emit any trailing content within the event's range
322374 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
323375 if matches!(&event, Event::End(_)) {
···330382 self.emit_gap_before(range.start)?;
331383 }
332384385385+ // Store last_byte before processing
386386+ let last_byte_before = self.last_byte_offset;
387387+333388 // Process the event (passing range for tag syntax)
334389 self.process_event(event, range.clone())?;
335390336336- // Update tracking
337337- self.last_byte_offset = range.end;
391391+ // Update tracking - but don't override if start_tag manually updated it
392392+ // (for inline formatting tags that emit opening syntax)
393393+ if self.last_byte_offset == last_byte_before {
394394+ // Event didn't update offset, so we update it
395395+ self.last_byte_offset = range.end;
396396+ }
397397+ // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
338398 }
339399340400 // Emit any trailing syntax
···346406 let doc_char_len = self.source_rope.len_chars();
347407348408 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
349349- tracing::debug!(
350350- "Unmapped trailing content: bytes {}..{}, chars {}..{}",
351351- self.last_byte_offset,
352352- doc_byte_len,
353353- self.last_char_offset,
354354- doc_char_len
355355- );
356356-357409 // Emit the trailing content as visible syntax
358410 if self.last_byte_offset < doc_byte_len {
359411 let trailing = &self.source[self.last_byte_offset..];
···384436 }
385437 }
386438387387- Ok(self.offset_maps)
439439+ Ok(WriterResult {
440440+ offset_maps: self.offset_maps,
441441+ paragraph_ranges: self.paragraph_ranges,
442442+ })
388443 }
389444390445 // Consume raw text events until end tag, for alt attributes
···436491 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> {
437492 use Event::*;
438493439439- tracing::debug!(
440440- "Event: {:?}, range: {:?}",
441441- match &event {
442442- Start(tag) => format!("Start({:?})", tag),
443443- End(tag) => format!("End({:?})", tag),
444444- Text(t) => format!("Text({:?})", &t[..t.len().min(20)]),
445445- _ => format!("{:?}", event),
446446- },
447447- range
448448- );
449494 match event {
450495 Start(tag) => self.start_tag(tag, range)?,
451496 End(tag) => self.end_tag(tag, range)?,
···454499 if let Some((_, ref mut buffer)) = self.code_buffer {
455500 buffer.push_str(&text);
456501457457- // Track byte range for code block content
458458- if let Some(ref mut code_range) = self.code_buffer_byte_range {
459459- // Extend existing range
460460- code_range.end = range.end;
502502+ // Track byte and char ranges for code block content
503503+ let text_char_len = text.chars().count();
504504+ if let Some(ref mut code_byte_range) = self.code_buffer_byte_range {
505505+ // Extend existing ranges
506506+ code_byte_range.end = range.end;
507507+ if let Some(ref mut code_char_range) = self.code_buffer_char_range {
508508+ code_char_range.end = self.last_char_offset + text_char_len;
509509+ }
461510 } else {
462511 // First text in code block - start tracking
463512 self.code_buffer_byte_range = Some(range.clone());
513513+ self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len);
464514 }
465515 } else if !self.in_non_writing_block {
466516 // Escape HTML and count chars in one pass
···468518 let text_char_len =
469519 escape_html_body_text_with_char_count(&mut self.writer, &text)?;
470520 let char_end = char_start + text_char_len;
471471-472472- tracing::debug!(
473473- "Text event: range={:?}, chars={}..{}, text={:?}",
474474- range,
475475- char_start,
476476- char_end,
477477- &text[..text.len().min(40)]
478478- );
479521480522 // Text becomes a text node child of the current container
481523 if text_char_len > 0 {
···580622 let gap = &self.source[range.clone()];
581623 if gap.ends_with('\n') {
582624 let spaces = &gap[..gap.len() - 1]; // everything except the \n
583583- let char_start = byte_to_char(self.source, range.start);
625625+ let char_start = self.last_char_offset;
584626 let spaces_char_len = spaces.chars().count();
585627586628 // Emit and map the visible spaces
···602644 // Count the <br> as a child
603645 self.current_node_child_count += 1;
604646605605- // Map the newline to an element-based position (after the <br>)
606606- // The binary search is end-inclusive, so cursor at position N+1
607607- // will match a mapping with range N..N+1
647647+ // After <br>, emit plain zero-width space for cursor positioning
648648+ self.write("\u{200B}")?;
649649+650650+ // Count the zero-width space text node as a child
651651+ self.current_node_child_count += 1;
652652+653653+ // Map the newline position to the zero-width space text node
608654 if let Some(ref node_id) = self.current_node_id {
609655 let newline_char_offset = char_start + spaces_char_len;
610656 let mapping = OffsetMapping {
611657 byte_range: range.start + spaces.len()..range.end,
612658 char_range: newline_char_offset..newline_char_offset + 1,
613659 node_id: node_id.clone(),
614614- char_offset_in_node: 0,
615615- child_index: Some(self.current_node_child_count),
616616- utf16_len: 0,
660660+ char_offset_in_node: self.current_node_char_offset,
661661+ child_index: None, // text node - TreeWalker will find it
662662+ utf16_len: 1, // zero-width space is 1 UTF-16 unit
617663 };
618664 self.offset_maps.push(mapping);
665665+666666+ // Increment char offset - TreeWalker will encounter this text node
667667+ self.current_node_char_offset += 1;
619668 }
620669670670+ // DO NOT increment last_char_offset - zero-width space is not in source
671671+ // The \n itself IS in source, so we already accounted for it
621672 self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n
622673 } else {
623674 // Fallback: just <br>
···724775 SyntaxClass::Inline => "md-syntax-inline",
725776 SyntaxClass::Block => "md-syntax-block",
726777 };
778778+779779+ let char_start = self.last_char_offset;
780780+ let syntax_char_len = syntax.chars().count();
781781+ let syntax_byte_len = syntax.len();
782782+727783 self.write("<span class=\"")?;
728784 self.write(class)?;
729785 self.write("\">")?;
730786 escape_html(&mut self.writer, syntax)?;
731787 self.write("</span>")?;
788788+789789+ // Update tracking - we've consumed this opening syntax
790790+ tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}",
791791+ syntax, self.last_char_offset, char_start + syntax_char_len,
792792+ self.last_byte_offset, range.start + syntax_byte_len);
793793+ self.last_char_offset = char_start + syntax_char_len;
794794+ self.last_byte_offset = range.start + syntax_byte_len;
732795 }
733796 }
734797···736799 match tag {
737800 Tag::HtmlBlock => Ok(()),
738801 Tag::Paragraph => {
802802+ // Record paragraph start for boundary tracking
803803+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
804804+739805 let node_id = self.gen_node_id();
740806 if self.end_newline {
741807 write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
···791857 classes,
792858 attrs,
793859 } => {
860860+ // Record paragraph start for boundary tracking
861861+ // Treat headings as paragraph-level blocks
862862+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
863863+794864 if !self.end_newline {
795865 self.write("\n")?;
796866 }
···835905 self.write(">")?;
836906837907 // Begin node tracking for offset mapping
838838- self.begin_node(node_id);
908908+ self.begin_node(node_id.clone());
909909+910910+ // Map the start position of the heading (before any content)
911911+ // This allows cursor to be placed at the very beginning
912912+ let heading_start_char = self.last_char_offset;
913913+ let mapping = OffsetMapping {
914914+ byte_range: range.start..range.start,
915915+ char_range: heading_start_char..heading_start_char,
916916+ node_id: node_id.clone(),
917917+ char_offset_in_node: 0,
918918+ child_index: Some(0), // position before first child
919919+ utf16_len: 0,
920920+ };
921921+ self.offset_maps.push(mapping);
839922840923 // Emit # syntax inside the heading tag
841924 if range.start < range.end {
842842- let raw_text = &self.source[range];
925925+ let raw_text = &self.source[range.clone()];
843926 let count = level as usize;
844927 let pattern = "#".repeat(count);
845928···849932 let syntax_start = hash_pos;
850933 let syntax_end = (hash_pos + count + 1).min(raw_text.len());
851934 let syntax = &raw_text[syntax_start..syntax_end];
935935+ let syntax_char_len = syntax.chars().count();
936936+937937+ // Calculate byte range for this syntax in the source
938938+ let syntax_byte_start = range.start + syntax_start;
939939+ let syntax_byte_end = range.start + syntax_end;
940940+ let char_start = self.last_char_offset;
852941853942 self.write("<span class=\"md-syntax-block\">")?;
854943 escape_html(&mut self.writer, syntax)?;
855944 self.write("</span>")?;
945945+946946+ // Record offset mapping and update char tracking
947947+ // Note: last_byte_offset is managed by the main event loop
948948+ self.record_mapping(
949949+ syntax_byte_start..syntax_byte_end,
950950+ char_start..char_start + syntax_char_len
951951+ );
952952+ self.last_char_offset = char_start + syntax_char_len;
856953 }
857954 }
858955 Ok(())
···11931290 let result = match tag {
11941291 TagEnd::HtmlBlock => Ok(()),
11951292 TagEnd::Paragraph => {
12931293+ // Record paragraph end for boundary tracking
12941294+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
12951295+ let byte_range = byte_start..self.last_byte_offset;
12961296+ let char_range = char_start..self.last_char_offset;
12971297+ self.paragraph_ranges.push((byte_range, char_range));
12981298+ }
12991299+11961300 self.end_node();
11971301 self.write("</p>\n")
11981302 }
11991303 TagEnd::Heading(level) => {
13041304+ // Record paragraph end for boundary tracking
13051305+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
13061306+ let byte_range = byte_start..self.last_byte_offset;
13071307+ let char_range = char_start..self.last_char_offset;
13081308+ self.paragraph_ranges.push((byte_range, char_range));
13091309+ }
13101310+12001311 self.end_node();
12011312 self.write("</")?;
12021313 write!(&mut self.writer, "{}", level)?;
···12551366 LazyLock::new(|| SyntaxSet::load_defaults_newlines());
1256136712571368 if let Some((lang, buffer)) = self.code_buffer.take() {
12581258- // Create offset mapping for code block content if we tracked a range
12591259- if let Some(code_byte_range) = self.code_buffer_byte_range.take() {
12601260- // Calculate char range from the tracked byte range
12611261- let char_start = byte_to_char(self.source, code_byte_range.start);
12621262- let char_end = byte_to_char(self.source, code_byte_range.end);
12631263- let char_range = char_start..char_end;
12641264-13691369+ // Create offset mapping for code block content if we tracked ranges
13701370+ if let (Some(code_byte_range), Some(code_char_range)) =
13711371+ (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) {
12651372 // Record mapping before writing HTML
12661373 // (current_node_id should be set by start_tag for CodeBlock)
12671267- self.record_mapping(code_byte_range, char_range);
13741374+ self.record_mapping(code_byte_range, code_char_range);
12681375 }
1269137612701377 if let Some(ref lang_str) = lang {
···1348145513491456 result?;
1350145713511351- // Extract and emit closing syntax based on tag type
13521352- if range.start < range.end {
13531353- let raw_text = &self.source[range];
13541354- let closing_syntax = match &tag {
13551355- TagEnd::Strong => {
13561356- if raw_text.ends_with("**") {
13571357- Some("**")
13581358- } else if raw_text.ends_with("__") {
13591359- Some("__")
13601360- } else {
13611361- None
13621362- }
13631363- }
13641364- TagEnd::Emphasis => {
13651365- if raw_text.ends_with("*") {
13661366- Some("*")
13671367- } else if raw_text.ends_with("_") {
13681368- Some("_")
13691369- } else {
13701370- None
13711371- }
13721372- }
13731373- TagEnd::Strikethrough => {
13741374- if raw_text.ends_with("~~") {
13751375- Some("~~")
13761376- } else {
13771377- None
13781378- }
13791379- }
13801380- TagEnd::Link => {
13811381- // Extract ](url) part
13821382- if let Some(idx) = raw_text.rfind("](") {
13831383- Some(&raw_text[idx..])
13841384- } else {
13851385- None
13861386- }
13871387- }
13881388- TagEnd::CodeBlock => {
13891389- if raw_text.ends_with("```") {
13901390- raw_text.lines().last()
13911391- } else {
13921392- None
13931393- }
13941394- }
13951395- _ => None,
13961396- };
13971397-13981398- if let Some(syntax) = closing_syntax {
13991399- let class = match classify_syntax(syntax) {
14001400- SyntaxClass::Inline => "md-syntax-inline",
14011401- SyntaxClass::Block => "md-syntax-block",
14021402- };
14031403- self.write("<span class=\"")?;
14041404- self.write(class)?;
14051405- self.write("\">")?;
14061406- escape_html(&mut self.writer, syntax)?;
14071407- self.write("</span>")?;
14081408- }
14091409- }
14581458+ // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled
14591459+ // by emit_gap_before(range.end) which is called before end_tag() in the main loop.
14601460+ // No need for manual emission here anymore.
1410146114111462 Ok(())
14121463 }