more bugs squashed, getting less shit

Orual df2ba14f 03aa2553

+346 -189
+176 -121
crates/weaver-app/src/components/editor/mod.rs
··· 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"))] ··· 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 ··· 108 } 109 } 110 111 - // Store for next comparison (write-only, no reactive read) 112 - prev_paragraphs.set(new_paras); 113 }); 114 115 // Auto-save with debounce ··· 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 }, 160 161 onpaste: move |evt| { 162 - evt.prevent_default(); 163 handle_paste(evt, &mut document); 164 }, 165 } 166 ··· 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 ··· 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, ··· 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, ··· 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 340 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 - } 354 355 - // Insert pasted text 356 - doc.rope.insert(doc.cursor.offset, &text); 357 - doc.cursor.offset += text.chars().count(); 358 - }); 359 } 360 } 361 } 362 } 363 364 /// Handle keyboard events and update document state 365 fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 366 use dioxus::prelude::keyboard_types::Key; ··· 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 } 507 ··· 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
··· 71 .collect::<Vec<_>>() 72 }); 73 74 + // Cache paragraphs for change detection AND for event handlers to access 75 + let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 76 77 // Update DOM when paragraphs change (incremental rendering) 78 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 80 let new_paras = paragraphs(); 81 let cursor_offset = document().cursor.offset; 82 83 + // Use peek() to avoid creating reactive dependency on cached_paragraphs 84 + let prev = cached_paragraphs.peek().clone(); 85 86 let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); 87 ··· 108 } 109 } 110 111 + // Store for next comparison AND for event handlers (write-only, no reactive read) 112 + cached_paragraphs.set(new_paras); 113 }); 114 115 // Auto-save with debounce ··· 149 }, 150 151 onkeyup: move |evt| { 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 + } 164 }, 165 166 onclick: move |_evt| { 167 // After mouse click, sync cursor from DOM 168 + let paras = cached_paragraphs(); 169 + sync_cursor_from_dom(&mut document, editor_id, &paras); 170 }, 171 172 onpaste: move |evt| { 173 handle_paste(evt, &mut document); 174 + }, 175 + 176 + oncut: move |evt| { 177 + handle_cut(evt, &mut document); 178 }, 179 } 180 ··· 200 let key = evt.key(); 201 let mods = evt.modifiers(); 202 203 + // Handle Ctrl/Cmd shortcuts 204 if mods.ctrl() || mods.meta() { 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; 211 } 212 213 // Intercept content modifications ··· 217 ) 218 } 219 220 + /// Sync internal cursor and selection state from browser DOM selection 221 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 222 + fn sync_cursor_from_dom( 223 + document: &mut Signal<EditorDocument>, 224 + editor_id: &str, 225 + paragraphs: &[ParagraphRender], 226 + ) { 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 + } 233 234 let window = match web_sys::window() { 235 Some(w) => w, ··· 241 None => return, 242 }; 243 244 let editor_element = match dom_document.get_element_by_id(editor_id) { 245 Some(e) => e, 246 None => return, ··· 251 _ => return, 252 }; 253 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 + }; 259 let focus_node = match selection.focus_node() { 260 Some(node) => node, 261 None => return, 262 }; 263 + let anchor_offset = selection.anchor_offset() as usize; 264 + let focus_offset = selection.focus_offset() as usize; 265 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 + } 305 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(); 319 let node_id = loop { 320 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 321 + if element == editor_element { 322 break None; 323 } 324 325 let id = element 326 .get_attribute("id") 327 .or_else(|| element.get_attribute("data-node-id")); 328 329 if let Some(id) = id { 330 if id.starts_with('n') && id[1..].parse::<usize>().is_ok() { 331 break Some(id); 332 } 333 } 334 } 335 336 + current_node = current_node.parent_node()?; 337 }; 338 339 + let node_id = node_id?; 340 341 + // Get the container element 342 + let container = dom_document.get_element_by_id(&node_id).or_else(|| { 343 let selector = format!("[data-node-id='{}']", node_id); 344 dom_document.query_selector(&selector).ok().flatten() 345 + })?; 346 347 + // Calculate UTF-16 offset from start of container to the position 348 let mut utf16_offset_in_container = 0; 349 350 if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { 351 + while let Ok(Some(text_node)) = walker.next_node() { 352 + if &text_node == node { 353 + utf16_offset_in_container += offset_in_text_node; 354 break; 355 } 356 357 + if let Some(text) = text_node.text_content() { 358 utf16_offset_in_container += text.encode_utf16().count(); 359 } 360 } 361 } 362 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; 369 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); 375 } 376 } 377 } 378 + } 379 380 + None 381 } 382 383 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 384 + fn sync_cursor_from_dom( 385 + _document: &mut Signal<EditorDocument>, 386 + _editor_id: &str, 387 + _paragraphs: &[ParagraphRender], 388 + ) { 389 // No-op on non-wasm 390 } 391 392 /// Handle paste events and insert text at cursor 393 fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 394 + tracing::info!("[PASTE] handle_paste called"); 395 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 + } 420 } 421 + } else { 422 + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 423 } 424 } 425 } 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 + 445 /// Handle keyboard events and update document state 446 fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 447 use dioxus::prelude::keyboard_types::Key; ··· 570 doc.rope.insert(doc.cursor.offset, " \n\u{200C}"); 571 doc.cursor.offset += 3; 572 } else { 573 + // Enter: paragraph break 574 doc.rope.insert(doc.cursor.offset, "\n\n"); 575 doc.cursor.offset += 2; 576 } 577 } 578 ··· 682 let cursor_para_idx = new_paragraphs 683 .iter() 684 .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); 685 686 let mut cursor_para_updated = false; 687
+59 -19
crates/weaver-app/src/components/editor/render.rs
··· 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(&para_source); ··· 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(); ··· 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 }
··· 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 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 102 // Extract paragraph source 103 let para_source = rope_slice_to_string(rope, char_range.clone()); 104 let source_hash = hash_source(&para_source); ··· 159 }); 160 } 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 + 201 // Check if rope ends with trailing newlines (empty paragraph at end) 202 // If so, add an empty paragraph div for cursor positioning 203 let source = rope.to_string(); ··· 207 let doc_end_char = rope.len_chars(); 208 let doc_end_byte = rope.len_bytes(); 209 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}'); 214 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 + } 230 } 231 232 + paragraphs_with_gaps 233 }
+111 -49
crates/weaver-app/src/components/editor/writer.rs
··· 288 return Ok(()); 289 } 290 291 if next_offset > self.last_byte_offset { 292 self.emit_syntax(self.last_byte_offset..next_offset)?; 293 } ··· 501 502 // 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; ··· 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 517 let char_start = self.last_char_offset; ··· 533 } 534 } 535 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 - } 542 } 543 544 self.write("<code>")?; 545 546 // Track offset mapping for code content 547 - let char_start = self.last_char_offset; 548 let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; 549 - let char_end = char_start + text_char_len; 550 551 // Record offset mapping (code content is visible) 552 - self.record_mapping(range.clone(), char_start..char_end); 553 - self.last_char_offset = char_end; 554 555 self.write("</code>")?; 556 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 - } 563 } 564 } 565 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 - } 572 } 573 574 self.write(r#"<span class="math math-inline">"#)?; 575 escape_html(&mut self.writer, &text)?; 576 self.write("</span>")?; 577 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 - } 584 } 585 } 586 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 - } 593 } 594 595 self.write(r#"<span class="math math-display">"#)?; 596 escape_html(&mut self.writer, &text)?; 597 self.write("</span>")?; 598 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 - } 605 } 606 } 607 Html(html) | InlineHtml(html) => { ··· 1018 Ok(()) 1019 } 1020 Tag::CodeBlock(info) => { 1021 if !self.end_newline { 1022 self.write_newline()?; 1023 } ··· 1027 1028 match info { 1029 CodeBlockKind::Fenced(info) => { 1030 - // Emit opening ```language 1031 if range.start < range.end { 1032 - let raw_text = &self.source[range]; 1033 if let Some(fence_pos) = raw_text.find("```") { 1034 let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 1035 let syntax = &raw_text[fence_pos..fence_end]; 1036 self.write("<span class=\"md-syntax-block\">")?; 1037 escape_html(&mut self.writer, syntax)?; 1038 self.write("</span>\n")?; 1039 } 1040 } 1041 ··· 1063 } 1064 } 1065 Tag::List(Some(1)) => { 1066 if self.end_newline { 1067 self.write("<ol>\n") 1068 } else { ··· 1070 } 1071 } 1072 Tag::List(Some(start)) => { 1073 if self.end_newline { 1074 self.write("<ol start=\"")?; 1075 } else { ··· 1079 self.write("\">\n") 1080 } 1081 Tag::List(None) => { 1082 if self.end_newline { 1083 self.write("<ul>\n") 1084 } else { ··· 1098 // Begin node tracking 1099 self.begin_node(node_id); 1100 1101 - // Emit list marker syntax inside the <li> tag 1102 if range.start < range.end { 1103 - let raw_text = &self.source[range]; 1104 1105 // Try to find the list marker (-, *, or digit.) 1106 let trimmed = raw_text.trim_start(); 1107 if let Some(marker) = trimmed.chars().next() { 1108 if marker == '-' || marker == '*' { 1109 // Unordered list: extract "- " or "* " ··· 1112 .map(|pos| pos + 1) 1113 .unwrap_or(1); 1114 let syntax = &trimmed[..marker_end.min(trimmed.len())]; 1115 self.write("<span class=\"md-syntax-block\">")?; 1116 escape_html(&mut self.writer, syntax)?; 1117 self.write("</span>")?; 1118 } else if marker.is_ascii_digit() { 1119 // Ordered list: extract "1. " or similar 1120 if let Some(dot_pos) = trimmed.find('.') { 1121 let syntax_end = (dot_pos + 2).min(trimmed.len()); 1122 let syntax = &trimmed[..syntax_end].trim_end(); 1123 self.write("<span class=\"md-syntax-block\">")?; 1124 escape_html(&mut self.writer, syntax)?; 1125 self.write("</span>")?; 1126 } 1127 } 1128 } ··· 1407 self.write("</code></pre>\n")?; 1408 } 1409 1410 - // Emit closing ``` 1411 if range.start < range.end { 1412 let raw_text = &self.source[range.clone()]; 1413 if let Some(fence_line) = raw_text.lines().last() { 1414 - if fence_line.trim() == "```" { 1415 - self.write("<span class=\"md-syntax-block\">```</span>")?; 1416 } 1417 } 1418 } 1419 1420 Ok(()) 1421 } 1422 - TagEnd::List(true) => self.write("</ol>\n"), 1423 - TagEnd::List(false) => self.write("</ul>\n"), 1424 TagEnd::Item => { 1425 self.end_node(); 1426 self.write("</li>\n")
··· 288 return Ok(()); 289 } 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 + 297 if next_offset > self.last_byte_offset { 298 self.emit_syntax(self.last_byte_offset..next_offset)?; 299 } ··· 507 508 // Track byte and char ranges for code block content 509 let text_char_len = text.chars().count(); 510 + let text_byte_len = text.len(); 511 if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { 512 // Extend existing ranges 513 code_byte_range.end = range.end; ··· 519 self.code_buffer_byte_range = Some(range.clone()); 520 self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len); 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; 525 } else if !self.in_non_writing_block { 526 // Escape HTML and count chars in one pass 527 let char_start = self.last_char_offset; ··· 543 } 544 } 545 Code(text) => { 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; 553 } 554 555 self.write("<code>")?; 556 557 // Track offset mapping for code content 558 + let content_char_start = self.last_char_offset; 559 let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; 560 + let content_char_end = content_char_start + text_char_len; 561 562 // Record offset mapping (code content is visible) 563 + self.record_mapping(range.clone(), content_char_start..content_char_end); 564 + self.last_char_offset = content_char_end; 565 566 self.write("</code>")?; 567 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; 572 } 573 } 574 InlineMath(text) => { 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; 581 } 582 583 self.write(r#"<span class="math math-inline">"#)?; 584 + let text_char_len = text.chars().count(); 585 escape_html(&mut self.writer, &text)?; 586 + self.last_char_offset += text_char_len; 587 self.write("</span>")?; 588 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; 593 } 594 } 595 DisplayMath(text) => { 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; 602 } 603 604 self.write(r#"<span class="math math-display">"#)?; 605 + let text_char_len = text.chars().count(); 606 escape_html(&mut self.writer, &text)?; 607 + self.last_char_offset += text_char_len; 608 self.write("</span>")?; 609 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; 614 } 615 } 616 Html(html) | InlineHtml(html) => { ··· 1027 Ok(()) 1028 } 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 + 1033 if !self.end_newline { 1034 self.write_newline()?; 1035 } ··· 1039 1040 match info { 1041 CodeBlockKind::Fenced(info) => { 1042 + // Emit opening ```language and track both char and byte offsets 1043 if range.start < range.end { 1044 + let raw_text = &self.source[range.clone()]; 1045 if let Some(fence_pos) = raw_text.find("```") { 1046 let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 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 1050 self.write("<span class=\"md-syntax-block\">")?; 1051 escape_html(&mut self.writer, syntax)?; 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; 1055 } 1056 } 1057 ··· 1079 } 1080 } 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)); 1084 if self.end_newline { 1085 self.write("<ol>\n") 1086 } else { ··· 1088 } 1089 } 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)); 1093 if self.end_newline { 1094 self.write("<ol start=\"")?; 1095 } else { ··· 1099 self.write("\">\n") 1100 } 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)); 1104 if self.end_newline { 1105 self.write("<ul>\n") 1106 } else { ··· 1120 // Begin node tracking 1121 self.begin_node(node_id); 1122 1123 + // Emit list marker syntax inside the <li> tag and track both offsets 1124 if range.start < range.end { 1125 + let raw_text = &self.source[range.clone()]; 1126 1127 // Try to find the list marker (-, *, or digit.) 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 + 1132 if let Some(marker) = trimmed.chars().next() { 1133 if marker == '-' || marker == '*' { 1134 // Unordered list: extract "- " or "* " ··· 1137 .map(|pos| pos + 1) 1138 .unwrap_or(1); 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(); 1142 self.write("<span class=\"md-syntax-block\">")?; 1143 escape_html(&mut self.writer, syntax)?; 1144 self.write("</span>")?; 1145 + self.last_char_offset += syntax_char_len; 1146 + self.last_byte_offset = range.start + syntax_byte_len; 1147 } else if marker.is_ascii_digit() { 1148 // Ordered list: extract "1. " or similar 1149 if let Some(dot_pos) = trimmed.find('.') { 1150 let syntax_end = (dot_pos + 2).min(trimmed.len()); 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(); 1154 self.write("<span class=\"md-syntax-block\">")?; 1155 escape_html(&mut self.writer, syntax)?; 1156 self.write("</span>")?; 1157 + self.last_char_offset += syntax_char_len; 1158 + self.last_byte_offset = range.start + syntax_byte_len; 1159 } 1160 } 1161 } ··· 1440 self.write("</code></pre>\n")?; 1441 } 1442 1443 + // Emit closing ``` (emit_gap_before is skipped while buffering) 1444 if range.start < range.end { 1445 let raw_text = &self.source[range.clone()]; 1446 if let Some(fence_line) = raw_text.lines().last() { 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(); 1455 } 1456 } 1457 } 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 + 1466 Ok(()) 1467 } 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 + } 1486 TagEnd::Item => { 1487 self.end_node(); 1488 self.write("</li>\n")