cursor arrowing in paragraph gap fix

Orual 55d185bc 91c6ca64

+324 -30
+114 -8
crates/weaver-editor-browser/src/dom_sync.rs
··· 99 99 let anchor_offset = selection.anchor_offset() as usize; 100 100 let focus_offset = selection.focus_offset() as usize; 101 101 102 + tracing::debug!( 103 + anchor_node_name = %anchor_node.node_name(), 104 + anchor_offset, 105 + focus_node_name = %focus_node.node_name(), 106 + focus_offset, 107 + "sync_cursor_from_dom_impl: browser selection state" 108 + ); 109 + 102 110 let anchor_char = dom_position_to_text_offset( 103 111 &dom_document, 104 112 &editor_element, ··· 154 162 let node_id_attr = current_node 155 163 .dyn_ref::<web_sys::Element>() 156 164 .and_then(|e| e.get_attribute("id")); 157 - tracing::trace!( 165 + let text_content_preview = current_node 166 + .text_content() 167 + .map(|s| s.chars().take(20).collect::<String>()) 168 + .unwrap_or_default(); 169 + tracing::debug!( 158 170 node_name = %node_name, 159 171 node_id_attr = ?node_id_attr, 172 + text_preview = %text_content_preview.escape_debug(), 160 173 "dom_position_to_text_offset: walk-up iteration" 161 174 ); 162 175 ··· 195 208 196 209 // Selection is directly on the editor container (e.g., Cmd+A). 197 210 let child_count = editor_element.child_element_count() as usize; 211 + tracing::debug!( 212 + offset_in_text_node, 213 + child_count, 214 + "dom_position_to_text_offset: selection directly on editor container" 215 + ); 198 216 if offset_in_text_node == 0 { 217 + tracing::debug!( 218 + "dom_position_to_text_offset: returning 0 (editor container offset 0)" 219 + ); 199 220 return Some(0); 200 221 } else if offset_in_text_node >= child_count { 201 - return paragraphs.last().map(|p| p.char_range.end); 222 + let end = paragraphs.last().map(|p| p.char_range.end); 223 + tracing::debug!(end = ?end, "dom_position_to_text_offset: returning end of last paragraph"); 224 + return end; 202 225 } 203 226 break None; 204 227 } ··· 210 233 if let Some(id) = id { 211 234 // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs. 212 235 let is_node_id = id.starts_with('n') || id.contains("-n"); 213 - tracing::trace!( 236 + tracing::debug!( 214 237 id = %id, 215 238 is_node_id, 216 239 starts_with_n = id.starts_with('n'), ··· 227 250 current_node = current_node.parent_node()?; 228 251 }; 229 252 230 - let node_id = node_id?; 253 + let node_id = match node_id { 254 + Some(id) => id, 255 + None => { 256 + tracing::debug!("dom_position_to_text_offset: no node_id found in walk-up"); 257 + return None; 258 + } 259 + }; 260 + 261 + tracing::debug!(node_id = %node_id, "dom_position_to_text_offset: found node_id"); 231 262 232 263 let container = dom_document.get_element_by_id(&node_id).or_else(|| { 233 264 let selector = format!("[data-node-id='{}']", node_id); ··· 312 343 ); 313 344 314 345 // Look up the offset in paragraph offset maps. 346 + // Track the best match for the node_id in case offset is past the end. 347 + let mut best_match_for_node: Option<(usize, &OffsetMapping)> = None; 348 + 315 349 for para in paragraphs { 316 350 for mapping in &para.offset_map { 317 351 if mapping.node_id == node_id { ··· 322 356 mapping_node_id = %mapping.node_id, 323 357 mapping_start, 324 358 mapping_end, 359 + utf16_offset = utf16_offset_in_container, 325 360 char_range_start = mapping.char_range.start, 326 361 char_range_end = mapping.char_range.end, 327 362 "dom_position_to_text_offset: found matching node_id" 328 363 ); 329 364 330 - if utf16_offset_in_container >= mapping_start 331 - && utf16_offset_in_container <= mapping_end 332 - { 365 + // Track the mapping with the highest end position for this node. 366 + if best_match_for_node.is_none() || mapping_end > best_match_for_node.unwrap().0 { 367 + best_match_for_node = Some((mapping_end, mapping)); 368 + } 369 + 370 + let in_range = utf16_offset_in_container >= mapping_start 371 + && utf16_offset_in_container <= mapping_end; 372 + 373 + if in_range { 333 374 let offset_in_mapping = utf16_offset_in_container - mapping_start; 334 375 let char_offset = mapping.char_range.start + offset_in_mapping; 335 376 ··· 346 387 347 388 // Check if position is valid (not on invisible content). 348 389 if is_valid_cursor_position(&para.offset_map, char_offset) { 390 + tracing::debug!( 391 + char_offset, 392 + "dom_position_to_text_offset: returning valid position from mapping" 393 + ); 349 394 return Some(char_offset); 350 395 } 351 396 ··· 353 398 if let Some(snapped) = 354 399 find_nearest_valid_position(&para.offset_map, char_offset, direction_hint) 355 400 { 401 + tracing::debug!( 402 + original = char_offset, 403 + snapped = snapped.char_offset(), 404 + "dom_position_to_text_offset: snapped from invisible to valid" 405 + ); 356 406 return Some(snapped.char_offset()); 357 407 } 358 408 359 409 // Fallback to original if no snap target. 410 + tracing::debug!( 411 + char_offset, 412 + "dom_position_to_text_offset: returning original (no snap target)" 413 + ); 360 414 return Some(char_offset); 361 415 } 362 416 } 363 417 } 364 418 } 365 419 366 - // No mapping found - try to find any valid position in paragraphs. 420 + // If we found the node_id but offset was past the end, snap to the last tracked position. 421 + if let Some((max_end, mapping)) = best_match_for_node { 422 + if utf16_offset_in_container > max_end { 423 + // Cursor is past the end of tracked content - snap to end of last mapping. 424 + let char_offset = mapping.char_range.end; 425 + tracing::debug!( 426 + node_id = %node_id, 427 + utf16_offset = utf16_offset_in_container, 428 + max_tracked_end = max_end, 429 + snapped_to = char_offset, 430 + "dom_position_to_text_offset: offset past tracked content, snapping to end" 431 + ); 432 + return Some(char_offset); 433 + } 434 + } 435 + 436 + // No mapping found - try to find a valid position in the paragraph matching the node_id. 437 + // Extract paragraph index from node_id format "p-{idx}-n{node}" to avoid jumping to wrong paragraph. 438 + let para_idx_from_node = node_id 439 + .strip_prefix("p-") 440 + .and_then(|rest| rest.split('-').next()) 441 + .and_then(|idx_str| idx_str.parse::<usize>().ok()); 442 + 443 + tracing::debug!( 444 + node_id = %node_id, 445 + utf16_offset = utf16_offset_in_container, 446 + para_idx_from_node = ?para_idx_from_node, 447 + num_paragraphs = paragraphs.len(), 448 + "dom_position_to_text_offset: NO MAPPING FOUND - falling back" 449 + ); 450 + 451 + // First try the paragraph that matches the node_id prefix. 452 + if let Some(idx) = para_idx_from_node { 453 + if let Some(para) = paragraphs.get(idx) { 454 + if let Some(snapped) = 455 + find_nearest_valid_position(&para.offset_map, para.char_range.start, direction_hint) 456 + { 457 + tracing::debug!( 458 + para_id = %para.id, 459 + snapped_offset = snapped.char_offset(), 460 + "dom_position_to_text_offset: fallback to matching paragraph" 461 + ); 462 + return Some(snapped.char_offset()); 463 + } 464 + } 465 + } 466 + 467 + // Last resort: try any paragraph (starting from first). 367 468 for para in paragraphs { 368 469 if let Some(snapped) = 369 470 find_nearest_valid_position(&para.offset_map, para.char_range.start, direction_hint) 370 471 { 472 + tracing::debug!( 473 + para_id = %para.id, 474 + snapped_offset = snapped.char_offset(), 475 + "dom_position_to_text_offset: fallback to first available paragraph" 476 + ); 371 477 return Some(snapped.char_offset()); 372 478 } 373 479 }
+62
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blank_lines_in_paragraph.snap
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 6 + - "<p id=\"p-0-n0\" dir=\"ltr\">test<br />​​d\n</p>" 7 + - "<span id=\"p-1-n0\">​\n​</span><p id=\"p-1-n1\" dir=\"ltr\">tsdsd</p>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 10 11 + char_start: 0 12 + char_end: 8 13 + - byte_start: 10 14 + byte_end: 17 15 + char_start: 8 16 + char_end: 15 17 + offset_maps: 18 + - - byte_range: 0..0 19 + char_range: 0..0 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 0 23 + - byte_range: 0..4 24 + char_range: 0..4 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 4 28 + - byte_range: 4..5 29 + char_range: 4..5 30 + node_id: p-0-n0 31 + char_offset_in_node: 4 32 + utf16_len: 1 33 + - byte_range: 5..9 34 + char_range: 5..7 35 + node_id: p-0-n0 36 + char_offset_in_node: 5 37 + utf16_len: 2 38 + - byte_range: 9..10 39 + char_range: 7..8 40 + node_id: p-0-n0 41 + char_offset_in_node: 7 42 + utf16_len: 1 43 + - - byte_range: 10..11 44 + char_range: 8..9 45 + node_id: p-1-n0 46 + char_offset_in_node: 0 47 + utf16_len: 1 48 + - byte_range: 11..12 49 + char_range: 9..10 50 + node_id: p-1-n0 51 + char_offset_in_node: 1 52 + utf16_len: 2 53 + - byte_range: 12..12 54 + char_range: 10..10 55 + node_id: p-1-n1 56 + char_offset_in_node: 0 57 + utf16_len: 0 58 + - byte_range: 12..17 59 + char_range: 10..15 60 + node_id: p-1-n1 61 + char_offset_in_node: 0 62 + utf16_len: 5
+1 -1
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<blockquote><p id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">&gt; </span>quote\n</p>" 7 - - "</blockquote><span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">text after</p>" 7 + - "</blockquote><span id=\"p-1-n0\">​</span><p id=\"p-1-n1\" dir=\"ltr\">text after</p>" 8 8 paragraph_ranges: 9 9 - byte_start: 0 10 10 byte_end: 8
+14 -4
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap
··· 3 3 expression: output 4 4 --- 5 5 html_segments: 6 - - "<span id=\"p-0-n0\">\n\n\n</span>" 6 + - "<span id=\"p-0-n0\">​\n​\n​</span>" 7 7 - "<span id=\"p-0-n1\">\n​</span>" 8 8 paragraph_ranges: 9 9 - byte_start: 0 ··· 15 15 char_start: 3 16 16 char_end: 4 17 17 offset_maps: 18 - - - byte_range: 0..3 19 - char_range: 0..3 18 + - - byte_range: 0..1 19 + char_range: 0..1 20 20 node_id: p-0-n0 21 21 char_offset_in_node: 0 22 - utf16_len: 3 22 + utf16_len: 1 23 + - byte_range: 1..2 24 + char_range: 1..2 25 + node_id: p-0-n0 26 + char_offset_in_node: 1 27 + utf16_len: 2 28 + - byte_range: 2..3 29 + char_range: 2..3 30 + node_id: p-0-n0 31 + char_offset_in_node: 3 32 + utf16_len: 2 23 33 - - byte_range: 3..4 24 34 char_range: 3..4 25 35 node_id: p-0-n1
+24 -4
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<p id=\"p-0-n0\" dir=\"ltr\">start\n</p>" 7 - - "<span id=\"p-1-n0\">\n\n\n\n\n</span><p id=\"p-1-n1\" dir=\"ltr\">end</p>" 7 + - "<span id=\"p-1-n0\">​\n​\n​\n​\n​</span><p id=\"p-1-n1\" dir=\"ltr\">end</p>" 8 8 paragraph_ranges: 9 9 - byte_start: 0 10 10 byte_end: 6 ··· 30 30 node_id: p-0-n0 31 31 char_offset_in_node: 5 32 32 utf16_len: 1 33 - - - byte_range: 6..11 34 - char_range: 6..11 33 + - - byte_range: 6..7 34 + char_range: 6..7 35 35 node_id: p-1-n0 36 36 char_offset_in_node: 0 37 - utf16_len: 5 37 + utf16_len: 1 38 + - byte_range: 7..8 39 + char_range: 7..8 40 + node_id: p-1-n0 41 + char_offset_in_node: 1 42 + utf16_len: 2 43 + - byte_range: 8..9 44 + char_range: 8..9 45 + node_id: p-1-n0 46 + char_offset_in_node: 3 47 + utf16_len: 2 48 + - byte_range: 9..10 49 + char_range: 9..10 50 + node_id: p-1-n0 51 + char_offset_in_node: 5 52 + utf16_len: 2 53 + - byte_range: 10..11 54 + char_range: 10..11 55 + node_id: p-1-n0 56 + char_offset_in_node: 7 57 + utf16_len: 2 38 58 - byte_range: 11..11 39 59 char_range: 11..11 40 60 node_id: p-1-n1
+1 -1
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<p id=\"p-0-n0\" dir=\"ltr\">hello world\n</p>" 7 - - "<span id=\"p-1-n0\">\n</span>" 7 + - "<span id=\"p-1-n0\">​</span>" 8 8 - "<span id=\"p-1-n1\">\n​</span>" 9 9 paragraph_ranges: 10 10 - byte_start: 0
+8 -3
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<p id=\"p-0-n0\" dir=\"ltr\">test\n</p>" 7 - - "<span id=\"p-1-n0\">\n\n</span>" 7 + - "<span id=\"p-1-n0\">​\n​</span>" 8 8 - "<span id=\"p-1-n1\">\n​</span>" 9 9 paragraph_ranges: 10 10 - byte_start: 0 ··· 35 35 node_id: p-0-n0 36 36 char_offset_in_node: 4 37 37 utf16_len: 1 38 - - - byte_range: 5..7 39 - char_range: 5..7 38 + - - byte_range: 5..6 39 + char_range: 5..6 40 40 node_id: p-1-n0 41 41 char_offset_in_node: 0 42 + utf16_len: 1 43 + - byte_range: 6..7 44 + char_range: 6..7 45 + node_id: p-1-n0 46 + char_offset_in_node: 1 42 47 utf16_len: 2 43 48 - - byte_range: 7..8 44 49 char_range: 7..8
+8 -3
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap
··· 3 3 expression: output 4 4 --- 5 5 html_segments: 6 - - "<span id=\"p-0-n0\">\n\n</span>" 6 + - "<span id=\"p-0-n0\">​\n​</span>" 7 7 - "<span id=\"p-0-n1\">\n​</span>" 8 8 paragraph_ranges: 9 9 - byte_start: 0 ··· 15 15 char_start: 2 16 16 char_end: 3 17 17 offset_maps: 18 - - - byte_range: 0..2 19 - char_range: 0..2 18 + - - byte_range: 0..1 19 + char_range: 0..1 20 20 node_id: p-0-n0 21 21 char_offset_in_node: 0 22 + utf16_len: 1 23 + - byte_range: 1..2 24 + char_range: 1..2 25 + node_id: p-0-n0 26 + char_offset_in_node: 1 22 27 utf16_len: 2 23 28 - - byte_range: 2..3 24 29 char_range: 2..3
+1 -1
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<p id=\"p-0-n0\" dir=\"ltr\">first\n</p>" 7 - - "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">second</p>" 7 + - "<span id=\"p-1-n0\">​</span><p id=\"p-1-n1\" dir=\"ltr\">second</p>" 8 8 paragraph_ranges: 9 9 - byte_start: 0 10 10 byte_end: 6
+1 -1
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.snap
··· 4 4 --- 5 5 html_segments: 6 6 - "<p id=\"p-0-n0\" dir=\"ltr\">first\n</p>" 7 - - "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">second\n</p>" 7 + - "<span id=\"p-1-n0\">​</span><p id=\"p-1-n1\" dir=\"ltr\">second\n</p>" 8 8 - "<span id=\"p-2-n0\">\n​</span>" 9 9 paragraph_ranges: 10 10 - byte_start: 0
+60 -4
crates/weaver-editor-core/src/writer/syntax.rs
··· 6 6 use markdown_weaver::Event; 7 7 use markdown_weaver_escape::{StrWrite, escape_html}; 8 8 9 + use crate::offset_map::OffsetMapping; 9 10 use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; 10 11 use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; 11 12 ··· 41 42 let is_whitespace_only = syntax.trim().is_empty(); 42 43 43 44 if is_whitespace_only { 44 - // Emit as plain text with tracking span (not hideable) 45 + // Check if we need to create a wrapper for standalone gap content. 45 46 let created_node = if self.current_node.id.is_none() { 46 47 let node_id = self.gen_node_id(); 47 48 write!(&mut self.writer, "<span id=\"{}\">", node_id)?; ··· 51 52 false 52 53 }; 53 54 54 - escape_html(&mut self.writer, syntax)?; 55 + // Only convert newlines to <br /> when this is standalone gap content 56 + // (created_node = true). Inside paragraphs, keep newlines as-is. 57 + if created_node { 58 + // Gap content: the first newline is just the paragraph break (already 59 + // visual from div structure), so emit only ZWSP. Additional newlines 60 + // are actual blank lines, so emit <br /> + ZWSP for those. 61 + let mut byte_offset = range.start; 62 + let mut char_offset = char_start; 63 + let mut newline_count = 0usize; 64 + for ch in syntax.chars() { 65 + let char_byte_len = ch.len_utf8(); 66 + if ch == '\n' { 67 + newline_count += 1; 68 + let utf16_len = if newline_count == 1 { 69 + // First newline: just ZWSP (paragraph break is already visual). 70 + self.write("\u{200B}")?; 71 + 1 72 + } else { 73 + // Additional newlines: literal \n + ZWSP. 74 + // CSS white-space-collapse: break-spaces handles the visual break. 75 + self.write("\n\u{200B}")?; 76 + 2 77 + }; 78 + if let Some(ref node_id) = self.current_node.id { 79 + let mapping = OffsetMapping { 80 + byte_range: byte_offset..byte_offset + char_byte_len, 81 + char_range: char_offset..char_offset + 1, 82 + node_id: node_id.clone(), 83 + char_offset_in_node: self.current_node.char_offset, 84 + child_index: None, 85 + utf16_len, 86 + }; 87 + self.current_para.offset_maps.push(mapping); 88 + self.current_node.char_offset += utf16_len; 89 + } 90 + } else { 91 + escape_html(&mut self.writer, &ch.to_string())?; 92 + if let Some(ref node_id) = self.current_node.id { 93 + let mapping = OffsetMapping { 94 + byte_range: byte_offset..byte_offset + char_byte_len, 95 + char_range: char_offset..char_offset + 1, 96 + node_id: node_id.clone(), 97 + char_offset_in_node: self.current_node.char_offset, 98 + child_index: None, 99 + utf16_len: 1, 100 + }; 101 + self.current_para.offset_maps.push(mapping); 102 + self.current_node.char_offset += 1; 103 + } 104 + } 105 + byte_offset += char_byte_len; 106 + char_offset += 1; 107 + } 108 + } else { 109 + // Inside a paragraph: emit whitespace as plain text. 110 + escape_html(&mut self.writer, syntax)?; 111 + self.record_mapping(range.clone(), char_start..char_end); 112 + } 55 113 56 - // Record offset mapping BEFORE end_node (which clears current_node.id) 57 - self.record_mapping(range.clone(), char_start..char_end); 58 114 self.last_char_offset = char_end; 59 115 self.last_byte_offset = range.end; 60 116
+8
crates/weaver-editor-core/src/writer/tests.rs
··· 221 221 let output = render_markdown("line one \nline two"); 222 222 insta::assert_yaml_snapshot!(output); 223 223 } 224 + 225 + #[test] 226 + fn test_blank_lines_in_paragraph() { 227 + // Text with ZWSP, then blank lines, then more text 228 + // This tests the exact case causing cursor jump issues 229 + let output = render_markdown("test\n\u{200B}d\n\n\ntsdsd"); 230 + insta::assert_yaml_snapshot!(output); 231 + }
+22
docs/graph-data.json
··· 2243 2243 "created_at": "2026-01-07T19:44:35.176793385-05:00", 2244 2244 "updated_at": "2026-01-07T19:44:35.176793385-05:00", 2245 2245 "metadata_json": "{\"confidence\":95}" 2246 + }, 2247 + { 2248 + "id": 206, 2249 + "change_id": "2ff2950e-e749-4d6e-b524-27217a323be0", 2250 + "node_type": "outcome", 2251 + "title": "Fixed long line overflow - added overflow-wrap: break-word to editor content", 2252 + "description": null, 2253 + "status": "pending", 2254 + "created_at": "2026-01-07T19:55:00.600569229-05:00", 2255 + "updated_at": "2026-01-07T19:55:00.600569229-05:00", 2256 + "metadata_json": "{\"confidence\":95}" 2246 2257 } 2247 2258 ], 2248 2259 "edges": [ ··· 4412 4423 "weight": 1.0, 4413 4424 "rationale": "Fixed by early-exit check in restore_session", 4414 4425 "created_at": "2026-01-07T19:44:39.301696883-05:00" 4426 + }, 4427 + { 4428 + "id": 199, 4429 + "from_node_id": 198, 4430 + "to_node_id": 206, 4431 + "from_change_id": "aed63653-1a44-4cf6-91d7-55ad805e333e", 4432 + "to_change_id": "2ff2950e-e749-4d6e-b524-27217a323be0", 4433 + "edge_type": "leads_to", 4434 + "weight": 1.0, 4435 + "rationale": "CSS fix for word wrapping", 4436 + "created_at": "2026-01-07T19:55:00.626395757-05:00" 4415 4437 } 4416 4438 ] 4417 4439 }