From 55d185bc5002fee4accbc580eea9bf13ac4d9841 Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 7 Jan 2026 21:13:00 -0500 Subject: [PATCH] cursor arrowing in paragraph gap fix --- crates/weaver-editor-browser/src/dom_sync.rs | 122 ++++++++++++++++-- ...iter__tests__blank_lines_in_paragraph.snap | 62 +++++++++ ...__writer__tests__blockquote_then_text.snap | 2 +- ...itor_core__writer__tests__four_enters.snap | 18 ++- ...core__writer__tests__many_blank_lines.snap | 28 +++- ...ests__single_paragraph_triple_newline.snap | 2 +- ..._writer__tests__text_then_four_enters.snap | 11 +- ...tor_core__writer__tests__three_enters.snap | 11 +- ...r_core__writer__tests__two_paragraphs.snap | 2 +- ...riter__tests__two_paragraphs_trailing.snap | 2 +- .../weaver-editor-core/src/writer/syntax.rs | 64 ++++++++- crates/weaver-editor-core/src/writer/tests.rs | 8 ++ docs/graph-data.json | 22 ++++ 13 files changed, 324 insertions(+), 30 deletions(-) create mode 100644 crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blank_lines_in_paragraph.snap diff --git a/crates/weaver-editor-browser/src/dom_sync.rs b/crates/weaver-editor-browser/src/dom_sync.rs index 4392063..f8f195c 100644 --- a/crates/weaver-editor-browser/src/dom_sync.rs +++ b/crates/weaver-editor-browser/src/dom_sync.rs @@ -99,6 +99,14 @@ pub fn sync_cursor_from_dom_impl( let anchor_offset = selection.anchor_offset() as usize; let focus_offset = selection.focus_offset() as usize; + tracing::debug!( + anchor_node_name = %anchor_node.node_name(), + anchor_offset, + focus_node_name = %focus_node.node_name(), + focus_offset, + "sync_cursor_from_dom_impl: browser selection state" + ); + let anchor_char = dom_position_to_text_offset( &dom_document, &editor_element, @@ -154,9 +162,14 @@ pub fn dom_position_to_text_offset( let node_id_attr = current_node .dyn_ref::() .and_then(|e| e.get_attribute("id")); - tracing::trace!( + let text_content_preview = current_node + .text_content() + .map(|s| s.chars().take(20).collect::()) + .unwrap_or_default(); + tracing::debug!( node_name = %node_name, node_id_attr = ?node_id_attr, + text_preview = %text_content_preview.escape_debug(), "dom_position_to_text_offset: walk-up iteration" ); @@ -195,10 +208,20 @@ pub fn dom_position_to_text_offset( // Selection is directly on the editor container (e.g., Cmd+A). let child_count = editor_element.child_element_count() as usize; + tracing::debug!( + offset_in_text_node, + child_count, + "dom_position_to_text_offset: selection directly on editor container" + ); if offset_in_text_node == 0 { + tracing::debug!( + "dom_position_to_text_offset: returning 0 (editor container offset 0)" + ); return Some(0); } else if offset_in_text_node >= child_count { - return paragraphs.last().map(|p| p.char_range.end); + let end = paragraphs.last().map(|p| p.char_range.end); + tracing::debug!(end = ?end, "dom_position_to_text_offset: returning end of last paragraph"); + return end; } break None; } @@ -210,7 +233,7 @@ pub fn dom_position_to_text_offset( if let Some(id) = id { // Match both old-style "n0" and paragraph-prefixed "p-2-n0" node IDs. let is_node_id = id.starts_with('n') || id.contains("-n"); - tracing::trace!( + tracing::debug!( id = %id, is_node_id, starts_with_n = id.starts_with('n'), @@ -227,7 +250,15 @@ pub fn dom_position_to_text_offset( current_node = current_node.parent_node()?; }; - let node_id = node_id?; + let node_id = match node_id { + Some(id) => id, + None => { + tracing::debug!("dom_position_to_text_offset: no node_id found in walk-up"); + return None; + } + }; + + tracing::debug!(node_id = %node_id, "dom_position_to_text_offset: found node_id"); let container = dom_document.get_element_by_id(&node_id).or_else(|| { let selector = format!("[data-node-id='{}']", node_id); @@ -312,6 +343,9 @@ pub fn dom_position_to_text_offset( ); // Look up the offset in paragraph offset maps. + // Track the best match for the node_id in case offset is past the end. + let mut best_match_for_node: Option<(usize, &OffsetMapping)> = None; + for para in paragraphs { for mapping in ¶.offset_map { if mapping.node_id == node_id { @@ -322,14 +356,21 @@ pub fn dom_position_to_text_offset( mapping_node_id = %mapping.node_id, mapping_start, mapping_end, + utf16_offset = utf16_offset_in_container, char_range_start = mapping.char_range.start, char_range_end = mapping.char_range.end, "dom_position_to_text_offset: found matching node_id" ); - if utf16_offset_in_container >= mapping_start - && utf16_offset_in_container <= mapping_end - { + // Track the mapping with the highest end position for this node. + if best_match_for_node.is_none() || mapping_end > best_match_for_node.unwrap().0 { + best_match_for_node = Some((mapping_end, mapping)); + } + + let in_range = utf16_offset_in_container >= mapping_start + && utf16_offset_in_container <= mapping_end; + + if in_range { let offset_in_mapping = utf16_offset_in_container - mapping_start; let char_offset = mapping.char_range.start + offset_in_mapping; @@ -346,6 +387,10 @@ pub fn dom_position_to_text_offset( // Check if position is valid (not on invisible content). if is_valid_cursor_position(¶.offset_map, char_offset) { + tracing::debug!( + char_offset, + "dom_position_to_text_offset: returning valid position from mapping" + ); return Some(char_offset); } @@ -353,21 +398,82 @@ pub fn dom_position_to_text_offset( if let Some(snapped) = find_nearest_valid_position(¶.offset_map, char_offset, direction_hint) { + tracing::debug!( + original = char_offset, + snapped = snapped.char_offset(), + "dom_position_to_text_offset: snapped from invisible to valid" + ); return Some(snapped.char_offset()); } // Fallback to original if no snap target. + tracing::debug!( + char_offset, + "dom_position_to_text_offset: returning original (no snap target)" + ); return Some(char_offset); } } } } - // No mapping found - try to find any valid position in paragraphs. + // If we found the node_id but offset was past the end, snap to the last tracked position. + if let Some((max_end, mapping)) = best_match_for_node { + if utf16_offset_in_container > max_end { + // Cursor is past the end of tracked content - snap to end of last mapping. + let char_offset = mapping.char_range.end; + tracing::debug!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + max_tracked_end = max_end, + snapped_to = char_offset, + "dom_position_to_text_offset: offset past tracked content, snapping to end" + ); + return Some(char_offset); + } + } + + // No mapping found - try to find a valid position in the paragraph matching the node_id. + // Extract paragraph index from node_id format "p-{idx}-n{node}" to avoid jumping to wrong paragraph. + let para_idx_from_node = node_id + .strip_prefix("p-") + .and_then(|rest| rest.split('-').next()) + .and_then(|idx_str| idx_str.parse::().ok()); + + tracing::debug!( + node_id = %node_id, + utf16_offset = utf16_offset_in_container, + para_idx_from_node = ?para_idx_from_node, + num_paragraphs = paragraphs.len(), + "dom_position_to_text_offset: NO MAPPING FOUND - falling back" + ); + + // First try the paragraph that matches the node_id prefix. + if let Some(idx) = para_idx_from_node { + if let Some(para) = paragraphs.get(idx) { + if let Some(snapped) = + find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) + { + tracing::debug!( + para_id = %para.id, + snapped_offset = snapped.char_offset(), + "dom_position_to_text_offset: fallback to matching paragraph" + ); + return Some(snapped.char_offset()); + } + } + } + + // Last resort: try any paragraph (starting from first). for para in paragraphs { if let Some(snapped) = find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) { + tracing::debug!( + para_id = %para.id, + snapped_offset = snapped.char_offset(), + "dom_position_to_text_offset: fallback to first available paragraph" + ); return Some(snapped.char_offset()); } } diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blank_lines_in_paragraph.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blank_lines_in_paragraph.snap new file mode 100644 index 0000000..43c10f7 --- /dev/null +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blank_lines_in_paragraph.snap @@ -0,0 +1,62 @@ +--- +source: crates/weaver-editor-core/src/writer/tests.rs +expression: output +--- +html_segments: + - "

test
​​d\n

" + - "​\n​

tsdsd

" +paragraph_ranges: + - byte_start: 0 + byte_end: 10 + char_start: 0 + char_end: 8 + - byte_start: 10 + byte_end: 17 + char_start: 8 + char_end: 15 +offset_maps: + - - byte_range: 0..0 + char_range: 0..0 + node_id: p-0-n0 + char_offset_in_node: 0 + utf16_len: 0 + - byte_range: 0..4 + char_range: 0..4 + node_id: p-0-n0 + char_offset_in_node: 0 + utf16_len: 4 + - byte_range: 4..5 + char_range: 4..5 + node_id: p-0-n0 + char_offset_in_node: 4 + utf16_len: 1 + - byte_range: 5..9 + char_range: 5..7 + node_id: p-0-n0 + char_offset_in_node: 5 + utf16_len: 2 + - byte_range: 9..10 + char_range: 7..8 + node_id: p-0-n0 + char_offset_in_node: 7 + utf16_len: 1 + - - byte_range: 10..11 + char_range: 8..9 + node_id: p-1-n0 + char_offset_in_node: 0 + utf16_len: 1 + - byte_range: 11..12 + char_range: 9..10 + node_id: p-1-n0 + char_offset_in_node: 1 + utf16_len: 2 + - byte_range: 12..12 + char_range: 10..10 + node_id: p-1-n1 + char_offset_in_node: 0 + utf16_len: 0 + - byte_range: 12..17 + char_range: 10..15 + node_id: p-1-n1 + char_offset_in_node: 0 + utf16_len: 5 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap index 34a820a..20fecdc 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

> quote\n

" - - "
\n

text after

" + - "​

text after

" paragraph_ranges: - byte_start: 0 byte_end: 8 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap index c76b032..8ba3298 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap @@ -3,7 +3,7 @@ source: crates/weaver-editor-core/src/writer/tests.rs expression: output --- html_segments: - - "\n\n\n" + - "​\n​\n​" - "\n​" paragraph_ranges: - byte_start: 0 @@ -15,11 +15,21 @@ paragraph_ranges: char_start: 3 char_end: 4 offset_maps: - - - byte_range: 0..3 - char_range: 0..3 + - - byte_range: 0..1 + char_range: 0..1 node_id: p-0-n0 char_offset_in_node: 0 - utf16_len: 3 + utf16_len: 1 + - byte_range: 1..2 + char_range: 1..2 + node_id: p-0-n0 + char_offset_in_node: 1 + utf16_len: 2 + - byte_range: 2..3 + char_range: 2..3 + node_id: p-0-n0 + char_offset_in_node: 3 + utf16_len: 2 - - byte_range: 3..4 char_range: 3..4 node_id: p-0-n1 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.snap index e27fa53..dd4147c 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

start\n

" - - "\n\n\n\n\n

end

" + - "​\n​\n​\n​\n​

end

" paragraph_ranges: - byte_start: 0 byte_end: 6 @@ -30,11 +30,31 @@ offset_maps: node_id: p-0-n0 char_offset_in_node: 5 utf16_len: 1 - - - byte_range: 6..11 - char_range: 6..11 + - - byte_range: 6..7 + char_range: 6..7 node_id: p-1-n0 char_offset_in_node: 0 - utf16_len: 5 + utf16_len: 1 + - byte_range: 7..8 + char_range: 7..8 + node_id: p-1-n0 + char_offset_in_node: 1 + utf16_len: 2 + - byte_range: 8..9 + char_range: 8..9 + node_id: p-1-n0 + char_offset_in_node: 3 + utf16_len: 2 + - byte_range: 9..10 + char_range: 9..10 + node_id: p-1-n0 + char_offset_in_node: 5 + utf16_len: 2 + - byte_range: 10..11 + char_range: 10..11 + node_id: p-1-n0 + char_offset_in_node: 7 + utf16_len: 2 - byte_range: 11..11 char_range: 11..11 node_id: p-1-n1 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.snap index 59cd482..831b748 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

hello world\n

" - - "\n" + - "​" - "\n​" paragraph_ranges: - byte_start: 0 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.snap index 90d2c4c..8efac32 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

test\n

" - - "\n\n" + - "​\n​" - "\n​" paragraph_ranges: - byte_start: 0 @@ -35,10 +35,15 @@ offset_maps: node_id: p-0-n0 char_offset_in_node: 4 utf16_len: 1 - - - byte_range: 5..7 - char_range: 5..7 + - - byte_range: 5..6 + char_range: 5..6 node_id: p-1-n0 char_offset_in_node: 0 + utf16_len: 1 + - byte_range: 6..7 + char_range: 6..7 + node_id: p-1-n0 + char_offset_in_node: 1 utf16_len: 2 - - byte_range: 7..8 char_range: 7..8 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap index 8bc9a91..33b8d24 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap @@ -3,7 +3,7 @@ source: crates/weaver-editor-core/src/writer/tests.rs expression: output --- html_segments: - - "\n\n" + - "​\n​" - "\n​" paragraph_ranges: - byte_start: 0 @@ -15,10 +15,15 @@ paragraph_ranges: char_start: 2 char_end: 3 offset_maps: - - - byte_range: 0..2 - char_range: 0..2 + - - byte_range: 0..1 + char_range: 0..1 node_id: p-0-n0 char_offset_in_node: 0 + utf16_len: 1 + - byte_range: 1..2 + char_range: 1..2 + node_id: p-0-n0 + char_offset_in_node: 1 utf16_len: 2 - - byte_range: 2..3 char_range: 2..3 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.snap index aa421cc..1d9ab35 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

first\n

" - - "\n

second

" + - "​

second

" paragraph_ranges: - byte_start: 0 byte_end: 6 diff --git a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.snap b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.snap index fc64a8e..5829b95 100644 --- a/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.snap +++ b/crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.snap @@ -4,7 +4,7 @@ expression: output --- html_segments: - "

first\n

" - - "\n

second\n

" + - "​

second\n

" - "\n​" paragraph_ranges: - byte_start: 0 diff --git a/crates/weaver-editor-core/src/writer/syntax.rs b/crates/weaver-editor-core/src/writer/syntax.rs index be68885..51f0be2 100644 --- a/crates/weaver-editor-core/src/writer/syntax.rs +++ b/crates/weaver-editor-core/src/writer/syntax.rs @@ -6,6 +6,7 @@ use std::ops::Range; use markdown_weaver::Event; use markdown_weaver_escape::{StrWrite, escape_html}; +use crate::offset_map::OffsetMapping; use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; @@ -41,7 +42,7 @@ where let is_whitespace_only = syntax.trim().is_empty(); if is_whitespace_only { - // Emit as plain text with tracking span (not hideable) + // Check if we need to create a wrapper for standalone gap content. let created_node = if self.current_node.id.is_none() { let node_id = self.gen_node_id(); write!(&mut self.writer, "", node_id)?; @@ -51,10 +52,65 @@ where false }; - escape_html(&mut self.writer, syntax)?; + // Only convert newlines to
when this is standalone gap content + // (created_node = true). Inside paragraphs, keep newlines as-is. + if created_node { + // Gap content: the first newline is just the paragraph break (already + // visual from div structure), so emit only ZWSP. Additional newlines + // are actual blank lines, so emit
+ ZWSP for those. + let mut byte_offset = range.start; + let mut char_offset = char_start; + let mut newline_count = 0usize; + for ch in syntax.chars() { + let char_byte_len = ch.len_utf8(); + if ch == '\n' { + newline_count += 1; + let utf16_len = if newline_count == 1 { + // First newline: just ZWSP (paragraph break is already visual). + self.write("\u{200B}")?; + 1 + } else { + // Additional newlines: literal \n + ZWSP. + // CSS white-space-collapse: break-spaces handles the visual break. + self.write("\n\u{200B}")?; + 2 + }; + if let Some(ref node_id) = self.current_node.id { + let mapping = OffsetMapping { + byte_range: byte_offset..byte_offset + char_byte_len, + char_range: char_offset..char_offset + 1, + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, + utf16_len, + }; + self.current_para.offset_maps.push(mapping); + self.current_node.char_offset += utf16_len; + } + } else { + escape_html(&mut self.writer, &ch.to_string())?; + if let Some(ref node_id) = self.current_node.id { + let mapping = OffsetMapping { + byte_range: byte_offset..byte_offset + char_byte_len, + char_range: char_offset..char_offset + 1, + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, + utf16_len: 1, + }; + self.current_para.offset_maps.push(mapping); + self.current_node.char_offset += 1; + } + } + byte_offset += char_byte_len; + char_offset += 1; + } + } else { + // Inside a paragraph: emit whitespace as plain text. + escape_html(&mut self.writer, syntax)?; + self.record_mapping(range.clone(), char_start..char_end); + } - // Record offset mapping BEFORE end_node (which clears current_node.id) - self.record_mapping(range.clone(), char_start..char_end); self.last_char_offset = char_end; self.last_byte_offset = range.end; diff --git a/crates/weaver-editor-core/src/writer/tests.rs b/crates/weaver-editor-core/src/writer/tests.rs index 07fa3c2..920fecd 100644 --- a/crates/weaver-editor-core/src/writer/tests.rs +++ b/crates/weaver-editor-core/src/writer/tests.rs @@ -221,3 +221,11 @@ fn test_hard_break() { let output = render_markdown("line one \nline two"); insta::assert_yaml_snapshot!(output); } + +#[test] +fn test_blank_lines_in_paragraph() { + // Text with ZWSP, then blank lines, then more text + // This tests the exact case causing cursor jump issues + let output = render_markdown("test\n\u{200B}d\n\n\ntsdsd"); + insta::assert_yaml_snapshot!(output); +} diff --git a/docs/graph-data.json b/docs/graph-data.json index f9a9056..adb7c34 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -2243,6 +2243,17 @@ "created_at": "2026-01-07T19:44:35.176793385-05:00", "updated_at": "2026-01-07T19:44:35.176793385-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 206, + "change_id": "2ff2950e-e749-4d6e-b524-27217a323be0", + "node_type": "outcome", + "title": "Fixed long line overflow - added overflow-wrap: break-word to editor content", + "description": null, + "status": "pending", + "created_at": "2026-01-07T19:55:00.600569229-05:00", + "updated_at": "2026-01-07T19:55:00.600569229-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -4412,6 +4423,17 @@ "weight": 1.0, "rationale": "Fixed by early-exit check in restore_session", "created_at": "2026-01-07T19:44:39.301696883-05:00" + }, + { + "id": 199, + "from_node_id": 198, + "to_node_id": 206, + "from_change_id": "aed63653-1a44-4cf6-91d7-55ad805e333e", + "to_change_id": "2ff2950e-e749-4d6e-b524-27217a323be0", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "CSS fix for word wrapping", + "created_at": "2026-01-07T19:55:00.626395757-05:00" } ] } \ No newline at end of file -- knotmirror 0.0.1