more bugs squashed, getting less shit

Orual df2ba14f 03aa2553

+346 -189
+176 -121
crates/weaver-app/src/components/editor/mod.rs
··· 71 71 .collect::<Vec<_>>() 72 72 }); 73 73 74 - // Track previous paragraphs for change detection (outside effect so it persists) 75 - let mut prev_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 74 + // Cache paragraphs for change detection AND for event handlers to access 75 + let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 76 76 77 77 // Update DOM when paragraphs change (incremental rendering) 78 78 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 80 80 let new_paras = paragraphs(); 81 81 let cursor_offset = document().cursor.offset; 82 82 83 - // Use peek() to avoid creating reactive dependency on prev_paragraphs 84 - let prev = prev_paragraphs.peek().clone(); 83 + // Use peek() to avoid creating reactive dependency on cached_paragraphs 84 + let prev = cached_paragraphs.peek().clone(); 85 85 86 86 let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); 87 87 ··· 108 108 } 109 109 } 110 110 111 - // Store for next comparison (write-only, no reactive read) 112 - prev_paragraphs.set(new_paras); 111 + // Store for next comparison AND for event handlers (write-only, no reactive read) 112 + cached_paragraphs.set(new_paras); 113 113 }); 114 114 115 115 // Auto-save with debounce ··· 149 149 }, 150 150 151 151 onkeyup: move |evt| { 152 - // After any key (including arrow keys), sync cursor from DOM 153 - sync_cursor_from_dom(&mut document, editor_id); 152 + use dioxus::prelude::keyboard_types::Key; 153 + // Only sync cursor from DOM after navigation keys 154 + // Content-modifying keys update cursor directly in handle_keydown 155 + let dominated = matches!( 156 + evt.key(), 157 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 158 + Key::Home | Key::End | Key::PageUp | Key::PageDown 159 + ); 160 + if dominated { 161 + let paras = cached_paragraphs(); 162 + sync_cursor_from_dom(&mut document, editor_id, &paras); 163 + } 154 164 }, 155 165 156 166 onclick: move |_evt| { 157 167 // After mouse click, sync cursor from DOM 158 - sync_cursor_from_dom(&mut document, editor_id); 168 + let paras = cached_paragraphs(); 169 + sync_cursor_from_dom(&mut document, editor_id, &paras); 159 170 }, 160 171 161 172 onpaste: move |evt| { 162 - evt.prevent_default(); 163 173 handle_paste(evt, &mut document); 174 + }, 175 + 176 + oncut: move |evt| { 177 + handle_cut(evt, &mut document); 164 178 }, 165 179 } 166 180 ··· 186 200 let key = evt.key(); 187 201 let mods = evt.modifiers(); 188 202 189 - // Intercept shortcuts 203 + // Handle Ctrl/Cmd shortcuts 190 204 if mods.ctrl() || mods.meta() { 191 - return true; 205 + if let Key::Character(ch) = &key { 206 + // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I) 207 + return matches!(ch.as_str(), "b" | "i"); 208 + } 209 + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.) 210 + return false; 192 211 } 193 212 194 213 // Intercept content modifications ··· 198 217 ) 199 218 } 200 219 201 - /// Sync internal cursor state from browser DOM selection 220 + /// Sync internal cursor and selection state from browser DOM selection 202 221 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 203 - fn sync_cursor_from_dom(document: &mut Signal<EditorDocument>, editor_id: &str) { 222 + fn sync_cursor_from_dom( 223 + document: &mut Signal<EditorDocument>, 224 + editor_id: &str, 225 + paragraphs: &[ParagraphRender], 226 + ) { 204 227 use wasm_bindgen::JsCast; 228 + 229 + // Early return if paragraphs not yet populated (first render edge case) 230 + if paragraphs.is_empty() { 231 + return; 232 + } 205 233 206 234 let window = match web_sys::window() { 207 235 Some(w) => w, ··· 213 241 None => return, 214 242 }; 215 243 216 - // Get editor element as boundary for search 217 244 let editor_element = match dom_document.get_element_by_id(editor_id) { 218 245 Some(e) => e, 219 246 None => return, ··· 224 251 _ => return, 225 252 }; 226 253 227 - // Get cursor position from selection 254 + // Get both anchor (selection start) and focus (selection end) positions 255 + let anchor_node = match selection.anchor_node() { 256 + Some(node) => node, 257 + None => return, 258 + }; 228 259 let focus_node = match selection.focus_node() { 229 260 Some(node) => node, 230 261 None => return, 231 262 }; 263 + let anchor_offset = selection.anchor_offset() as usize; 264 + let focus_offset = selection.focus_offset() as usize; 232 265 233 - let focus_offset = selection.focus_offset() as usize; 266 + // Convert both DOM positions to rope offsets using cached paragraphs 267 + let anchor_rope = dom_position_to_rope_offset( 268 + &dom_document, 269 + &editor_element, 270 + &anchor_node, 271 + anchor_offset, 272 + paragraphs, 273 + ); 274 + let focus_rope = dom_position_to_rope_offset( 275 + &dom_document, 276 + &editor_element, 277 + &focus_node, 278 + focus_offset, 279 + paragraphs, 280 + ); 281 + 282 + document.with_mut(|doc| { 283 + match (anchor_rope, focus_rope) { 284 + (Some(anchor), Some(focus)) => { 285 + doc.cursor.offset = focus; 286 + if anchor != focus { 287 + // There's an actual selection 288 + doc.selection = Some(Selection { 289 + anchor, 290 + head: focus, 291 + }); 292 + tracing::debug!("[SYNC] Selection {}..{}", anchor, focus); 293 + } else { 294 + // Collapsed selection (just cursor) 295 + doc.selection = None; 296 + tracing::debug!("[SYNC] Cursor at {}", focus); 297 + } 298 + } 299 + _ => { 300 + tracing::warn!("Could not map DOM selection to rope offsets"); 301 + } 302 + } 303 + }); 304 + } 234 305 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(); 306 + /// Convert a DOM position (node + offset) to a rope char offset using offset maps 307 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 308 + fn dom_position_to_rope_offset( 309 + dom_document: &web_sys::Document, 310 + editor_element: &web_sys::Element, 311 + node: &web_sys::Node, 312 + offset_in_text_node: usize, 313 + paragraphs: &[ParagraphRender], 314 + ) -> Option<usize> { 315 + use wasm_bindgen::JsCast; 316 + 317 + // Find the containing element with a node ID (walk up from text node) 318 + let mut current_node = node.clone(); 238 319 let node_id = loop { 239 320 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 { 321 + if element == editor_element { 242 322 break None; 243 323 } 244 324 245 - // Check both id and data-node-id attributes 246 - // (paragraphs use id, headings use data-node-id to preserve user heading IDs) 247 325 let id = element 248 326 .get_attribute("id") 249 327 .or_else(|| element.get_attribute("data-node-id")); 250 328 251 329 if let Some(id) = id { 252 - // Look for node IDs like "n0", "n1", etc (from offset map) 253 330 if id.starts_with('n') && id[1..].parse::<usize>().is_ok() { 254 331 break Some(id); 255 332 } 256 333 } 257 334 } 258 335 259 - current_node = match current_node.parent_node() { 260 - Some(parent) => parent, 261 - None => break None, 262 - }; 336 + current_node = current_node.parent_node()?; 263 337 }; 264 338 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 - }; 339 + let node_id = node_id?; 272 340 273 - let container = match dom_document.get_element_by_id(&node_id).or_else(|| { 341 + // Get the container element 342 + let container = dom_document.get_element_by_id(&node_id).or_else(|| { 274 343 let selector = format!("[data-node-id='{}']", node_id); 275 344 dom_document.query_selector(&selector).ok().flatten() 276 - }) { 277 - Some(e) => e, 278 - None => return, 279 - }; 345 + })?; 280 346 281 - // Calculate UTF-16 offset from start of container to focus position 347 + // Calculate UTF-16 offset from start of container to the position 282 348 let mut utf16_offset_in_container = 0; 283 349 284 - // Create tree walker for text nodes in container 285 350 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; 351 + while let Ok(Some(text_node)) = walker.next_node() { 352 + if &text_node == node { 353 + utf16_offset_in_container += offset_in_text_node; 290 354 break; 291 355 } 292 356 293 - // Accumulate length of previous text nodes 294 - if let Some(text) = node.text_content() { 357 + if let Some(text) = text_node.text_content() { 295 358 utf16_offset_in_container += text.encode_utf16().count(); 296 359 } 297 360 } 298 361 } 299 362 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; 363 + // Look up in offset maps 364 + for para in paragraphs { 365 + for mapping in &para.offset_map { 366 + if mapping.node_id == node_id { 367 + let mapping_start = mapping.char_offset_in_node; 368 + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; 321 369 322 - tracing::debug!("[SYNC] -> MATCHED! rope_offset: {} (was {})", rope_offset, doc.cursor.offset); 323 - doc.cursor.offset = rope_offset; 324 - return; 325 - } 370 + if utf16_offset_in_container >= mapping_start 371 + && utf16_offset_in_container <= mapping_end 372 + { 373 + let offset_in_mapping = utf16_offset_in_container - mapping_start; 374 + return Some(mapping.char_range.start + offset_in_mapping); 326 375 } 327 376 } 328 377 } 378 + } 329 379 330 - tracing::warn!("Could not map DOM cursor position to rope offset"); 331 - }); 380 + None 332 381 } 333 382 334 383 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 335 - fn sync_cursor_from_dom(_document: &mut Signal<EditorDocument>, _editor_id: &str) { 384 + fn sync_cursor_from_dom( 385 + _document: &mut Signal<EditorDocument>, 386 + _editor_id: &str, 387 + _paragraphs: &[ParagraphRender], 388 + ) { 336 389 // No-op on non-wasm 337 390 } 338 391 339 392 /// Handle paste events and insert text at cursor 340 393 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 341 - // Downcast to web_sys event to get clipboard data 342 - #[cfg(target_arch = "wasm32")] 343 - if let Some(web_evt) = evt.data().downcast::<web_sys::ClipboardEvent>() { 344 - if let Some(data_transfer) = web_evt.clipboard_data() { 345 - if let Ok(text) = data_transfer.get_data("text/plain") { 346 - document.with_mut(|doc| { 347 - // Delete selection if present 348 - if let Some(sel) = doc.selection { 349 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 350 - doc.rope.remove(start..end); 351 - doc.cursor.offset = start; 352 - doc.selection = None; 353 - } 394 + tracing::info!("[PASTE] handle_paste called"); 354 395 355 - // Insert pasted text 356 - doc.rope.insert(doc.cursor.offset, &text); 357 - doc.cursor.offset += text.chars().count(); 358 - }); 396 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 397 + { 398 + use dioxus::web::WebEventExt; 399 + use wasm_bindgen::JsCast; 400 + 401 + let base_evt = evt.as_web_event(); 402 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 403 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 404 + if let Ok(text) = data_transfer.get_data("text/plain") { 405 + tracing::info!("[PASTE] Got text: {} chars", text.len()); 406 + document.with_mut(|doc| { 407 + // Delete selection if present 408 + if let Some(sel) = doc.selection { 409 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 410 + doc.rope.remove(start..end); 411 + doc.cursor.offset = start; 412 + doc.selection = None; 413 + } 414 + 415 + // Insert pasted text 416 + doc.rope.insert(doc.cursor.offset, &text); 417 + doc.cursor.offset += text.chars().count(); 418 + }); 419 + } 359 420 } 421 + } else { 422 + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 360 423 } 361 424 } 362 425 } 363 426 427 + /// Handle cut events - browser copies selection, we delete it from rope 428 + /// Selection is synced via onkeyup/onclick, so doc.selection should be current 429 + fn handle_cut(_evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 430 + tracing::info!("[CUT] handle_cut called"); 431 + 432 + document.with_mut(|doc| { 433 + if let Some(sel) = doc.selection { 434 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 435 + if start != end { 436 + tracing::info!("[CUT] Deleting selection {}..{}", start, end); 437 + doc.rope.remove(start..end); 438 + doc.cursor.offset = start; 439 + doc.selection = None; 440 + } 441 + } 442 + }); 443 + } 444 + 364 445 /// Handle keyboard events and update document state 365 446 fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 366 447 use dioxus::prelude::keyboard_types::Key; ··· 489 570 doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); 490 571 doc.cursor.offset += 3; 491 572 } 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 - ); 573 + // Enter: paragraph break 498 574 doc.rope.insert(doc.cursor.offset, "\n\n"); 499 575 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 576 } 506 577 } 507 578 ··· 611 682 let cursor_para_idx = new_paragraphs 612 683 .iter() 613 684 .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 685 631 686 let mut cursor_para_updated = false; 632 687
+59 -19
crates/weaver-app/src/components/editor/render.rs
··· 98 98 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len()); 99 99 let mut node_id_offset = 0; // Track total nodes used so far for unique IDs 100 100 101 - tracing::info!("[RENDER] Rendering {} paragraphs", paragraph_ranges.len()); 102 101 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 103 - tracing::info!("[RENDER] Paragraph {}: char_range {:?}", idx, char_range); 104 102 // Extract paragraph source 105 103 let para_source = rope_slice_to_string(rope, char_range.clone()); 106 104 let source_hash = hash_source(&para_source); ··· 161 159 }); 162 160 } 163 161 162 + // Insert gap paragraphs for whitespace between blocks 163 + // This gives the cursor somewhere to land when positioned in newlines 164 + let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2); 165 + let mut prev_end_char = 0usize; 166 + let mut prev_end_byte = 0usize; 167 + 168 + for para in paragraphs { 169 + // Check for gap before this paragraph 170 + if para.char_range.start > prev_end_char { 171 + let gap_start_char = prev_end_char; 172 + let gap_end_char = para.char_range.start; 173 + let gap_start_byte = prev_end_byte; 174 + let gap_end_byte = para.byte_range.start; 175 + 176 + let gap_node_id = format!("n{}", node_id_offset); 177 + node_id_offset += 1; 178 + let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}'); 179 + 180 + paragraphs_with_gaps.push(ParagraphRender { 181 + byte_range: gap_start_byte..gap_end_byte, 182 + char_range: gap_start_char..gap_end_char, 183 + html: gap_html, 184 + offset_map: vec![OffsetMapping { 185 + byte_range: gap_start_byte..gap_end_byte, 186 + char_range: gap_start_char..gap_end_char, 187 + node_id: gap_node_id, 188 + char_offset_in_node: 0, 189 + child_index: None, 190 + utf16_len: 1, // zero-width space represents the gap 191 + }], 192 + source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)), 193 + }); 194 + } 195 + 196 + prev_end_char = para.char_range.end; 197 + prev_end_byte = para.byte_range.end; 198 + paragraphs_with_gaps.push(para); 199 + } 200 + 164 201 // Check if rope ends with trailing newlines (empty paragraph at end) 165 202 // If so, add an empty paragraph div for cursor positioning 166 203 let source = rope.to_string(); ··· 170 207 let doc_end_char = rope.len_chars(); 171 208 let doc_end_byte = rope.len_bytes(); 172 209 173 - let empty_node_id = format!("n{}", node_id_offset); 174 - let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 210 + // Only add if there's actually a gap at the end 211 + if doc_end_char > prev_end_char { 212 + let empty_node_id = format!("n{}", node_id_offset); 213 + let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}'); 175 214 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 - }); 215 + paragraphs_with_gaps.push(ParagraphRender { 216 + byte_range: prev_end_byte..doc_end_byte, 217 + char_range: prev_end_char..doc_end_char, 218 + html: empty_html, 219 + offset_map: vec![OffsetMapping { 220 + byte_range: prev_end_byte..doc_end_byte, 221 + char_range: prev_end_char..doc_end_char, 222 + node_id: empty_node_id, 223 + char_offset_in_node: 0, 224 + child_index: None, 225 + utf16_len: 1, // zero-width space is 1 UTF-16 code unit 226 + }], 227 + source_hash: 0, // always render this paragraph 228 + }); 229 + } 190 230 } 191 231 192 - paragraphs 232 + paragraphs_with_gaps 193 233 }
+111 -49
crates/weaver-app/src/components/editor/writer.rs
··· 288 288 return Ok(()); 289 289 } 290 290 291 + // Skip gap emission if we're buffering code block content 292 + // The code block handler manages its own syntax emission 293 + if self.code_buffer.is_some() { 294 + return Ok(()); 295 + } 296 + 291 297 if next_offset > self.last_byte_offset { 292 298 self.emit_syntax(self.last_byte_offset..next_offset)?; 293 299 } ··· 501 507 502 508 // Track byte and char ranges for code block content 503 509 let text_char_len = text.chars().count(); 510 + let text_byte_len = text.len(); 504 511 if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { 505 512 // Extend existing ranges 506 513 code_byte_range.end = range.end; ··· 512 519 self.code_buffer_byte_range = Some(range.clone()); 513 520 self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len); 514 521 } 522 + // Update offsets so paragraph boundary is correct 523 + self.last_char_offset += text_char_len; 524 + self.last_byte_offset += text_byte_len; 515 525 } else if !self.in_non_writing_block { 516 526 // Escape HTML and count chars in one pass 517 527 let char_start = self.last_char_offset; ··· 533 543 } 534 544 } 535 545 Code(text) => { 536 - // Emit opening backtick 537 - if range.start < range.end { 538 - let raw_text = &self.source[range.clone()]; 539 - if raw_text.starts_with('`') { 540 - self.write("<span class=\"md-syntax-inline\">`</span>")?; 541 - } 546 + let char_start = self.last_char_offset; 547 + let raw_text = &self.source[range.clone()]; 548 + 549 + // Emit opening backtick and track it 550 + if raw_text.starts_with('`') { 551 + self.write("<span class=\"md-syntax-inline\">`</span>")?; 552 + self.last_char_offset += 1; 542 553 } 543 554 544 555 self.write("<code>")?; 545 556 546 557 // Track offset mapping for code content 547 - let char_start = self.last_char_offset; 558 + let content_char_start = self.last_char_offset; 548 559 let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; 549 - let char_end = char_start + text_char_len; 560 + let content_char_end = content_char_start + text_char_len; 550 561 551 562 // Record offset mapping (code content is visible) 552 - self.record_mapping(range.clone(), char_start..char_end); 553 - self.last_char_offset = char_end; 563 + self.record_mapping(range.clone(), content_char_start..content_char_end); 564 + self.last_char_offset = content_char_end; 554 565 555 566 self.write("</code>")?; 556 567 557 - // Emit closing backtick 558 - if range.start < range.end { 559 - let raw_text = &self.source[range]; 560 - if raw_text.ends_with('`') { 561 - self.write("<span class=\"md-syntax-inline\">`</span>")?; 562 - } 568 + // Emit closing backtick and track it 569 + if raw_text.ends_with('`') { 570 + self.write("<span class=\"md-syntax-inline\">`</span>")?; 571 + self.last_char_offset += 1; 563 572 } 564 573 } 565 574 InlineMath(text) => { 566 - // Emit opening $ 567 - if range.start < range.end { 568 - let raw_text = &self.source[range.clone()]; 569 - if raw_text.starts_with('$') { 570 - self.write("<span class=\"md-syntax-inline\">$</span>")?; 571 - } 575 + let raw_text = &self.source[range.clone()]; 576 + 577 + // Emit opening $ and track it 578 + if raw_text.starts_with('$') { 579 + self.write("<span class=\"md-syntax-inline\">$</span>")?; 580 + self.last_char_offset += 1; 572 581 } 573 582 574 583 self.write(r#"<span class="math math-inline">"#)?; 584 + let text_char_len = text.chars().count(); 575 585 escape_html(&mut self.writer, &text)?; 586 + self.last_char_offset += text_char_len; 576 587 self.write("</span>")?; 577 588 578 - // Emit closing $ 579 - if range.start < range.end { 580 - let raw_text = &self.source[range]; 581 - if raw_text.ends_with('$') { 582 - self.write("<span class=\"md-syntax-inline\">$</span>")?; 583 - } 589 + // Emit closing $ and track it 590 + if raw_text.ends_with('$') { 591 + self.write("<span class=\"md-syntax-inline\">$</span>")?; 592 + self.last_char_offset += 1; 584 593 } 585 594 } 586 595 DisplayMath(text) => { 587 - // Emit opening $$ 588 - if range.start < range.end { 589 - let raw_text = &self.source[range.clone()]; 590 - if raw_text.starts_with("$$") { 591 - self.write("<span class=\"md-syntax-inline\">$$</span>")?; 592 - } 596 + let raw_text = &self.source[range.clone()]; 597 + 598 + // Emit opening $$ and track it 599 + if raw_text.starts_with("$$") { 600 + self.write("<span class=\"md-syntax-inline\">$$</span>")?; 601 + self.last_char_offset += 2; 593 602 } 594 603 595 604 self.write(r#"<span class="math math-display">"#)?; 605 + let text_char_len = text.chars().count(); 596 606 escape_html(&mut self.writer, &text)?; 607 + self.last_char_offset += text_char_len; 597 608 self.write("</span>")?; 598 609 599 - // Emit closing $$ 600 - if range.start < range.end { 601 - let raw_text = &self.source[range]; 602 - if raw_text.ends_with("$$") { 603 - self.write("<span class=\"md-syntax-inline\">$$</span>")?; 604 - } 610 + // Emit closing $$ and track it 611 + if raw_text.ends_with("$$") { 612 + self.write("<span class=\"md-syntax-inline\">$$</span>")?; 613 + self.last_char_offset += 2; 605 614 } 606 615 } 607 616 Html(html) | InlineHtml(html) => { ··· 1018 1027 Ok(()) 1019 1028 } 1020 1029 Tag::CodeBlock(info) => { 1030 + // Track code block as paragraph-level block 1031 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1032 + 1021 1033 if !self.end_newline { 1022 1034 self.write_newline()?; 1023 1035 } ··· 1027 1039 1028 1040 match info { 1029 1041 CodeBlockKind::Fenced(info) => { 1030 - // Emit opening ```language 1042 + // Emit opening ```language and track both char and byte offsets 1031 1043 if range.start < range.end { 1032 - let raw_text = &self.source[range]; 1044 + let raw_text = &self.source[range.clone()]; 1033 1045 if let Some(fence_pos) = raw_text.find("```") { 1034 1046 let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 1035 1047 let syntax = &raw_text[fence_pos..fence_end]; 1048 + let syntax_char_len = syntax.chars().count() + 1; // +1 for newline 1049 + let syntax_byte_len = syntax.len() + 1; // +1 for newline 1036 1050 self.write("<span class=\"md-syntax-block\">")?; 1037 1051 escape_html(&mut self.writer, syntax)?; 1038 1052 self.write("</span>\n")?; 1053 + self.last_char_offset += syntax_char_len; 1054 + self.last_byte_offset = range.start + fence_pos + syntax_byte_len; 1039 1055 } 1040 1056 } 1041 1057 ··· 1063 1079 } 1064 1080 } 1065 1081 Tag::List(Some(1)) => { 1082 + // Track list as paragraph-level block 1083 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1066 1084 if self.end_newline { 1067 1085 self.write("<ol>\n") 1068 1086 } else { ··· 1070 1088 } 1071 1089 } 1072 1090 Tag::List(Some(start)) => { 1091 + // Track list as paragraph-level block 1092 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1073 1093 if self.end_newline { 1074 1094 self.write("<ol start=\"")?; 1075 1095 } else { ··· 1079 1099 self.write("\">\n") 1080 1100 } 1081 1101 Tag::List(None) => { 1102 + // Track list as paragraph-level block 1103 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1082 1104 if self.end_newline { 1083 1105 self.write("<ul>\n") 1084 1106 } else { ··· 1098 1120 // Begin node tracking 1099 1121 self.begin_node(node_id); 1100 1122 1101 - // Emit list marker syntax inside the <li> tag 1123 + // Emit list marker syntax inside the <li> tag and track both offsets 1102 1124 if range.start < range.end { 1103 - let raw_text = &self.source[range]; 1125 + let raw_text = &self.source[range.clone()]; 1104 1126 1105 1127 // Try to find the list marker (-, *, or digit.) 1106 1128 let trimmed = raw_text.trim_start(); 1129 + let leading_ws_bytes = raw_text.len() - trimmed.len(); 1130 + let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count(); 1131 + 1107 1132 if let Some(marker) = trimmed.chars().next() { 1108 1133 if marker == '-' || marker == '*' { 1109 1134 // Unordered list: extract "- " or "* " ··· 1112 1137 .map(|pos| pos + 1) 1113 1138 .unwrap_or(1); 1114 1139 let syntax = &trimmed[..marker_end.min(trimmed.len())]; 1140 + let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1141 + let syntax_byte_len = leading_ws_bytes + syntax.len(); 1115 1142 self.write("<span class=\"md-syntax-block\">")?; 1116 1143 escape_html(&mut self.writer, syntax)?; 1117 1144 self.write("</span>")?; 1145 + self.last_char_offset += syntax_char_len; 1146 + self.last_byte_offset = range.start + syntax_byte_len; 1118 1147 } else if marker.is_ascii_digit() { 1119 1148 // Ordered list: extract "1. " or similar 1120 1149 if let Some(dot_pos) = trimmed.find('.') { 1121 1150 let syntax_end = (dot_pos + 2).min(trimmed.len()); 1122 1151 let syntax = &trimmed[..syntax_end].trim_end(); 1152 + let syntax_char_len = leading_ws_chars + syntax.chars().count(); 1153 + let syntax_byte_len = leading_ws_bytes + syntax.len(); 1123 1154 self.write("<span class=\"md-syntax-block\">")?; 1124 1155 escape_html(&mut self.writer, syntax)?; 1125 1156 self.write("</span>")?; 1157 + self.last_char_offset += syntax_char_len; 1158 + self.last_byte_offset = range.start + syntax_byte_len; 1126 1159 } 1127 1160 } 1128 1161 } ··· 1407 1440 self.write("</code></pre>\n")?; 1408 1441 } 1409 1442 1410 - // Emit closing ``` 1443 + // Emit closing ``` (emit_gap_before is skipped while buffering) 1411 1444 if range.start < range.end { 1412 1445 let raw_text = &self.source[range.clone()]; 1413 1446 if let Some(fence_line) = raw_text.lines().last() { 1414 - if fence_line.trim() == "```" { 1415 - self.write("<span class=\"md-syntax-block\">```</span>")?; 1447 + if fence_line.trim().starts_with("```") { 1448 + let fence = fence_line.trim(); 1449 + let fence_char_len = fence.chars().count(); 1450 + self.write("<span class=\"md-syntax-block\">")?; 1451 + escape_html(&mut self.writer, fence)?; 1452 + self.write("</span>")?; 1453 + self.last_char_offset += fence_char_len; 1454 + self.last_byte_offset += fence.len(); 1416 1455 } 1417 1456 } 1418 1457 } 1419 1458 1459 + // Record code block end for paragraph boundary tracking 1460 + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1461 + let byte_range = byte_start..self.last_byte_offset; 1462 + let char_range = char_start..self.last_char_offset; 1463 + self.paragraph_ranges.push((byte_range, char_range)); 1464 + } 1465 + 1420 1466 Ok(()) 1421 1467 } 1422 - TagEnd::List(true) => self.write("</ol>\n"), 1423 - TagEnd::List(false) => self.write("</ul>\n"), 1468 + TagEnd::List(true) => { 1469 + // Record list end for paragraph boundary tracking 1470 + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1471 + let byte_range = byte_start..self.last_byte_offset; 1472 + let char_range = char_start..self.last_char_offset; 1473 + self.paragraph_ranges.push((byte_range, char_range)); 1474 + } 1475 + self.write("</ol>\n") 1476 + } 1477 + TagEnd::List(false) => { 1478 + // Record list end for paragraph boundary tracking 1479 + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1480 + let byte_range = byte_start..self.last_byte_offset; 1481 + let char_range = char_start..self.last_char_offset; 1482 + self.paragraph_ranges.push((byte_range, char_range)); 1483 + } 1484 + self.write("</ul>\n") 1485 + } 1424 1486 TagEnd::Item => { 1425 1487 self.end_node(); 1426 1488 self.write("</li>\n")