···86.md-syntax-inline {
87 color: var(--color-muted);
88 opacity: 0.6;
89+}
90+91+.md-syntax-inline:[hidden] {
92+ color: var(--color-muted);
93+ opacity: 0.6;
94+ width: 0;
95+ user-select: none; /* idk if we want this when its hidden or not */
96}
9798/* Markdown syntax characters - block level (#, >, -, etc) */
99.md-syntax-block {
100 color: var(--color-muted);
101 opacity: 0.7;
102+ width: 0;
103 font-weight: normal;
104+}
105+106+.md-syntax-block:[hidden] {
107+ content: attr(data-syntax);
108+ display: inline-block;
109+ margin-right: 4px;
110+ user-select: none; /* idk if we want this when its hidden or not */
111+}
112+113+/* Cursor positioning helper after <br> */
114+.br-cursor {
115+ display: inline-block;
116+ font-size: 0;
117+ width: 0;
118+ height: 1em; /* force height so cursor is visible */
119+ line-height: 1em;
120+ vertical-align: baseline;
121}
122123/* Future: contextual hiding based on cursor position */
+13-1
crates/weaver-app/src/components/editor/cursor.rs
···48 let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
49 .ok_or("no mapping found for cursor offset")?;
50000051 // If cursor is in invisible content, snap to next visible position
52 // For now, we'll still use the mapping but this is a future enhancement
53 if should_snap {
···58 let window = web_sys::window().ok_or("no window")?;
59 let document = window.document().ok_or("no document")?;
6061- // Get the container element by node ID
62 let container = document
63 .get_element_by_id(&mapping.node_id)
000064 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
6566 // Set selection using Range API
···116 let mut accumulated_utf16 = 0;
117 let mut last_node: Option<web_sys::Node> = None;
1180119 while let Some(node) = walker.next_node()? {
120 last_node = Some(node.clone());
121122 if let Some(text) = node.text_content() {
123 let text_len = text.encode_utf16().count();
00124125 // Found the node containing target offset
126 if accumulated_utf16 + text_len >= target_utf16_offset {
127 let offset_in_node = target_utf16_offset - accumulated_utf16;
0128 return Ok((node, offset_in_node));
129 }
130
···48 let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
49 .ok_or("no mapping found for cursor offset")?;
5051+ tracing::info!("[CURSOR] Restoring cursor at offset {}", char_offset);
52+ tracing::info!("[CURSOR] found mapping: char_range {:?}, node_id '{}', char_offset_in_node {}",
53+ mapping.char_range, mapping.node_id, mapping.char_offset_in_node);
54+55 // If cursor is in invisible content, snap to next visible position
56 // For now, we'll still use the mapping but this is a future enhancement
57 if should_snap {
···62 let window = web_sys::window().ok_or("no window")?;
63 let document = window.document().ok_or("no document")?;
6465+ // Get the container element by node ID (try id attribute first, then data-node-id)
66 let container = document
67 .get_element_by_id(&mapping.node_id)
68+ .or_else(|| {
69+ let selector = format!("[data-node-id='{}']", mapping.node_id);
70+ document.query_selector(&selector).ok().flatten()
71+ })
72 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?;
7374 // Set selection using Range API
···124 let mut accumulated_utf16 = 0;
125 let mut last_node: Option<web_sys::Node> = None;
126127+ tracing::info!("[CURSOR] Walking text nodes, target_utf16_offset = {}", target_utf16_offset);
128 while let Some(node) = walker.next_node()? {
129 last_node = Some(node.clone());
130131 if let Some(text) = node.text_content() {
132 let text_len = text.encode_utf16().count();
133+ tracing::info!("[CURSOR] text node: '{}' (utf16_len {}), accumulated = {}",
134+ text.chars().take(20).collect::<String>(), text_len, accumulated_utf16);
135136 // Found the node containing target offset
137 if accumulated_utf16 + text_len >= target_utf16_offset {
138 let offset_in_node = target_utf16_offset - accumulated_utf16;
139+ tracing::info!("[CURSOR] -> FOUND at offset {} in this node", offset_in_node);
140 return Ok((node, offset_in_node));
141 }
142
+430-73
crates/weaver-app/src/components/editor/mod.rs
···8mod document;
9mod formatting;
10mod offset_map;
11-mod offsets;
12mod render;
13mod rope_writer;
14mod storage;
···18pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection};
19pub use formatting::{FormatAction, apply_formatting, find_word_boundaries};
20pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte};
21-pub use render::render_markdown_simple;
022pub use rope_writer::RopeWriter;
23pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage};
24pub use toolbar::EditorToolbar;
02526use dioxus::prelude::*;
27···58 let mut document = use_signal(|| EditorDocument::new(restored()));
59 let editor_id = "markdown-editor";
6061- // Render markdown to HTML with offset mappings
62- let render_result = use_memo(move || render::render_markdown_simple(&document().to_string()));
63- let rendered_html = use_memo(move || render_result.read().html.clone());
64- let offset_map = use_memo(move || render_result.read().offset_map.clone());
000000000000000000000000000000000000000000000006566 // Auto-save with debounce
67 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···75 timer.forget();
76 });
7778- // Restore cursor after re-render
79- #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
80- use_effect(move || {
81- use wasm_bindgen::prelude::*;
82- use wasm_bindgen::JsCast;
83-84- let cursor_offset = document().cursor.offset;
85- let rope = document().rope.clone();
86- let map = offset_map.read().clone();
87-88- // Use requestAnimationFrame to wait for browser paint
89- let window = web_sys::window().expect("no window");
90-91- let closure = Closure::once(move || {
92- if let Err(e) = cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) {
93- tracing::warn!("Cursor restoration failed: {:?}", e);
94- }
95- });
96-97- let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
98- closure.forget();
99- });
100-101 rsx! {
102 Stylesheet { href: asset!("/assets/styling/editor.css") }
103 div { class: "markdown-editor-container",
···111 id: "{editor_id}",
112 class: "editor-content",
113 contenteditable: "true",
114- dangerous_inner_html: "{rendered_html}",
115116 onkeydown: move |evt| {
117- evt.prevent_default();
118- handle_keydown(evt, &mut document);
00000000000000119 },
120121 onpaste: move |evt| {
122 evt.prevent_default();
123 handle_paste(evt, &mut document);
124 },
125-126- // Phase 1: Accept that cursor position will jump
127- // Phase 2: Restore cursor properly
128 }
129130···141 }
142}
14300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000144/// Handle paste events and insert text at cursor
145fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
146 // Downcast to web_sys event to get clipboard data
···213 doc.cursor.offset = start;
214 doc.selection = None;
215 } else if doc.cursor.offset > 0 {
216- // Delete previous char
217- let prev = doc.cursor.offset - 1;
218- doc.rope.remove(prev..doc.cursor.offset);
219- doc.cursor.offset = prev;
000000000000000000000000000000000000000000220 }
221 }
222···233 }
234 }
235236- Key::ArrowLeft => {
237- if mods.ctrl() {
238- // Word boundary (implement later)
239- if doc.cursor.offset > 0 {
240- doc.cursor.offset -= 1;
241- }
242- } else if doc.cursor.offset > 0 {
243- doc.cursor.offset -= 1;
244- }
245- doc.selection = None;
246- }
247-248- Key::ArrowRight => {
249- if mods.ctrl() {
250- // Word boundary (implement later)
251- if doc.cursor.offset < doc.len_chars() {
252- doc.cursor.offset += 1;
253- }
254- } else if doc.cursor.offset < doc.len_chars() {
255- doc.cursor.offset += 1;
256- }
257- doc.selection = None;
258 }
259260 Key::Enter => {
···265 doc.cursor.offset = start;
266 doc.selection = None;
267 }
268- // Insert two spaces + newline for hard line break
269- doc.rope.insert(doc.cursor.offset, " \n");
270- doc.cursor.offset += 3;
271- }
272273- Key::Home => {
274- let line_start = find_line_start(&doc.rope, doc.cursor.offset);
275- doc.cursor.offset = line_start;
276- doc.selection = None;
000000000000000277 }
278279- Key::End => {
280- let line_end = find_line_end(&doc.rope, doc.cursor.offset);
281- doc.cursor.offset = line_end;
282- doc.selection = None;
283 }
284285 _ => {}
···287 });
288}
2890000000000000000000290/// Find start of line containing offset
291fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize {
292 // Search backwards from cursor for newline
···326327 rope.len_chars()
328}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···8mod document;
9mod formatting;
10mod offset_map;
11+mod paragraph;
12mod render;
13mod rope_writer;
14mod storage;
···18pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection};
19pub use formatting::{FormatAction, apply_formatting, find_word_boundaries};
20pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte};
21+pub use paragraph::ParagraphRender;
22+pub use render::{render_markdown_simple, render_paragraphs};
23pub use rope_writer::RopeWriter;
24pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage};
25pub use toolbar::EditorToolbar;
26+pub use writer::WriterResult;
2728use dioxus::prelude::*;
29···60 let mut document = use_signal(|| EditorDocument::new(restored()));
61 let editor_id = "markdown-editor";
6263+ // Render paragraphs for incremental updates
64+ let paragraphs = use_memo(move || render::render_paragraphs(&document().rope));
65+66+ // Flatten offset maps from all paragraphs
67+ let offset_map = use_memo(move || {
68+ paragraphs()
69+ .iter()
70+ .flat_map(|p| p.offset_map.iter().cloned())
71+ .collect::<Vec<_>>()
72+ });
73+74+ // Track previous paragraphs for change detection (outside effect so it persists)
75+ let mut prev_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
76+77+ // Update DOM when paragraphs change (incremental rendering)
78+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
79+ use_effect(move || {
80+ let new_paras = paragraphs();
81+ let cursor_offset = document().cursor.offset;
82+83+ // Use peek() to avoid creating reactive dependency on prev_paragraphs
84+ let prev = prev_paragraphs.peek().clone();
85+86+ let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset);
87+88+ // Only restore cursor if we actually re-rendered the paragraph it's in
89+ if cursor_para_updated {
90+ use wasm_bindgen::JsCast;
91+ use wasm_bindgen::prelude::*;
92+93+ let rope = document().rope.clone();
94+ let map = offset_map();
95+96+ // Use requestAnimationFrame to wait for browser paint
97+ if let Some(window) = web_sys::window() {
98+ let closure = Closure::once(move || {
99+ if let Err(e) =
100+ cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id)
101+ {
102+ tracing::warn!("Cursor restoration failed: {:?}", e);
103+ }
104+ });
105+106+ let _ = window.request_animation_frame(closure.as_ref().unchecked_ref());
107+ closure.forget();
108+ }
109+ }
110+111+ // Store for next comparison (write-only, no reactive read)
112+ prev_paragraphs.set(new_paras);
113+ });
114115 // Auto-save with debounce
116 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···124 timer.forget();
125 });
12600000000000000000000000127 rsx! {
128 Stylesheet { href: asset!("/assets/styling/editor.css") }
129 div { class: "markdown-editor-container",
···137 id: "{editor_id}",
138 class: "editor-content",
139 contenteditable: "true",
140+ // DOM populated via web-sys in use_effect for incremental updates
141142 onkeydown: move |evt| {
143+ // Only prevent default for operations that modify content
144+ // Let browser handle arrow keys, Home/End naturally
145+ if should_intercept_key(&evt) {
146+ evt.prevent_default();
147+ handle_keydown(evt, &mut document);
148+ }
149+ },
150+151+ onkeyup: move |evt| {
152+ // After any key (including arrow keys), sync cursor from DOM
153+ sync_cursor_from_dom(&mut document, editor_id);
154+ },
155+156+ onclick: move |_evt| {
157+ // After mouse click, sync cursor from DOM
158+ sync_cursor_from_dom(&mut document, editor_id);
159 },
160161 onpaste: move |evt| {
162 evt.prevent_default();
163 handle_paste(evt, &mut document);
164 },
000165 }
166167···178 }
179}
180181+/// Check if we need to intercept this key event
182+/// Returns true for content-modifying operations, false for navigation
183+fn should_intercept_key(evt: &Event<KeyboardData>) -> bool {
184+ use dioxus::prelude::keyboard_types::Key;
185+186+ let key = evt.key();
187+ let mods = evt.modifiers();
188+189+ // Intercept shortcuts
190+ if mods.ctrl() || mods.meta() {
191+ return true;
192+ }
193+194+ // Intercept content modifications
195+ matches!(
196+ key,
197+ Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab
198+ )
199+}
200+201+/// Sync internal cursor state from browser DOM selection
202+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
203+fn sync_cursor_from_dom(document: &mut Signal<EditorDocument>, editor_id: &str) {
204+ use wasm_bindgen::JsCast;
205+206+ let window = match web_sys::window() {
207+ Some(w) => w,
208+ None => return,
209+ };
210+211+ let dom_document = match window.document() {
212+ Some(d) => d,
213+ None => return,
214+ };
215+216+ // Get editor element as boundary for search
217+ let editor_element = match dom_document.get_element_by_id(editor_id) {
218+ Some(e) => e,
219+ None => return,
220+ };
221+222+ let selection = match window.get_selection() {
223+ Ok(Some(sel)) => sel,
224+ _ => return,
225+ };
226+227+ // Get cursor position from selection
228+ let focus_node = match selection.focus_node() {
229+ Some(node) => node,
230+ None => return,
231+ };
232+233+ let focus_offset = selection.focus_offset() as usize;
234+235+ // Find the text node's containing element with an ID (from offset map)
236+ // Walk up but stop at editor boundary to avoid escaping the editor
237+ let mut current_node = focus_node.clone();
238+ let node_id = loop {
239+ if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
240+ // Stop if we've reached the editor boundary
241+ if element == &editor_element {
242+ break None;
243+ }
244+245+ // Check both id and data-node-id attributes
246+ // (paragraphs use id, headings use data-node-id to preserve user heading IDs)
247+ let id = element
248+ .get_attribute("id")
249+ .or_else(|| element.get_attribute("data-node-id"));
250+251+ if let Some(id) = id {
252+ // Look for node IDs like "n0", "n1", etc (from offset map)
253+ if id.starts_with('n') && id[1..].parse::<usize>().is_ok() {
254+ break Some(id);
255+ }
256+ }
257+ }
258+259+ current_node = match current_node.parent_node() {
260+ Some(parent) => parent,
261+ None => break None,
262+ };
263+ };
264+265+ let node_id = match node_id {
266+ Some(id) => id,
267+ None => {
268+ tracing::warn!("Could not find node_id for cursor position");
269+ return;
270+ }
271+ };
272+273+ let container = match dom_document.get_element_by_id(&node_id).or_else(|| {
274+ let selector = format!("[data-node-id='{}']", node_id);
275+ dom_document.query_selector(&selector).ok().flatten()
276+ }) {
277+ Some(e) => e,
278+ None => return,
279+ };
280+281+ // Calculate UTF-16 offset from start of container to focus position
282+ let mut utf16_offset_in_container = 0;
283+284+ // Create tree walker for text nodes in container
285+ if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) {
286+ while let Ok(Some(node)) = walker.next_node() {
287+ if node == focus_node {
288+ // Found the exact text node, add the offset within it
289+ utf16_offset_in_container += focus_offset;
290+ break;
291+ }
292+293+ // Accumulate length of previous text nodes
294+ if let Some(text) = node.text_content() {
295+ utf16_offset_in_container += text.encode_utf16().count();
296+ }
297+ }
298+ }
299+300+ // Now look up this position in the offset map
301+ // We need to find the mapping with this node_id and calculate rope offset
302+ document.with_mut(|doc| {
303+ // Render to get current offset maps
304+ let paragraphs = render::render_paragraphs(&doc.rope);
305+306+ tracing::debug!("[SYNC] Looking for node_id: {}, utf16_offset_in_container: {}", node_id, utf16_offset_in_container);
307+308+ // Find mapping with this node_id
309+ for para in paragraphs {
310+ for mapping in para.offset_map {
311+ if mapping.node_id == node_id {
312+ // Check if our utf16 offset falls within this mapping's range
313+ // End-INCLUSIVE to allow cursor at the end of text nodes
314+ let mapping_start = mapping.char_offset_in_node;
315+ let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
316+317+ if utf16_offset_in_container >= mapping_start && utf16_offset_in_container <= mapping_end {
318+ // Calculate rope offset
319+ let offset_in_mapping = utf16_offset_in_container - mapping_start;
320+ let rope_offset = mapping.char_range.start + offset_in_mapping;
321+322+ tracing::debug!("[SYNC] -> MATCHED! rope_offset: {} (was {})", rope_offset, doc.cursor.offset);
323+ doc.cursor.offset = rope_offset;
324+ return;
325+ }
326+ }
327+ }
328+ }
329+330+ tracing::warn!("Could not map DOM cursor position to rope offset");
331+ });
332+}
333+334+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
335+fn sync_cursor_from_dom(_document: &mut Signal<EditorDocument>, _editor_id: &str) {
336+ // No-op on non-wasm
337+}
338+339/// Handle paste events and insert text at cursor
340fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
341 // Downcast to web_sys event to get clipboard data
···408 doc.cursor.offset = start;
409 doc.selection = None;
410 } else if doc.cursor.offset > 0 {
411+ // Check if we're about to delete a newline
412+ let prev_char = get_char_at(&doc.rope, doc.cursor.offset - 1);
413+414+ if prev_char == Some('\n') {
415+ let newline_pos = doc.cursor.offset - 1;
416+ let mut delete_start = newline_pos;
417+ let mut delete_end = doc.cursor.offset;
418+419+ // Check if there's another newline before this one (empty paragraph)
420+ // If so, delete both newlines to merge paragraphs
421+ if newline_pos > 0 {
422+ let prev_prev_char = get_char_at(&doc.rope, newline_pos - 1);
423+ if prev_prev_char == Some('\n') {
424+ // Empty paragraph case: delete both newlines
425+ delete_start = newline_pos - 1;
426+ }
427+ }
428+429+ // Also check if there's a zero-width char after cursor (inserted by Shift+Enter)
430+ if let Some(ch) = get_char_at(&doc.rope, delete_end) {
431+ if ch == '\u{200C}' || ch == '\u{200B}' {
432+ delete_end += 1;
433+ }
434+ }
435+436+ // Scan backwards through whitespace before the newline(s)
437+ while delete_start > 0 {
438+ let ch = get_char_at(&doc.rope, delete_start - 1);
439+ match ch {
440+ Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => {
441+ delete_start -= 1;
442+ }
443+ Some('\n') => break, // stop at another newline
444+ _ => break, // stop at actual content
445+ }
446+ }
447+448+ // Delete from where we stopped to end (including any trailing zero-width)
449+ doc.rope.remove(delete_start..delete_end);
450+ doc.cursor.offset = delete_start;
451+ } else {
452+ // Normal backspace - delete one char
453+ let prev = doc.cursor.offset - 1;
454+ doc.rope.remove(prev..doc.cursor.offset);
455+ doc.cursor.offset = prev;
456+ }
457 }
458 }
459···470 }
471 }
472473+ // Arrow keys handled by browser, synced in onkeyup
474+ Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => {
475+ // Browser handles these naturally
0000000000000000000476 }
477478 Key::Enter => {
···483 doc.cursor.offset = start;
484 doc.selection = None;
485 }
0000486487+ if mods.shift() {
488+ // Shift+Enter: hard line break (soft break)
489+ doc.rope.insert(doc.cursor.offset, " \n\u{200C}");
490+ doc.cursor.offset += 3;
491+ } else {
492+ // Enter: paragraph break (much cleaner, less jank)
493+ tracing::info!(
494+ "[ENTER] Before insert - cursor at {}, rope len {}",
495+ doc.cursor.offset,
496+ doc.len_chars()
497+ );
498+ doc.rope.insert(doc.cursor.offset, "\n\n");
499+ doc.cursor.offset += 2;
500+ tracing::info!(
501+ "[ENTER] After insert - cursor at {}, rope len {}",
502+ doc.cursor.offset,
503+ doc.len_chars()
504+ );
505+ }
506 }
507508+ // Home/End handled by browser, synced in onkeyup
509+ Key::Home | Key::End => {
510+ // Browser handles these naturally
0511 }
512513 _ => {}
···515 });
516}
517518+/// Get character at the given offset in the rope
519+fn get_char_at(rope: &jumprope::JumpRopeBuf, offset: usize) -> Option<char> {
520+ if offset >= rope.len_chars() {
521+ return None;
522+ }
523+524+ let rope = rope.borrow();
525+ let mut current = 0;
526+ for substr in rope.slice_substrings(offset..offset + 1) {
527+ for c in substr.chars() {
528+ if current == 0 {
529+ return Some(c);
530+ }
531+ current += 1;
532+ }
533+ }
534+ None
535+}
536+537/// Find start of line containing offset
538fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize {
539 // Search backwards from cursor for newline
···573574 rope.len_chars()
575}
576+577+/// Update paragraph DOM elements incrementally.
578+///
579+/// Only modifies paragraphs that changed (by comparing source_hash).
580+/// Browser preserves cursor naturally in unchanged paragraphs.
581+///
582+/// Returns true if the paragraph containing the cursor was updated.
583+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
584+fn update_paragraph_dom(
585+ editor_id: &str,
586+ old_paragraphs: &[ParagraphRender],
587+ new_paragraphs: &[ParagraphRender],
588+ cursor_offset: usize,
589+) -> bool {
590+ use wasm_bindgen::JsCast;
591+592+ let window = match web_sys::window() {
593+ Some(w) => w,
594+ None => return false,
595+ };
596+597+ let document = match window.document() {
598+ Some(d) => d,
599+ None => return false,
600+ };
601+602+ let editor = match document.get_element_by_id(editor_id) {
603+ Some(e) => e,
604+ None => return false,
605+ };
606+607+ // Find which paragraph contains cursor
608+ // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N)
609+ // This handles typing at end of paragraph, which is the common case
610+ // The empty paragraph at document end catches any trailing cursor positions
611+ let cursor_para_idx = new_paragraphs
612+ .iter()
613+ .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end);
614+615+ tracing::info!(
616+ "[DOM] cursor_offset = {}, cursor_para_idx = {:?}",
617+ cursor_offset,
618+ cursor_para_idx
619+ );
620+ for (idx, para) in new_paragraphs.iter().enumerate() {
621+ let matches =
622+ para.char_range.start <= cursor_offset && cursor_offset <= para.char_range.end;
623+ tracing::info!(
624+ "[DOM] para {}: char_range {:?}, matches cursor? {}",
625+ idx,
626+ para.char_range,
627+ matches
628+ );
629+ }
630+631+ let mut cursor_para_updated = false;
632+633+ // Update or create paragraphs
634+ for (idx, new_para) in new_paragraphs.iter().enumerate() {
635+ let para_id = format!("para-{}", idx);
636+637+ if let Some(old_para) = old_paragraphs.get(idx) {
638+ // Paragraph exists - check if changed
639+ if new_para.source_hash != old_para.source_hash {
640+ // Changed - update innerHTML
641+ if let Some(elem) = document.get_element_by_id(¶_id) {
642+ elem.set_inner_html(&new_para.html);
643+ }
644+645+ // Track if we updated the cursor's paragraph
646+ if Some(idx) == cursor_para_idx {
647+ cursor_para_updated = true;
648+ }
649+ }
650+ // Unchanged - do nothing, browser preserves cursor
651+ } else {
652+ // New paragraph - create div
653+ if let Ok(div) = document.create_element("div") {
654+ div.set_id(¶_id);
655+ div.set_inner_html(&new_para.html);
656+ let _ = editor.append_child(&div);
657+ }
658+659+ // Track if we created the cursor's paragraph
660+ if Some(idx) == cursor_para_idx {
661+ cursor_para_updated = true;
662+ }
663+ }
664+ }
665+666+ // Remove extra paragraphs if document got shorter
667+ for idx in new_paragraphs.len()..old_paragraphs.len() {
668+ let para_id = format!("para-{}", idx);
669+ if let Some(elem) = document.get_element_by_id(¶_id) {
670+ let _ = elem.remove();
671+ }
672+ }
673+674+ cursor_para_updated
675+}
676+677+#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
678+fn update_paragraph_dom(
679+ _editor_id: &str,
680+ _old_paragraphs: &[ParagraphRender],
681+ _new_paragraphs: &[ParagraphRender],
682+ _cursor_offset: usize,
683+) -> bool {
684+ false
685+}
···1+//! Paragraph-level rendering for incremental updates.
2+//!
3+//! Paragraphs are discovered during markdown rendering by tracking
4+//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
5+6+use super::offset_map::OffsetMapping;
7+use jumprope::JumpRopeBuf;
8+use std::ops::Range;
9+10+/// A rendered paragraph with its source range and offset mappings.
11+#[derive(Debug, Clone, PartialEq)]
12+pub struct ParagraphRender {
13+ /// Source byte range in the rope
14+ pub byte_range: Range<usize>,
15+16+ /// Source char range in the rope
17+ pub char_range: Range<usize>,
18+19+ /// Rendered HTML content (without wrapper div)
20+ pub html: String,
21+22+ /// Offset mappings for this paragraph
23+ pub offset_map: Vec<OffsetMapping>,
24+25+ /// Hash of source text for quick change detection
26+ pub source_hash: u64,
27+}
28+29+/// Simple hash function for source text comparison
30+pub fn hash_source(text: &str) -> u64 {
31+ use std::collections::hash_map::DefaultHasher;
32+ use std::hash::{Hash, Hasher};
33+34+ let mut hasher = DefaultHasher::new();
35+ text.hash(&mut hasher);
36+ hasher.finish()
37+}
38+39+/// Extract substring from rope as String
40+pub fn rope_slice_to_string(rope: &JumpRopeBuf, range: Range<usize>) -> String {
41+ let rope_borrow = rope.borrow();
42+ let mut result = String::new();
43+44+ for substr in rope_borrow.slice_substrings(range) {
45+ result.push_str(substr);
46+ }
47+48+ result
49+}
50+
+155-13
crates/weaver-app/src/components/editor/render.rs
···1//! Markdown rendering for the editor.
2//!
3-//! Phase 2: Full-document rendering with formatting characters visible as styled spans.
4-//! Future: Incremental paragraph rendering and contextual formatting visibility.
5//!
6//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
78-use markdown_weaver::Parser;
9-use super::offset_map::RenderResult;
10use super::writer::EditorWriter;
001112/// Render markdown to HTML with visible formatting characters and offset mappings.
13///
···24/// - Offset map generation for cursor restoration
25/// - Full document re-render (fast enough for current needs)
26///
27-/// # Future improvements
28-/// - Paragraph-level incremental rendering
29-/// - Contextual formatting hiding based on cursor position
30pub fn render_markdown_simple(source: &str) -> RenderResult {
31- use jumprope::JumpRopeBuf;
32-33 let source_rope = JumpRopeBuf::from(source);
34- let parser = Parser::new_ext(source, weaver_renderer::default_md_options())
35- .into_offset_iter();
36 let mut output = String::new();
3738 match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
39- Ok(offset_map) => RenderResult {
40 html: output,
41- offset_map,
42 },
43 Err(_) => {
44 // Fallback to empty result on error
···49 }
50 }
51}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1//! Markdown rendering for the editor.
2//!
3+//! Phase 2: Paragraph-level incremental rendering with formatting characters visible.
04//!
5//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters.
67+use super::offset_map::{OffsetMapping, RenderResult};
8+use super::paragraph::{ParagraphRender, hash_source, rope_slice_to_string};
9use super::writer::EditorWriter;
10+use jumprope::JumpRopeBuf;
11+use markdown_weaver::Parser;
1213/// Render markdown to HTML with visible formatting characters and offset mappings.
14///
···25/// - Offset map generation for cursor restoration
26/// - Full document re-render (fast enough for current needs)
27///
28+/// # Deprecated: Use `render_paragraphs()` for incremental rendering
0029pub fn render_markdown_simple(source: &str) -> RenderResult {
0030 let source_rope = JumpRopeBuf::from(source);
31+ let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter();
032 let mut output = String::new();
3334 match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() {
35+ Ok(result) => RenderResult {
36 html: output,
37+ offset_map: result.offset_maps,
38 },
39 Err(_) => {
40 // Fallback to empty result on error
···45 }
46 }
47}
48+49+/// Render markdown in paragraph chunks for incremental DOM updates.
50+///
51+/// First renders the whole document to discover paragraph boundaries via
52+/// markdown events (Tag::Paragraph), then re-renders each paragraph separately.
53+/// This allows updating only changed paragraphs in the DOM, preserving cursor
54+/// position naturally.
55+///
56+/// # Returns
57+///
58+/// A vector of `ParagraphRender` structs, each containing:
59+/// - Source byte and char ranges
60+/// - Rendered HTML (without wrapper div)
61+/// - Offset mappings for that paragraph
62+/// - Source hash for change detection
63+///
64+/// # Phase 2 Benefits
65+/// - Only re-render changed paragraphs
66+/// - Browser preserves cursor in unchanged paragraphs naturally
67+/// - Faster for large documents
68+/// - No manual cursor restoration needed for most edits
69+pub fn render_paragraphs(rope: &JumpRopeBuf) -> Vec<ParagraphRender> {
70+ let source = rope.to_string();
71+72+ // Handle empty rope - return single empty paragraph for cursor positioning
73+ if source.is_empty() {
74+ let empty_node_id = "n0".to_string();
75+ let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
76+77+ return vec![ParagraphRender {
78+ byte_range: 0..0,
79+ char_range: 0..0,
80+ html: empty_html,
81+ offset_map: vec![],
82+ source_hash: 0,
83+ }];
84+ }
85+86+ // First pass: render whole document to get paragraph boundaries
87+ // TODO: CACHE THIS!
88+ let parser = Parser::new_ext(&source, weaver_renderer::default_md_options()).into_offset_iter();
89+ let mut scratch_output = String::new();
90+91+ let paragraph_ranges =
92+ match EditorWriter::<_, _, ()>::new(&source, rope, parser, &mut scratch_output).run() {
93+ Ok(result) => result.paragraph_ranges,
94+ Err(_) => return Vec::new(),
95+ };
96+97+ // Second pass: render each paragraph separately
98+ let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
99+ let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
100+101+ tracing::info!("[RENDER] Rendering {} paragraphs", paragraph_ranges.len());
102+ for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
103+ tracing::info!("[RENDER] Paragraph {}: char_range {:?}", idx, char_range);
104+ // Extract paragraph source
105+ let para_source = rope_slice_to_string(rope, char_range.clone());
106+ let source_hash = hash_source(¶_source);
107+108+ // Render this paragraph with unique node IDs
109+ let para_rope = JumpRopeBuf::from(para_source.as_str());
110+ let parser =
111+ Parser::new_ext(¶_source, weaver_renderer::default_md_options()).into_offset_iter();
112+ let mut output = String::new();
113+114+ let mut offset_map = match EditorWriter::<_, _, ()>::new_with_node_offset(
115+ ¶_source,
116+ ¶_rope,
117+ parser,
118+ &mut output,
119+ node_id_offset,
120+ )
121+ .run()
122+ {
123+ Ok(result) => {
124+ // Update node ID offset for next paragraph
125+ // Count how many unique node IDs were used in this paragraph
126+ let max_node_id = result
127+ .offset_maps
128+ .iter()
129+ .filter_map(|m| {
130+ m.node_id
131+ .strip_prefix("n")
132+ .and_then(|s| s.parse::<usize>().ok())
133+ })
134+ .max()
135+ .unwrap_or(node_id_offset);
136+ node_id_offset = max_node_id + 1;
137+138+ result.offset_maps
139+ }
140+ Err(_) => Vec::new(),
141+ };
142+143+ // Adjust offset map to be relative to document, not paragraph
144+ // Each mapping's ranges need to be shifted by paragraph start
145+ let para_char_start = char_range.start;
146+ let para_byte_start = byte_range.start;
147+148+ for mapping in &mut offset_map {
149+ mapping.byte_range.start += para_byte_start;
150+ mapping.byte_range.end += para_byte_start;
151+ mapping.char_range.start += para_char_start;
152+ mapping.char_range.end += para_char_start;
153+ }
154+155+ paragraphs.push(ParagraphRender {
156+ byte_range: byte_range.clone(),
157+ char_range: char_range.clone(),
158+ html: output,
159+ offset_map,
160+ source_hash,
161+ });
162+ }
163+164+ // Check if rope ends with trailing newlines (empty paragraph at end)
165+ // If so, add an empty paragraph div for cursor positioning
166+ let source = rope.to_string();
167+ let has_trailing_newlines = source.ends_with("\n\n") || source.ends_with("\n");
168+169+ if has_trailing_newlines {
170+ let doc_end_char = rope.len_chars();
171+ let doc_end_byte = rope.len_bytes();
172+173+ let empty_node_id = format!("n{}", node_id_offset);
174+ let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
175+176+ paragraphs.push(ParagraphRender {
177+ byte_range: doc_end_byte..doc_end_byte,
178+ char_range: doc_end_char..doc_end_char + 1, // range for the zero-width space
179+ html: empty_html,
180+ offset_map: vec![OffsetMapping {
181+ byte_range: doc_end_byte..doc_end_byte,
182+ char_range: doc_end_char..doc_end_char + 1,
183+ node_id: empty_node_id,
184+ char_offset_in_node: 0,
185+ child_index: None,
186+ utf16_len: 1, // zero-width space is 1 UTF-16 code unit
187+ }],
188+ source_hash: 0, // always render this paragraph
189+ });
190+ }
191+192+ paragraphs
193+}
+177-126
crates/weaver-app/src/components/editor/writer.rs
···7//! represent consumed formatting characters.
89use super::offset_map::{OffsetMapping, RenderResult};
10-use super::offsets::{byte_to_char, char_to_byte};
11use jumprope::JumpRopeBuf;
12use markdown_weaver::{
13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
···18};
19use std::collections::HashMap;
20use std::ops::Range;
000000000002122/// Classification of markdown syntax characters
23#[derive(Debug, Clone, Copy, PartialEq)]
···100101 code_buffer: Option<(Option<String>, String)>, // (lang, content)
102 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content
0103 pending_blockquote_range: Option<Range<usize>>, // range for emitting > inside next paragraph
104105 // Table rendering mode
···113 current_node_char_offset: usize, // UTF-16 offset within current node
114 current_node_child_count: usize, // number of child elements/text nodes in current container
1150000116 _phantom: std::marker::PhantomData<&'a ()>,
117}
118···126 EditorWriter<'a, I, W, E>
127{
128 pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self {
0000000000129 Self {
130 source,
131 source_rope,
···142 embed_provider: None,
143 code_buffer: None,
144 code_buffer_byte_range: None,
0145 pending_blockquote_range: None,
146 render_tables_as_markdown: true, // Default to markdown rendering
147 table_start_offset: None,
148 offset_maps: Vec::new(),
149- next_node_id: 0,
150 current_node_id: None,
151 current_node_char_offset: 0,
152 current_node_child_count: 0,
00153 _phantom: std::marker::PhantomData,
154 }
155 }
···172 embed_provider: Some(provider),
173 code_buffer: self.code_buffer,
174 code_buffer_byte_range: self.code_buffer_byte_range,
0175 pending_blockquote_range: self.pending_blockquote_range,
176 render_tables_as_markdown: self.render_tables_as_markdown,
177 table_start_offset: self.table_start_offset,
···180 current_node_id: self.current_node_id,
181 current_node_char_offset: self.current_node_char_offset,
182 current_node_child_count: self.current_node_child_count,
00183 _phantom: std::marker::PhantomData,
184 }
185 }
···211 let char_start = self.last_char_offset;
212 let syntax_char_len = syntax.chars().count();
213214- tracing::debug!(
215- "emit_syntax: range={:?}, chars={}..{}, syntax={:?}",
216- range,
217- char_start,
218- char_start + syntax_char_len,
219- syntax
220- );
221-222 // If we're outside any node, create a wrapper span for tracking
223 let created_node = if self.current_node_id.is_none() {
224 let node_id = self.gen_node_id();
···241242 // Record offset mapping for this syntax
243 self.record_mapping(range.clone(), char_start..char_start + syntax_char_len);
244- self.last_char_offset = char_start + syntax_char_len;
245- self.last_byte_offset = range.end; // Mark bytes as processed
0000246247 // Close wrapper if we created one
248 if created_node {
···300 let utf16_len = wchar_end - wchar_start;
301302 let mapping = OffsetMapping {
303- byte_range,
304- char_range,
305 node_id: node_id.clone(),
306 char_offset_in_node: self.current_node_char_offset,
307 child_index: None, // text-based position
···309 };
310 self.offset_maps.push(mapping);
311 self.current_node_char_offset += utf16_len;
00312 }
313 }
314315 /// Process markdown events and write HTML.
316 ///
317- /// Returns the offset mappings. The HTML is written to the writer
318- /// passed in the constructor.
319- pub fn run(mut self) -> Result<Vec<OffsetMapping>, W::Error> {
320 while let Some((event, range)) = self.events.next() {
00000000000000000000000321 // For End events, emit any trailing content within the event's range
322 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
323 if matches!(&event, Event::End(_)) {
···330 self.emit_gap_before(range.start)?;
331 }
332000333 // Process the event (passing range for tag syntax)
334 self.process_event(event, range.clone())?;
335336- // Update tracking
337- self.last_byte_offset = range.end;
00000338 }
339340 // Emit any trailing syntax
···346 let doc_char_len = self.source_rope.len_chars();
347348 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
349- tracing::debug!(
350- "Unmapped trailing content: bytes {}..{}, chars {}..{}",
351- self.last_byte_offset,
352- doc_byte_len,
353- self.last_char_offset,
354- doc_char_len
355- );
356-357 // Emit the trailing content as visible syntax
358 if self.last_byte_offset < doc_byte_len {
359 let trailing = &self.source[self.last_byte_offset..];
···384 }
385 }
386387- Ok(self.offset_maps)
000388 }
389390 // Consume raw text events until end tag, for alt attributes
···436 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> {
437 use Event::*;
438439- tracing::debug!(
440- "Event: {:?}, range: {:?}",
441- match &event {
442- Start(tag) => format!("Start({:?})", tag),
443- End(tag) => format!("End({:?})", tag),
444- Text(t) => format!("Text({:?})", &t[..t.len().min(20)]),
445- _ => format!("{:?}", event),
446- },
447- range
448- );
449 match event {
450 Start(tag) => self.start_tag(tag, range)?,
451 End(tag) => self.end_tag(tag, range)?,
···454 if let Some((_, ref mut buffer)) = self.code_buffer {
455 buffer.push_str(&text);
456457- // Track byte range for code block content
458- if let Some(ref mut code_range) = self.code_buffer_byte_range {
459- // Extend existing range
460- code_range.end = range.end;
0000461 } else {
462 // First text in code block - start tracking
463 self.code_buffer_byte_range = Some(range.clone());
0464 }
465 } else if !self.in_non_writing_block {
466 // Escape HTML and count chars in one pass
···468 let text_char_len =
469 escape_html_body_text_with_char_count(&mut self.writer, &text)?;
470 let char_end = char_start + text_char_len;
471-472- tracing::debug!(
473- "Text event: range={:?}, chars={}..{}, text={:?}",
474- range,
475- char_start,
476- char_end,
477- &text[..text.len().min(40)]
478- );
479480 // Text becomes a text node child of the current container
481 if text_char_len > 0 {
···580 let gap = &self.source[range.clone()];
581 if gap.ends_with('\n') {
582 let spaces = &gap[..gap.len() - 1]; // everything except the \n
583- let char_start = byte_to_char(self.source, range.start);
584 let spaces_char_len = spaces.chars().count();
585586 // Emit and map the visible spaces
···602 // Count the <br> as a child
603 self.current_node_child_count += 1;
604605- // Map the newline to an element-based position (after the <br>)
606- // The binary search is end-inclusive, so cursor at position N+1
607- // will match a mapping with range N..N+1
0000608 if let Some(ref node_id) = self.current_node_id {
609 let newline_char_offset = char_start + spaces_char_len;
610 let mapping = OffsetMapping {
611 byte_range: range.start + spaces.len()..range.end,
612 char_range: newline_char_offset..newline_char_offset + 1,
613 node_id: node_id.clone(),
614- char_offset_in_node: 0,
615- child_index: Some(self.current_node_child_count),
616- utf16_len: 0,
617 };
618 self.offset_maps.push(mapping);
000619 }
62000621 self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n
622 } else {
623 // Fallback: just <br>
···724 SyntaxClass::Inline => "md-syntax-inline",
725 SyntaxClass::Block => "md-syntax-block",
726 };
00000727 self.write("<span class=\"")?;
728 self.write(class)?;
729 self.write("\">")?;
730 escape_html(&mut self.writer, syntax)?;
731 self.write("</span>")?;
0000000732 }
733 }
734···736 match tag {
737 Tag::HtmlBlock => Ok(()),
738 Tag::Paragraph => {
000739 let node_id = self.gen_node_id();
740 if self.end_newline {
741 write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
···791 classes,
792 attrs,
793 } => {
0000794 if !self.end_newline {
795 self.write("\n")?;
796 }
···835 self.write(">")?;
836837 // Begin node tracking for offset mapping
838- self.begin_node(node_id);
0000000000000839840 // Emit # syntax inside the heading tag
841 if range.start < range.end {
842- let raw_text = &self.source[range];
843 let count = level as usize;
844 let pattern = "#".repeat(count);
845···849 let syntax_start = hash_pos;
850 let syntax_end = (hash_pos + count + 1).min(raw_text.len());
851 let syntax = &raw_text[syntax_start..syntax_end];
000000852853 self.write("<span class=\"md-syntax-block\">")?;
854 escape_html(&mut self.writer, syntax)?;
855 self.write("</span>")?;
00000000856 }
857 }
858 Ok(())
···1193 let result = match tag {
1194 TagEnd::HtmlBlock => Ok(()),
1195 TagEnd::Paragraph => {
00000001196 self.end_node();
1197 self.write("</p>\n")
1198 }
1199 TagEnd::Heading(level) => {
00000001200 self.end_node();
1201 self.write("</")?;
1202 write!(&mut self.writer, "{}", level)?;
···1255 LazyLock::new(|| SyntaxSet::load_defaults_newlines());
12561257 if let Some((lang, buffer)) = self.code_buffer.take() {
1258- // Create offset mapping for code block content if we tracked a range
1259- if let Some(code_byte_range) = self.code_buffer_byte_range.take() {
1260- // Calculate char range from the tracked byte range
1261- let char_start = byte_to_char(self.source, code_byte_range.start);
1262- let char_end = byte_to_char(self.source, code_byte_range.end);
1263- let char_range = char_start..char_end;
1264-1265 // Record mapping before writing HTML
1266 // (current_node_id should be set by start_tag for CodeBlock)
1267- self.record_mapping(code_byte_range, char_range);
1268 }
12691270 if let Some(ref lang_str) = lang {
···13481349 result?;
13501351- // Extract and emit closing syntax based on tag type
1352- if range.start < range.end {
1353- let raw_text = &self.source[range];
1354- let closing_syntax = match &tag {
1355- TagEnd::Strong => {
1356- if raw_text.ends_with("**") {
1357- Some("**")
1358- } else if raw_text.ends_with("__") {
1359- Some("__")
1360- } else {
1361- None
1362- }
1363- }
1364- TagEnd::Emphasis => {
1365- if raw_text.ends_with("*") {
1366- Some("*")
1367- } else if raw_text.ends_with("_") {
1368- Some("_")
1369- } else {
1370- None
1371- }
1372- }
1373- TagEnd::Strikethrough => {
1374- if raw_text.ends_with("~~") {
1375- Some("~~")
1376- } else {
1377- None
1378- }
1379- }
1380- TagEnd::Link => {
1381- // Extract ](url) part
1382- if let Some(idx) = raw_text.rfind("](") {
1383- Some(&raw_text[idx..])
1384- } else {
1385- None
1386- }
1387- }
1388- TagEnd::CodeBlock => {
1389- if raw_text.ends_with("```") {
1390- raw_text.lines().last()
1391- } else {
1392- None
1393- }
1394- }
1395- _ => None,
1396- };
1397-1398- if let Some(syntax) = closing_syntax {
1399- let class = match classify_syntax(syntax) {
1400- SyntaxClass::Inline => "md-syntax-inline",
1401- SyntaxClass::Block => "md-syntax-block",
1402- };
1403- self.write("<span class=\"")?;
1404- self.write(class)?;
1405- self.write("\">")?;
1406- escape_html(&mut self.writer, syntax)?;
1407- self.write("</span>")?;
1408- }
1409- }
14101411 Ok(())
1412 }
···7//! represent consumed formatting characters.
89use super::offset_map::{OffsetMapping, RenderResult};
010use jumprope::JumpRopeBuf;
11use markdown_weaver::{
12 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
···17};
18use std::collections::HashMap;
19use std::ops::Range;
20+21+/// Result of rendering with the EditorWriter.
22+#[derive(Debug, Clone)]
23+pub struct WriterResult {
24+ /// Offset mappings from source to DOM positions
25+ pub offset_maps: Vec<OffsetMapping>,
26+27+ /// Paragraph boundaries in source: (byte_range, char_range)
28+ /// These are extracted during rendering by tracking Tag::Paragraph events
29+ pub paragraph_ranges: Vec<(Range<usize>, Range<usize>)>,
30+}
3132/// Classification of markdown syntax characters
33#[derive(Debug, Clone, Copy, PartialEq)]
···110111 code_buffer: Option<(Option<String>, String)>, // (lang, content)
112 code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content
113+ code_buffer_char_range: Option<Range<usize>>, // char range of buffered code content
114 pending_blockquote_range: Option<Range<usize>>, // range for emitting > inside next paragraph
115116 // Table rendering mode
···124 current_node_char_offset: usize, // UTF-16 offset within current node
125 current_node_child_count: usize, // number of child elements/text nodes in current container
126127+ // Paragraph boundary tracking for incremental rendering
128+ paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range)
129+ current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset)
130+131 _phantom: std::marker::PhantomData<&'a ()>,
132}
133···141 EditorWriter<'a, I, W, E>
142{
143 pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self {
144+ Self::new_with_node_offset(source, source_rope, events, writer, 0)
145+ }
146+147+ pub fn new_with_node_offset(
148+ source: &'a str,
149+ source_rope: &'a JumpRopeBuf,
150+ events: I,
151+ writer: W,
152+ node_id_offset: usize,
153+ ) -> Self {
154 Self {
155 source,
156 source_rope,
···167 embed_provider: None,
168 code_buffer: None,
169 code_buffer_byte_range: None,
170+ code_buffer_char_range: None,
171 pending_blockquote_range: None,
172 render_tables_as_markdown: true, // Default to markdown rendering
173 table_start_offset: None,
174 offset_maps: Vec::new(),
175+ next_node_id: node_id_offset,
176 current_node_id: None,
177 current_node_char_offset: 0,
178 current_node_child_count: 0,
179+ paragraph_ranges: Vec::new(),
180+ current_paragraph_start: None,
181 _phantom: std::marker::PhantomData,
182 }
183 }
···200 embed_provider: Some(provider),
201 code_buffer: self.code_buffer,
202 code_buffer_byte_range: self.code_buffer_byte_range,
203+ code_buffer_char_range: self.code_buffer_char_range,
204 pending_blockquote_range: self.pending_blockquote_range,
205 render_tables_as_markdown: self.render_tables_as_markdown,
206 table_start_offset: self.table_start_offset,
···209 current_node_id: self.current_node_id,
210 current_node_char_offset: self.current_node_char_offset,
211 current_node_child_count: self.current_node_child_count,
212+ paragraph_ranges: self.paragraph_ranges,
213+ current_paragraph_start: self.current_paragraph_start,
214 _phantom: std::marker::PhantomData,
215 }
216 }
···242 let char_start = self.last_char_offset;
243 let syntax_char_len = syntax.chars().count();
24400000000245 // If we're outside any node, create a wrapper span for tracking
246 let created_node = if self.current_node_id.is_none() {
247 let node_id = self.gen_node_id();
···264265 // Record offset mapping for this syntax
266 self.record_mapping(range.clone(), char_start..char_start + syntax_char_len);
267+ let new_char = char_start + syntax_char_len;
268+ let new_byte = range.end;
269+ tracing::debug!("[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}",
270+ self.last_char_offset, new_char, self.last_byte_offset, new_byte);
271+ self.last_char_offset = new_char;
272+ self.last_byte_offset = new_byte; // Mark bytes as processed
273274 // Close wrapper if we created one
275 if created_node {
···327 let utf16_len = wchar_end - wchar_start;
328329 let mapping = OffsetMapping {
330+ byte_range: byte_range.clone(),
331+ char_range: char_range.clone(),
332 node_id: node_id.clone(),
333 char_offset_in_node: self.current_node_char_offset,
334 child_index: None, // text-based position
···336 };
337 self.offset_maps.push(mapping);
338 self.current_node_char_offset += utf16_len;
339+ } else {
340+ tracing::warn!("[RECORD_MAPPING] SKIPPED - current_node_id is None!");
341 }
342 }
343344 /// Process markdown events and write HTML.
345 ///
346+ /// Returns offset mappings and paragraph boundaries. The HTML is written
347+ /// to the writer passed in the constructor.
348+ pub fn run(mut self) -> Result<WriterResult, W::Error> {
349 while let Some((event, range)) = self.events.next() {
350+ // Log events for debugging
351+ tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}",
352+ match &event {
353+ Event::Start(tag) => format!("Start({:?})", tag),
354+ Event::End(tag) => format!("End({:?})", tag),
355+ Event::Text(t) => format!("Text('{}')", t),
356+ Event::Code(t) => format!("Code('{}')", t),
357+ Event::Html(t) => format!("Html('{}')", t),
358+ Event::InlineHtml(t) => format!("InlineHtml('{}')", t),
359+ Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t),
360+ Event::SoftBreak => "SoftBreak".to_string(),
361+ Event::HardBreak => "HardBreak".to_string(),
362+ Event::Rule => "Rule".to_string(),
363+ Event::TaskListMarker(b) => format!("TaskListMarker({})", b),
364+ Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t),
365+ Event::InlineMath(t) => format!("InlineMath('{}')", t),
366+ Event::DisplayMath(t) => format!("DisplayMath('{}')", t),
367+ },
368+ &range,
369+ self.last_byte_offset,
370+ self.last_char_offset
371+ );
372+373 // For End events, emit any trailing content within the event's range
374 // BEFORE calling end_tag (which calls end_node and clears current_node_id)
375 if matches!(&event, Event::End(_)) {
···382 self.emit_gap_before(range.start)?;
383 }
384385+ // Store last_byte before processing
386+ let last_byte_before = self.last_byte_offset;
387+388 // Process the event (passing range for tag syntax)
389 self.process_event(event, range.clone())?;
390391+ // Update tracking - but don't override if start_tag manually updated it
392+ // (for inline formatting tags that emit opening syntax)
393+ if self.last_byte_offset == last_byte_before {
394+ // Event didn't update offset, so we update it
395+ self.last_byte_offset = range.end;
396+ }
397+ // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value
398 }
399400 // Emit any trailing syntax
···406 let doc_char_len = self.source_rope.len_chars();
407408 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len {
00000000409 // Emit the trailing content as visible syntax
410 if self.last_byte_offset < doc_byte_len {
411 let trailing = &self.source[self.last_byte_offset..];
···436 }
437 }
438439+ Ok(WriterResult {
440+ offset_maps: self.offset_maps,
441+ paragraph_ranges: self.paragraph_ranges,
442+ })
443 }
444445 // Consume raw text events until end tag, for alt attributes
···491 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> {
492 use Event::*;
4930000000000494 match event {
495 Start(tag) => self.start_tag(tag, range)?,
496 End(tag) => self.end_tag(tag, range)?,
···499 if let Some((_, ref mut buffer)) = self.code_buffer {
500 buffer.push_str(&text);
501502+ // Track byte and char ranges for code block content
503+ let text_char_len = text.chars().count();
504+ if let Some(ref mut code_byte_range) = self.code_buffer_byte_range {
505+ // Extend existing ranges
506+ code_byte_range.end = range.end;
507+ if let Some(ref mut code_char_range) = self.code_buffer_char_range {
508+ code_char_range.end = self.last_char_offset + text_char_len;
509+ }
510 } else {
511 // First text in code block - start tracking
512 self.code_buffer_byte_range = Some(range.clone());
513+ self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len);
514 }
515 } else if !self.in_non_writing_block {
516 // Escape HTML and count chars in one pass
···518 let text_char_len =
519 escape_html_body_text_with_char_count(&mut self.writer, &text)?;
520 let char_end = char_start + text_char_len;
00000000521522 // Text becomes a text node child of the current container
523 if text_char_len > 0 {
···622 let gap = &self.source[range.clone()];
623 if gap.ends_with('\n') {
624 let spaces = &gap[..gap.len() - 1]; // everything except the \n
625+ let char_start = self.last_char_offset;
626 let spaces_char_len = spaces.chars().count();
627628 // Emit and map the visible spaces
···644 // Count the <br> as a child
645 self.current_node_child_count += 1;
646647+ // After <br>, emit plain zero-width space for cursor positioning
648+ self.write("\u{200B}")?;
649+650+ // Count the zero-width space text node as a child
651+ self.current_node_child_count += 1;
652+653+ // Map the newline position to the zero-width space text node
654 if let Some(ref node_id) = self.current_node_id {
655 let newline_char_offset = char_start + spaces_char_len;
656 let mapping = OffsetMapping {
657 byte_range: range.start + spaces.len()..range.end,
658 char_range: newline_char_offset..newline_char_offset + 1,
659 node_id: node_id.clone(),
660+ char_offset_in_node: self.current_node_char_offset,
661+ child_index: None, // text node - TreeWalker will find it
662+ utf16_len: 1, // zero-width space is 1 UTF-16 unit
663 };
664 self.offset_maps.push(mapping);
665+666+ // Increment char offset - TreeWalker will encounter this text node
667+ self.current_node_char_offset += 1;
668 }
669670+ // DO NOT increment last_char_offset - zero-width space is not in source
671+ // The \n itself IS in source, so we already accounted for it
672 self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n
673 } else {
674 // Fallback: just <br>
···775 SyntaxClass::Inline => "md-syntax-inline",
776 SyntaxClass::Block => "md-syntax-block",
777 };
778+779+ let char_start = self.last_char_offset;
780+ let syntax_char_len = syntax.chars().count();
781+ let syntax_byte_len = syntax.len();
782+783 self.write("<span class=\"")?;
784 self.write(class)?;
785 self.write("\">")?;
786 escape_html(&mut self.writer, syntax)?;
787 self.write("</span>")?;
788+789+ // Update tracking - we've consumed this opening syntax
790+ tracing::debug!("[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}",
791+ syntax, self.last_char_offset, char_start + syntax_char_len,
792+ self.last_byte_offset, range.start + syntax_byte_len);
793+ self.last_char_offset = char_start + syntax_char_len;
794+ self.last_byte_offset = range.start + syntax_byte_len;
795 }
796 }
797···799 match tag {
800 Tag::HtmlBlock => Ok(()),
801 Tag::Paragraph => {
802+ // Record paragraph start for boundary tracking
803+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
804+805 let node_id = self.gen_node_id();
806 if self.end_newline {
807 write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
···857 classes,
858 attrs,
859 } => {
860+ // Record paragraph start for boundary tracking
861+ // Treat headings as paragraph-level blocks
862+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
863+864 if !self.end_newline {
865 self.write("\n")?;
866 }
···905 self.write(">")?;
906907 // Begin node tracking for offset mapping
908+ self.begin_node(node_id.clone());
909+910+ // Map the start position of the heading (before any content)
911+ // This allows cursor to be placed at the very beginning
912+ let heading_start_char = self.last_char_offset;
913+ let mapping = OffsetMapping {
914+ byte_range: range.start..range.start,
915+ char_range: heading_start_char..heading_start_char,
916+ node_id: node_id.clone(),
917+ char_offset_in_node: 0,
918+ child_index: Some(0), // position before first child
919+ utf16_len: 0,
920+ };
921+ self.offset_maps.push(mapping);
922923 // Emit # syntax inside the heading tag
924 if range.start < range.end {
925+ let raw_text = &self.source[range.clone()];
926 let count = level as usize;
927 let pattern = "#".repeat(count);
928···932 let syntax_start = hash_pos;
933 let syntax_end = (hash_pos + count + 1).min(raw_text.len());
934 let syntax = &raw_text[syntax_start..syntax_end];
935+ let syntax_char_len = syntax.chars().count();
936+937+ // Calculate byte range for this syntax in the source
938+ let syntax_byte_start = range.start + syntax_start;
939+ let syntax_byte_end = range.start + syntax_end;
940+ let char_start = self.last_char_offset;
941942 self.write("<span class=\"md-syntax-block\">")?;
943 escape_html(&mut self.writer, syntax)?;
944 self.write("</span>")?;
945+946+ // Record offset mapping and update char tracking
947+ // Note: last_byte_offset is managed by the main event loop
948+ self.record_mapping(
949+ syntax_byte_start..syntax_byte_end,
950+ char_start..char_start + syntax_char_len
951+ );
952+ self.last_char_offset = char_start + syntax_char_len;
953 }
954 }
955 Ok(())
···1290 let result = match tag {
1291 TagEnd::HtmlBlock => Ok(()),
1292 TagEnd::Paragraph => {
1293+ // Record paragraph end for boundary tracking
1294+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1295+ let byte_range = byte_start..self.last_byte_offset;
1296+ let char_range = char_start..self.last_char_offset;
1297+ self.paragraph_ranges.push((byte_range, char_range));
1298+ }
1299+1300 self.end_node();
1301 self.write("</p>\n")
1302 }
1303 TagEnd::Heading(level) => {
1304+ // Record paragraph end for boundary tracking
1305+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1306+ let byte_range = byte_start..self.last_byte_offset;
1307+ let char_range = char_start..self.last_char_offset;
1308+ self.paragraph_ranges.push((byte_range, char_range));
1309+ }
1310+1311 self.end_node();
1312 self.write("</")?;
1313 write!(&mut self.writer, "{}", level)?;
···1366 LazyLock::new(|| SyntaxSet::load_defaults_newlines());
13671368 if let Some((lang, buffer)) = self.code_buffer.take() {
1369+ // Create offset mapping for code block content if we tracked ranges
1370+ if let (Some(code_byte_range), Some(code_char_range)) =
1371+ (self.code_buffer_byte_range.take(), self.code_buffer_char_range.take()) {
00001372 // Record mapping before writing HTML
1373 // (current_node_id should be set by start_tag for CodeBlock)
1374+ self.record_mapping(code_byte_range, code_char_range);
1375 }
13761377 if let Some(ref lang_str) = lang {
···14551456 result?;
14571458+ // Note: Closing syntax for inline tags (Strong, Emphasis, etc.) is now handled
1459+ // by emit_gap_before(range.end) which is called before end_tag() in the main loop.
1460+ // No need for manual emission here anymore.
0000000000000000000000000000000000000000000000000000000014611462 Ok(())
1463 }