bunch of editor bugs fixed, discovered courtesy of aviva the human fuzz tester

Orual 5795cc05 1f18b2d9

+1435 -39
+3
Cargo.lock
··· 12260 "dioxus-web 0.7.2", 12261 "gloo-events", 12262 "gloo-utils", 12263 "js-sys", 12264 "smol_str", 12265 "tracing", ··· 12274 name = "weaver-editor-core" 12275 version = "0.1.0" 12276 dependencies = [ 12277 "jacquard", 12278 "markdown-weaver", 12279 "markdown-weaver-escape", 12280 "ropey", 12281 "smol_str", 12282 "syntect", 12283 "thiserror 2.0.17",
··· 12260 "dioxus-web 0.7.2", 12261 "gloo-events", 12262 "gloo-utils", 12263 + "insta", 12264 "js-sys", 12265 "smol_str", 12266 "tracing", ··· 12275 name = "weaver-editor-core" 12276 version = "0.1.0" 12277 dependencies = [ 12278 + "insta", 12279 "jacquard", 12280 "markdown-weaver", 12281 "markdown-weaver-escape", 12282 "ropey", 12283 + "serde", 12284 "smol_str", 12285 "syntect", 12286 "thiserror 2.0.17",
+3 -6
crates/weaver-app/src/components/editor/component.rs
··· 808 }; 809 810 // Navigation keys (with or without Shift for selection) 811 let navigation = matches!( 812 evt.key(), 813 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 814 Key::Home | Key::End | Key::PageUp | Key::PageDown 815 ); 816 817 - // Cmd/Ctrl+A for select all 818 - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 819 - && matches!(evt.key(), Key::Character(ref c) if c == "a"); 820 821 - if navigation || select_all { 822 tracing::debug!( 823 key = ?evt.key(), 824 - navigation, 825 - select_all, 826 "onkeyup navigation - syncing cursor from DOM" 827 ); 828 let paras = cached_paragraphs();
··· 808 }; 809 810 // Navigation keys (with or without Shift for selection) 811 + // We sync cursor from DOM for these because we let the browser handle them 812 let navigation = matches!( 813 evt.key(), 814 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 815 Key::Home | Key::End | Key::PageUp | Key::PageDown 816 ); 817 818 + // Ctrl+A/Cmd+A is handled by browser natively, onselectionchange syncs it. 819 820 + if navigation { 821 tracing::debug!( 822 key = ?evt.key(), 823 "onkeyup navigation - syncing cursor from DOM" 824 ); 825 let paras = cached_paragraphs();
+1
crates/weaver-editor-browser/Cargo.toml
··· 83 84 [dev-dependencies] 85 wasm-bindgen-test = "0.3"
··· 83 84 [dev-dependencies] 85 wasm-bindgen-test = "0.3" 86 + insta = { version = "1.40", features = ["yaml"] }
+2
crates/weaver-editor-core/Cargo.toml
··· 37 ] } 38 39 [dev-dependencies]
··· 37 ] } 38 39 [dev-dependencies] 40 + insta = { version = "1.40", features = ["yaml"] } 41 + serde = { workspace = true, features = ["derive"] }
+28 -6
crates/weaver-editor-core/src/actions.rs
··· 716 } 717 718 // === Selection === 719 - bindings.insert( 720 - KeyCombo::primary(Key::character("a"), is_mac), 721 - EditorAction::SelectAll, 722 - ); 723 724 // === Line deletion === 725 if is_mac { ··· 764 range: Range::caret(0), 765 }, 766 ); 767 - bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 768 769 Self { bindings } 770 } ··· 772 /// Look up an action for the given key combo. 773 /// 774 /// The range in the returned action is updated to the provided range. 775 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> { 776 - self.bindings.get(combo).cloned().map(|a| a.with_range(range)) 777 } 778 779 /// Add or replace a keybinding.
··· 716 } 717 718 // === Selection === 719 + // Let browser handle Ctrl+A/Cmd+A natively - onselectionchange syncs to our state 720 + // bindings.insert( 721 + // KeyCombo::primary(Key::character("a"), is_mac), 722 + // EditorAction::SelectAll, 723 + // ); 724 725 // === Line deletion === 726 if is_mac { ··· 765 range: Range::caret(0), 766 }, 767 ); 768 + // bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 769 770 Self { bindings } 771 } ··· 773 /// Look up an action for the given key combo. 774 /// 775 /// The range in the returned action is updated to the provided range. 776 + /// Character keys are normalized to lowercase for matching (browsers report 777 + /// uppercase when modifiers like Ctrl are held). 778 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> { 779 + // Try exact match first 780 + if let Some(action) = self.bindings.get(combo) { 781 + return Some(action.clone().with_range(range)); 782 + } 783 + 784 + // For character keys, try lowercase version (browsers report "A" when Ctrl+A) 785 + if let Key::Character(ref s) = combo.key { 786 + let lower = s.to_lowercase(); 787 + if lower != s.as_str() { 788 + let normalized = KeyCombo { 789 + key: Key::Character(lower.into()), 790 + modifiers: combo.modifiers, 791 + }; 792 + if let Some(action) = self.bindings.get(&normalized) { 793 + return Some(action.clone().with_range(range)); 794 + } 795 + } 796 + } 797 + 798 + None 799 } 800 801 /// Add or replace a keybinding.
+6 -1
crates/weaver-editor-core/src/text.rs
··· 76 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1); 77 match self.slice(search_start..offset) { 78 Some(s) => match s.rfind('\n') { 79 - Some(pos) => (offset - search_start - pos - 1) <= BLOCK_SYNTAX_ZONE, 80 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE. 81 }, 82 None => false,
··· 76 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1); 77 match self.slice(search_start..offset) { 78 Some(s) => match s.rfind('\n') { 79 + Some(pos) => { 80 + // Distance from character after newline to current offset 81 + let newline_abs_pos = search_start + pos; 82 + let dist = offset.saturating_sub(newline_abs_pos + 1); 83 + dist <= BLOCK_SYNTAX_ZONE 84 + } 85 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE. 86 }, 87 None => false,
+59 -9
crates/weaver-editor-core/src/writer/events.rs
··· 38 // For End events, emit any trailing content within the event's range 39 // BEFORE calling end_tag (which calls end_node and clears current_node_id) 40 // 41 - // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), 42 // the closing syntax must be emitted AFTER the closing HTML tag, not before. 43 - // Otherwise the closing `**` span ends up INSIDE the <strong> element. 44 // These tags handle their own closing syntax in end_tag(). 45 // Image and Embed handle ALL their syntax in the Start event, so exclude them too. 46 let is_self_handled_end = matches!( ··· 49 TagEnd::Strong 50 | TagEnd::Emphasis 51 | TagEnd::Strikethrough 52 | TagEnd::Image 53 | TagEnd::Embed 54 ) ··· 60 } else if !matches!(&event, Event::End(_)) { 61 // For paragraph-level start events, capture pre-gap position so the 62 // paragraph's char_range includes leading whitespace/gap content. 63 let is_para_start = matches!( 64 &event, 65 Event::Start( ··· 67 | Tag::Heading { .. } 68 | Tag::CodeBlock(_) 69 | Tag::List(_) 70 - | Tag::BlockQuote(_) 71 | Tag::HtmlBlock 72 ) 73 ); ··· 78 79 // For other events, emit any gap before range.start 80 // (emit_syntax handles char offset tracking) 81 - self.emit_gap_before(range.start)?; 82 } 83 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML 84 85 // Store last_byte before processing 86 let last_byte_before = self.last_byte_offset; 87 88 // Process the event (passing range for tag syntax) 89 self.process_event(event, range.clone())?; 90 91 - // Update tracking - but don't override if start_tag manually updated it 92 - // (for inline formatting tags that emit opening syntax) 93 - if self.last_byte_offset == last_byte_before { 94 - // Event didn't update offset, so we update it 95 self.last_byte_offset = range.end; 96 } 97 - // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value 98 } 99 100 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing. ··· 164 || !self.current_para.syntax_spans.is_empty() 165 || !self.ref_collector.refs.is_empty() 166 { 167 self.offset_maps_by_para 168 .push(std::mem::take(&mut self.current_para.offset_maps)); 169 self.syntax_spans_by_para
··· 38 // For End events, emit any trailing content within the event's range 39 // BEFORE calling end_tag (which calls end_node and clears current_node_id) 40 // 41 + // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough, Link), 42 // the closing syntax must be emitted AFTER the closing HTML tag, not before. 43 + // Otherwise the closing `**` or `]]` span ends up INSIDE the element. 44 // These tags handle their own closing syntax in end_tag(). 45 // Image and Embed handle ALL their syntax in the Start event, so exclude them too. 46 let is_self_handled_end = matches!( ··· 49 TagEnd::Strong 50 | TagEnd::Emphasis 51 | TagEnd::Strikethrough 52 + | TagEnd::Link 53 | TagEnd::Image 54 | TagEnd::Embed 55 ) ··· 61 } else if !matches!(&event, Event::End(_)) { 62 // For paragraph-level start events, capture pre-gap position so the 63 // paragraph's char_range includes leading whitespace/gap content. 64 + // Note: BlockQuote is NOT included - it defers `>` syntax to emit inside 65 + // the next paragraph via pending_blockquote_range. 66 let is_para_start = matches!( 67 &event, 68 Event::Start( ··· 70 | Tag::Heading { .. } 71 | Tag::CodeBlock(_) 72 | Tag::List(_) 73 + | Tag::FootnoteDefinition(_) 74 | Tag::HtmlBlock 75 ) 76 ); ··· 81 82 // For other events, emit any gap before range.start 83 // (emit_syntax handles char offset tracking) 84 + // 85 + // EXCEPTION: For Paragraph starts when we have pending_blockquote_range, 86 + // skip gap emission here - the Paragraph handler will emit the `>` marker 87 + // inside the <p> tag so it gets proper visibility toggling. 88 + let skip_gap_for_blockquote = matches!(&event, Event::Start(Tag::Paragraph(_))) 89 + && self.pending_blockquote_range.is_some(); 90 + if !skip_gap_for_blockquote { 91 + self.emit_gap_before(range.start)?; 92 + } 93 } 94 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML 95 96 // Store last_byte before processing 97 let last_byte_before = self.last_byte_offset; 98 99 + // Check if this is a container start - container ranges span their entire content, 100 + // so we should NOT jump last_byte_offset to range.end for these. 101 + let is_container_start = matches!( 102 + &event, 103 + Event::Start( 104 + Tag::BlockQuote(_) 105 + | Tag::List(_) 106 + | Tag::Item 107 + | Tag::Table(_) 108 + | Tag::TableHead 109 + | Tag::TableRow 110 + | Tag::TableCell 111 + | Tag::FootnoteDefinition(_) 112 + ) 113 + ); 114 + 115 // Process the event (passing range for tag syntax) 116 self.process_event(event, range.clone())?; 117 118 + // Update tracking - but don't override if: 119 + // 1. start_tag manually updated it (for inline formatting tags that emit opening syntax) 120 + // 2. This is a container start (range.end is end of whole container, not current position) 121 + if self.last_byte_offset == last_byte_before && !is_container_start { 122 self.last_byte_offset = range.end; 123 } 124 + // else: Event updated offset, or it's a container - keep current value 125 } 126 127 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing. ··· 191 || !self.current_para.syntax_spans.is_empty() 192 || !self.ref_collector.refs.is_empty() 193 { 194 + // If we have offset_maps but no paragraph range was recorded (e.g., pure newlines 195 + // with no actual paragraph from the parser), synthesize a range from the maps. 196 + if !self.current_para.offset_maps.is_empty() { 197 + let maps = &self.current_para.offset_maps; 198 + let byte_start = maps.iter().map(|m| m.byte_range.start).min().unwrap_or(0); 199 + let byte_end = maps.iter().map(|m| m.byte_range.end).max().unwrap_or(0); 200 + let char_start = maps.iter().map(|m| m.char_range.start).min().unwrap_or(0); 201 + let char_end = maps.iter().map(|m| m.char_range.end).max().unwrap_or(0); 202 + 203 + // Only add if we have actual content and no range was already recorded 204 + if byte_end > byte_start 205 + && self 206 + .paragraphs 207 + .ranges 208 + .last() 209 + .is_none_or(|(br, _)| br.end <= byte_start) 210 + { 211 + self.paragraphs 212 + .ranges 213 + .push((byte_start..byte_end, char_start..char_end)); 214 + } 215 + } 216 + 217 self.offset_maps_by_para 218 .push(std::mem::take(&mut self.current_para.offset_maps)); 219 self.syntax_spans_by_para
+2
crates/weaver-editor-core/src/writer/mod.rs
··· 8 mod state; 9 mod syntax; 10 mod tags; 11 12 pub use embed::EditorImageResolver; 13 pub use state::*;
··· 8 mod state; 9 mod syntax; 10 mod tags; 11 + #[cfg(test)] 12 + mod tests; 13 14 pub use embed::EditorImageResolver; 15 pub use state::*;
+43
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_multiline.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 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>line one<br />​<span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">&gt; </span>line two</p>" 7 + - "</blockquote>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 21 11 + char_start: 0 12 + char_end: 21 13 + offset_maps: 14 + - - byte_range: 0..2 15 + char_range: 0..2 16 + node_id: p-0-n0 17 + char_offset_in_node: 0 18 + utf16_len: 2 19 + - byte_range: 2..2 20 + char_range: 2..2 21 + node_id: p-0-n0 22 + char_offset_in_node: 2 23 + utf16_len: 0 24 + - byte_range: 2..10 25 + char_range: 2..10 26 + node_id: p-0-n0 27 + char_offset_in_node: 2 28 + utf16_len: 8 29 + - byte_range: 10..11 30 + char_range: 10..11 31 + node_id: p-0-n0 32 + char_offset_in_node: 10 33 + utf16_len: 1 34 + - byte_range: 11..13 35 + char_range: 11..13 36 + node_id: p-0-n0 37 + char_offset_in_node: 11 38 + utf16_len: 2 39 + - byte_range: 13..21 40 + char_range: 13..21 41 + node_id: p-0-n0 42 + char_offset_in_node: 13 43 + utf16_len: 8
+28
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_simple.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 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</p>" 7 + - "</blockquote>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 7 11 + char_start: 0 12 + char_end: 7 13 + offset_maps: 14 + - - byte_range: 0..2 15 + char_range: 0..2 16 + node_id: p-0-n0 17 + char_offset_in_node: 0 18 + utf16_len: 2 19 + - byte_range: 2..2 20 + char_range: 2..2 21 + node_id: p-0-n0 22 + char_offset_in_node: 2 23 + utf16_len: 0 24 + - byte_range: 2..7 25 + char_range: 2..7 26 + node_id: p-0-n0 27 + char_offset_in_node: 2 28 + utf16_len: 5
+52
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_then_text.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 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>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 8 11 + char_start: 0 12 + char_end: 8 13 + - byte_start: 8 14 + byte_end: 19 15 + char_start: 8 16 + char_end: 19 17 + offset_maps: 18 + - - byte_range: 0..2 19 + char_range: 0..2 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 2 23 + - byte_range: 2..2 24 + char_range: 2..2 25 + node_id: p-0-n0 26 + char_offset_in_node: 2 27 + utf16_len: 0 28 + - byte_range: 2..7 29 + char_range: 2..7 30 + node_id: p-0-n0 31 + char_offset_in_node: 2 32 + utf16_len: 5 33 + - byte_range: 7..8 34 + char_range: 7..8 35 + node_id: p-0-n0 36 + char_offset_in_node: 7 37 + utf16_len: 1 38 + - - byte_range: 8..9 39 + char_range: 8..9 40 + node_id: p-1-n0 41 + char_offset_in_node: 0 42 + utf16_len: 1 43 + - byte_range: 9..9 44 + char_range: 9..9 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 0 48 + - byte_range: 9..19 49 + char_range: 9..19 50 + node_id: p-1-n1 51 + char_offset_in_node: 0 52 + utf16_len: 10
+43
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__blockquote_with_trailing.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 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>" 8 + - "<span id=\"p-1-n0\">\n​</span>" 9 + paragraph_ranges: 10 + - byte_start: 0 11 + byte_end: 8 12 + char_start: 0 13 + char_end: 8 14 + - byte_start: 8 15 + byte_end: 9 16 + char_start: 8 17 + char_end: 9 18 + offset_maps: 19 + - - byte_range: 0..2 20 + char_range: 0..2 21 + node_id: p-0-n0 22 + char_offset_in_node: 0 23 + utf16_len: 2 24 + - byte_range: 2..2 25 + char_range: 2..2 26 + node_id: p-0-n0 27 + char_offset_in_node: 2 28 + utf16_len: 0 29 + - byte_range: 2..7 30 + char_range: 2..7 31 + node_id: p-0-n0 32 + char_offset_in_node: 2 33 + utf16_len: 5 34 + - byte_range: 7..8 35 + char_range: 7..8 36 + node_id: p-0-n0 37 + char_offset_in_node: 7 38 + utf16_len: 1 39 + - - byte_range: 8..9 40 + char_range: 8..9 41 + node_id: p-1-n0 42 + char_offset_in_node: 0 43 + utf16_len: 2
+27
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__four_enters.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 6 + - "<span id=\"p-0-n0\">\n\n\n</span>" 7 + - "<span id=\"p-0-n1\">\n​</span>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 3 11 + char_start: 0 12 + char_end: 3 13 + - byte_start: 3 14 + byte_end: 4 15 + char_start: 3 16 + char_end: 4 17 + offset_maps: 18 + - - byte_range: 0..3 19 + char_range: 0..3 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 3 23 + - - byte_range: 3..4 24 + char_range: 3..4 25 + node_id: p-0-n1 26 + char_offset_in_node: 0 27 + utf16_len: 2
+37
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__hard_break.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\">line one<span class=\"md-placeholder\" data-syn-id=\"s0\" data-char-start=\"8\" data-char-end=\"10\"> </span><br />​line two</p>" 7 + paragraph_ranges: 8 + - byte_start: 0 9 + byte_end: 19 10 + char_start: 0 11 + char_end: 19 12 + offset_maps: 13 + - - byte_range: 0..0 14 + char_range: 0..0 15 + node_id: p-0-n0 16 + char_offset_in_node: 0 17 + utf16_len: 0 18 + - byte_range: 0..8 19 + char_range: 0..8 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 8 23 + - byte_range: 8..10 24 + char_range: 8..10 25 + node_id: p-0-n0 26 + char_offset_in_node: 8 27 + utf16_len: 2 28 + - byte_range: 10..11 29 + char_range: 10..11 30 + node_id: p-0-n0 31 + char_offset_in_node: 10 32 + utf16_len: 1 33 + - byte_range: 11..19 34 + char_range: 11..19 35 + node_id: p-0-n0 36 + char_offset_in_node: 11 37 + utf16_len: 8
+47
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_blank_lines.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\">start\n</p>" 7 + - "<span id=\"p-1-n0\">\n\n\n\n\n</span><p id=\"p-1-n1\" dir=\"ltr\">end</p>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 6 11 + char_start: 0 12 + char_end: 6 13 + - byte_start: 6 14 + byte_end: 14 15 + char_start: 6 16 + char_end: 14 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..5 24 + char_range: 0..5 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 5 28 + - byte_range: 5..6 29 + char_range: 5..6 30 + node_id: p-0-n0 31 + char_offset_in_node: 5 32 + utf16_len: 1 33 + - - byte_range: 6..11 34 + char_range: 6..11 35 + node_id: p-1-n0 36 + char_offset_in_node: 0 37 + utf16_len: 5 38 + - byte_range: 11..11 39 + char_range: 11..11 40 + node_id: p-1-n1 41 + char_offset_in_node: 0 42 + utf16_len: 0 43 + - byte_range: 11..14 44 + char_range: 11..14 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 3
+108
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__many_opening_brackets.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\">[[[[[[[[[[<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"10\" data-char-end=\"12\">[[</span><a class=\"link\" href=\"z\">z</a><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"13\" data-char-end=\"15\" spellcheck=\"false\">]]</span><br />​]]]]</p>" 7 + paragraph_ranges: 8 + - byte_start: 0 9 + byte_end: 20 10 + char_start: 0 11 + char_end: 20 12 + offset_maps: 13 + - - byte_range: 0..0 14 + char_range: 0..0 15 + node_id: p-0-n0 16 + char_offset_in_node: 0 17 + utf16_len: 0 18 + - byte_range: 0..1 19 + char_range: 0..1 20 + node_id: p-0-n0 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 27 + utf16_len: 1 28 + - byte_range: 2..3 29 + char_range: 2..3 30 + node_id: p-0-n0 31 + char_offset_in_node: 2 32 + utf16_len: 1 33 + - byte_range: 3..4 34 + char_range: 3..4 35 + node_id: p-0-n0 36 + char_offset_in_node: 3 37 + utf16_len: 1 38 + - byte_range: 4..5 39 + char_range: 4..5 40 + node_id: p-0-n0 41 + char_offset_in_node: 4 42 + utf16_len: 1 43 + - byte_range: 5..6 44 + char_range: 5..6 45 + node_id: p-0-n0 46 + char_offset_in_node: 5 47 + utf16_len: 1 48 + - byte_range: 6..7 49 + char_range: 6..7 50 + node_id: p-0-n0 51 + char_offset_in_node: 6 52 + utf16_len: 1 53 + - byte_range: 7..8 54 + char_range: 7..8 55 + node_id: p-0-n0 56 + char_offset_in_node: 7 57 + utf16_len: 1 58 + - byte_range: 8..9 59 + char_range: 8..9 60 + node_id: p-0-n0 61 + char_offset_in_node: 8 62 + utf16_len: 1 63 + - byte_range: 9..10 64 + char_range: 9..10 65 + node_id: p-0-n0 66 + char_offset_in_node: 9 67 + utf16_len: 1 68 + - byte_range: 10..12 69 + char_range: 10..12 70 + node_id: p-0-n0 71 + char_offset_in_node: 10 72 + utf16_len: 2 73 + - byte_range: 12..13 74 + char_range: 12..13 75 + node_id: p-0-n0 76 + char_offset_in_node: 12 77 + utf16_len: 1 78 + - byte_range: 13..15 79 + char_range: 13..15 80 + node_id: p-0-n0 81 + char_offset_in_node: 13 82 + utf16_len: 2 83 + - byte_range: 15..16 84 + char_range: 15..16 85 + node_id: p-0-n0 86 + char_offset_in_node: 15 87 + utf16_len: 1 88 + - byte_range: 16..17 89 + char_range: 16..17 90 + node_id: p-0-n0 91 + char_offset_in_node: 16 92 + utf16_len: 1 93 + - byte_range: 17..18 94 + char_range: 17..18 95 + node_id: p-0-n0 96 + char_offset_in_node: 17 97 + utf16_len: 1 98 + - byte_range: 18..19 99 + char_range: 18..19 100 + node_id: p-0-n0 101 + char_offset_in_node: 18 102 + utf16_len: 1 103 + - byte_range: 19..20 104 + char_range: 19..20 105 + node_id: p-0-n0 106 + char_offset_in_node: 19 107 + utf16_len: 1 108 + - []
+37
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_double_newline.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\">hello world\n</p>" 7 + - "<span id=\"p-1-n0\">\n​</span>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 12 11 + char_start: 0 12 + char_end: 12 13 + - byte_start: 12 14 + byte_end: 13 15 + char_start: 12 16 + char_end: 13 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..11 24 + char_range: 0..11 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 11 28 + - byte_range: 11..12 29 + char_range: 11..12 30 + node_id: p-0-n0 31 + char_offset_in_node: 11 32 + utf16_len: 1 33 + - - byte_range: 12..13 34 + char_range: 12..13 35 + node_id: p-1-n0 36 + char_offset_in_node: 0 37 + utf16_len: 2
+22
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_no_trailing.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\">hello world</p>" 7 + paragraph_ranges: 8 + - byte_start: 0 9 + byte_end: 11 10 + char_start: 0 11 + char_end: 11 12 + offset_maps: 13 + - - byte_range: 0..0 14 + char_range: 0..0 15 + node_id: p-0-n0 16 + char_offset_in_node: 0 17 + utf16_len: 0 18 + - byte_range: 0..11 19 + char_range: 0..11 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 11
+27
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_single_newline.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\">hello world\n</p>" 7 + paragraph_ranges: 8 + - byte_start: 0 9 + byte_end: 12 10 + char_start: 0 11 + char_end: 12 12 + offset_maps: 13 + - - byte_range: 0..0 14 + char_range: 0..0 15 + node_id: p-0-n0 16 + char_offset_in_node: 0 17 + utf16_len: 0 18 + - byte_range: 0..11 19 + char_range: 0..11 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 11 23 + - byte_range: 11..12 24 + char_range: 11..12 25 + node_id: p-0-n0 26 + char_offset_in_node: 11 27 + utf16_len: 1
+47
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__single_paragraph_triple_newline.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\">hello world\n</p>" 7 + - "<span id=\"p-1-n0\">\n</span>" 8 + - "<span id=\"p-1-n1\">\n​</span>" 9 + paragraph_ranges: 10 + - byte_start: 0 11 + byte_end: 12 12 + char_start: 0 13 + char_end: 12 14 + - byte_start: 12 15 + byte_end: 13 16 + char_start: 12 17 + char_end: 13 18 + - byte_start: 13 19 + byte_end: 14 20 + char_start: 13 21 + char_end: 14 22 + offset_maps: 23 + - - byte_range: 0..0 24 + char_range: 0..0 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 0 28 + - byte_range: 0..11 29 + char_range: 0..11 30 + node_id: p-0-n0 31 + char_offset_in_node: 0 32 + utf16_len: 11 33 + - byte_range: 11..12 34 + char_range: 11..12 35 + node_id: p-0-n0 36 + char_offset_in_node: 11 37 + utf16_len: 1 38 + - - byte_range: 12..13 39 + char_range: 12..13 40 + node_id: p-1-n0 41 + char_offset_in_node: 0 42 + utf16_len: 1 43 + - - byte_range: 13..14 44 + char_range: 13..14 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 2
+32
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__soft_break.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\">line one<br />​line two</p>" 7 + paragraph_ranges: 8 + - byte_start: 0 9 + byte_end: 17 10 + char_start: 0 11 + char_end: 17 12 + offset_maps: 13 + - - byte_range: 0..0 14 + char_range: 0..0 15 + node_id: p-0-n0 16 + char_offset_in_node: 0 17 + utf16_len: 0 18 + - byte_range: 0..8 19 + char_range: 0..8 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 8 23 + - byte_range: 8..9 24 + char_range: 8..9 25 + node_id: p-0-n0 26 + char_offset_in_node: 8 27 + utf16_len: 1 28 + - byte_range: 9..17 29 + char_range: 9..17 30 + node_id: p-0-n0 31 + char_offset_in_node: 9 32 + utf16_len: 8
+47
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__text_then_four_enters.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\n</p>" 7 + - "<span id=\"p-1-n0\">\n\n</span>" 8 + - "<span id=\"p-1-n1\">\n​</span>" 9 + paragraph_ranges: 10 + - byte_start: 0 11 + byte_end: 5 12 + char_start: 0 13 + char_end: 5 14 + - byte_start: 5 15 + byte_end: 7 16 + char_start: 5 17 + char_end: 7 18 + - byte_start: 7 19 + byte_end: 8 20 + char_start: 7 21 + char_end: 8 22 + offset_maps: 23 + - - byte_range: 0..0 24 + char_range: 0..0 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 0 28 + - byte_range: 0..4 29 + char_range: 0..4 30 + node_id: p-0-n0 31 + char_offset_in_node: 0 32 + utf16_len: 4 33 + - byte_range: 4..5 34 + char_range: 4..5 35 + node_id: p-0-n0 36 + char_offset_in_node: 4 37 + utf16_len: 1 38 + - - byte_range: 5..7 39 + char_range: 5..7 40 + node_id: p-1-n0 41 + char_offset_in_node: 0 42 + utf16_len: 2 43 + - - byte_range: 7..8 44 + char_range: 7..8 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 2
+27
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__three_enters.snap
···
··· 1 + --- 2 + source: crates/weaver-editor-core/src/writer/tests.rs 3 + expression: output 4 + --- 5 + html_segments: 6 + - "<span id=\"p-0-n0\">\n\n</span>" 7 + - "<span id=\"p-0-n1\">\n​</span>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 2 11 + char_start: 0 12 + char_end: 2 13 + - byte_start: 2 14 + byte_end: 3 15 + char_start: 2 16 + char_end: 3 17 + offset_maps: 18 + - - byte_range: 0..2 19 + char_range: 0..2 20 + node_id: p-0-n0 21 + char_offset_in_node: 0 22 + utf16_len: 2 23 + - - byte_range: 2..3 24 + char_range: 2..3 25 + node_id: p-0-n1 26 + char_offset_in_node: 0 27 + utf16_len: 2
+47
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs.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\">first\n</p>" 7 + - "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">second</p>" 8 + paragraph_ranges: 9 + - byte_start: 0 10 + byte_end: 6 11 + char_start: 0 12 + char_end: 6 13 + - byte_start: 6 14 + byte_end: 13 15 + char_start: 6 16 + char_end: 13 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..5 24 + char_range: 0..5 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 5 28 + - byte_range: 5..6 29 + char_range: 5..6 30 + node_id: p-0-n0 31 + char_offset_in_node: 5 32 + utf16_len: 1 33 + - - byte_range: 6..7 34 + char_range: 6..7 35 + node_id: p-1-n0 36 + char_offset_in_node: 0 37 + utf16_len: 1 38 + - byte_range: 7..7 39 + char_range: 7..7 40 + node_id: p-1-n1 41 + char_offset_in_node: 0 42 + utf16_len: 0 43 + - byte_range: 7..13 44 + char_range: 7..13 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 6
+62
crates/weaver-editor-core/src/writer/snapshots/weaver_editor_core__writer__tests__two_paragraphs_trailing.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\">first\n</p>" 7 + - "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">second\n</p>" 8 + - "<span id=\"p-2-n0\">\n​</span>" 9 + paragraph_ranges: 10 + - byte_start: 0 11 + byte_end: 6 12 + char_start: 0 13 + char_end: 6 14 + - byte_start: 6 15 + byte_end: 14 16 + char_start: 6 17 + char_end: 14 18 + - byte_start: 14 19 + byte_end: 15 20 + char_start: 14 21 + char_end: 15 22 + offset_maps: 23 + - - byte_range: 0..0 24 + char_range: 0..0 25 + node_id: p-0-n0 26 + char_offset_in_node: 0 27 + utf16_len: 0 28 + - byte_range: 0..5 29 + char_range: 0..5 30 + node_id: p-0-n0 31 + char_offset_in_node: 0 32 + utf16_len: 5 33 + - byte_range: 5..6 34 + char_range: 5..6 35 + node_id: p-0-n0 36 + char_offset_in_node: 5 37 + utf16_len: 1 38 + - - byte_range: 6..7 39 + char_range: 6..7 40 + node_id: p-1-n0 41 + char_offset_in_node: 0 42 + utf16_len: 1 43 + - byte_range: 7..7 44 + char_range: 7..7 45 + node_id: p-1-n1 46 + char_offset_in_node: 0 47 + utf16_len: 0 48 + - byte_range: 7..13 49 + char_range: 7..13 50 + node_id: p-1-n1 51 + char_offset_in_node: 0 52 + utf16_len: 6 53 + - byte_range: 13..14 54 + char_range: 13..14 55 + node_id: p-1-n1 56 + char_offset_in_node: 6 57 + utf16_len: 1 58 + - - byte_range: 14..15 59 + char_range: 14..15 60 + node_id: p-2-n0 61 + char_offset_in_node: 0 62 + utf16_len: 2
+27 -17
crates/weaver-editor-core/src/writer/tags.rs
··· 232 } 233 self.begin_node(node_id.clone()); 234 235 - // Map the start position of the paragraph (before any content) 236 - // This allows cursor to be placed at the very beginning 237 - let para_start_char = self.last_char_offset; 238 - let mapping = OffsetMapping { 239 - byte_range: range.start..range.start, 240 - char_range: para_start_char..para_start_char, 241 - node_id, 242 - char_offset_in_node: 0, 243 - child_index: Some(0), // position before first child 244 - utf16_len: 0, 245 - }; 246 - self.current_para.offset_maps.push(mapping); 247 - 248 - // Emit > syntax if we're inside a blockquote 249 if let Some(bq_range) = self.pending_blockquote_range.take() { 250 if bq_range.start < bq_range.end { 251 let raw_text = &self.source[bq_range.clone()]; 252 if let Some(gt_pos) = raw_text.find('>') { 253 - // Extract > [!NOTE] or just > 254 let after_gt = &raw_text[gt_pos + 1..]; 255 let syntax_end = if after_gt.trim_start().starts_with("[!") { 256 // Find the closing ] ··· 260 gt_pos + 1 261 } 262 } else { 263 - // Just > and maybe a space 264 - (gt_pos + 1).min(raw_text.len()) 265 }; 266 267 let syntax = &raw_text[gt_pos..syntax_end]; ··· 270 } 271 } 272 } 273 Ok(()) 274 } 275 Tag::Heading { ··· 1466 syntax_type: SyntaxType::Inline, 1467 formatted_range: None, // Will be set by finalize 1468 }); 1469 1470 self.last_char_offset = char_end; 1471 self.last_byte_offset = range.end;
··· 232 } 233 self.begin_node(node_id.clone()); 234 235 + // Emit > syntax if we're inside a blockquote (BEFORE start mapping) 236 if let Some(bq_range) = self.pending_blockquote_range.take() { 237 if bq_range.start < bq_range.end { 238 let raw_text = &self.source[bq_range.clone()]; 239 if let Some(gt_pos) = raw_text.find('>') { 240 + // Extract "> " or "> [!NOTE]" etc 241 let after_gt = &raw_text[gt_pos + 1..]; 242 let syntax_end = if after_gt.trim_start().starts_with("[!") { 243 // Find the closing ] ··· 247 gt_pos + 1 248 } 249 } else { 250 + // Include > and the space after it (for consistent hiding) 251 + if after_gt.starts_with(' ') { 252 + gt_pos + 2 // "> " 253 + } else { 254 + gt_pos + 1 // just ">" 255 + } 256 }; 257 258 let syntax = &raw_text[gt_pos..syntax_end]; ··· 261 } 262 } 263 } 264 + 265 + // Map the start position of the paragraph (after blockquote marker if any) 266 + // This allows cursor to be placed at the start of actual content 267 + let para_start_byte = self.last_byte_offset; 268 + let para_start_char = self.last_char_offset; 269 + let mapping = OffsetMapping { 270 + byte_range: para_start_byte..para_start_byte, 271 + char_range: para_start_char..para_start_char, 272 + node_id, 273 + char_offset_in_node: self.current_node.char_offset, 274 + child_index: Some(self.current_node.child_count), 275 + utf16_len: 0, 276 + }; 277 + self.current_para.offset_maps.push(mapping); 278 + 279 Ok(()) 280 } 281 Tag::Heading { ··· 1472 syntax_type: SyntaxType::Inline, 1473 formatted_range: None, // Will be set by finalize 1474 }); 1475 + 1476 + // Record offset mapping for the ]] 1477 + let byte_start = range.end - 2; // ]] is 2 bytes 1478 + self.record_mapping(byte_start..range.end, char_start..char_end); 1479 1480 self.last_char_offset = char_end; 1481 self.last_byte_offset = range.end;
+223
crates/weaver-editor-core/src/writer/tests.rs
···
··· 1 + //! Snapshot tests for EditorWriter output. 2 + //! 3 + //! These tests exercise edge cases in paragraph rendering, cursor positioning, 4 + //! and offset mapping. 5 + 6 + use markdown_weaver::Parser; 7 + 8 + use crate::text::EditorRope; 9 + use crate::weaver_renderer; 10 + 11 + use super::EditorWriter; 12 + 13 + /// Helper to render markdown and return HTML segments + paragraph ranges. 14 + fn render_markdown(source: &str) -> RenderOutput { 15 + let rope = EditorRope::from(source); 16 + let parser = Parser::new_ext(source, weaver_renderer::default_md_options()).into_offset_iter(); 17 + 18 + let writer: EditorWriter<'_, _, _, (), (), ()> = 19 + EditorWriter::new(source, &rope, parser).with_auto_incrementing_prefix(0); 20 + 21 + let result = writer.run().expect("render failed"); 22 + 23 + RenderOutput { 24 + html_segments: result.html_segments, 25 + paragraph_ranges: result 26 + .paragraph_ranges 27 + .into_iter() 28 + .map(|(byte_range, char_range)| ParagraphRange { 29 + byte_start: byte_range.start, 30 + byte_end: byte_range.end, 31 + char_start: char_range.start, 32 + char_end: char_range.end, 33 + }) 34 + .collect(), 35 + offset_maps: result 36 + .offset_maps_by_paragraph 37 + .into_iter() 38 + .map(|maps| { 39 + maps.into_iter() 40 + .map(|m| OffsetMapEntry { 41 + byte_range: format!("{}..{}", m.byte_range.start, m.byte_range.end), 42 + char_range: format!("{}..{}", m.char_range.start, m.char_range.end), 43 + node_id: m.node_id.to_string(), 44 + char_offset_in_node: m.char_offset_in_node, 45 + utf16_len: m.utf16_len, 46 + }) 47 + .collect() 48 + }) 49 + .collect(), 50 + } 51 + } 52 + 53 + #[derive(Debug, serde::Serialize)] 54 + struct RenderOutput { 55 + html_segments: Vec<String>, 56 + paragraph_ranges: Vec<ParagraphRange>, 57 + offset_maps: Vec<Vec<OffsetMapEntry>>, 58 + } 59 + 60 + #[derive(Debug, serde::Serialize)] 61 + struct ParagraphRange { 62 + byte_start: usize, 63 + byte_end: usize, 64 + char_start: usize, 65 + char_end: usize, 66 + } 67 + 68 + #[derive(Debug, serde::Serialize)] 69 + struct OffsetMapEntry { 70 + byte_range: String, 71 + char_range: String, 72 + node_id: String, 73 + char_offset_in_node: usize, 74 + utf16_len: usize, 75 + } 76 + 77 + // === Trailing paragraph tests === 78 + 79 + #[test] 80 + fn test_single_paragraph_no_trailing() { 81 + let output = render_markdown("hello world"); 82 + insta::assert_yaml_snapshot!(output); 83 + } 84 + 85 + #[test] 86 + fn test_single_paragraph_single_newline() { 87 + let output = render_markdown("hello world\n"); 88 + insta::assert_yaml_snapshot!(output); 89 + } 90 + 91 + #[test] 92 + fn test_single_paragraph_double_newline() { 93 + // This should create a synthetic trailing paragraph 94 + let output = render_markdown("hello world\n\n"); 95 + insta::assert_yaml_snapshot!(output); 96 + } 97 + 98 + #[test] 99 + fn test_single_paragraph_triple_newline() { 100 + // Multiple trailing newlines 101 + let output = render_markdown("hello world\n\n\n"); 102 + insta::assert_yaml_snapshot!(output); 103 + } 104 + 105 + #[test] 106 + fn test_two_paragraphs() { 107 + let output = render_markdown("first\n\nsecond"); 108 + insta::assert_yaml_snapshot!(output); 109 + } 110 + 111 + #[test] 112 + fn test_two_paragraphs_trailing() { 113 + // Two paragraphs plus trailing newlines 114 + let output = render_markdown("first\n\nsecond\n\n"); 115 + insta::assert_yaml_snapshot!(output); 116 + } 117 + 118 + // === Multiple blank lines tests === 119 + 120 + #[test] 121 + fn test_three_enters() { 122 + // Simulates pressing enter 3 times from empty 123 + let output = render_markdown("\n\n\n"); 124 + insta::assert_yaml_snapshot!(output); 125 + } 126 + 127 + #[test] 128 + fn test_four_enters() { 129 + // Bug report: 4th enter moves cursor backwards 130 + let output = render_markdown("\n\n\n\n"); 131 + insta::assert_yaml_snapshot!(output); 132 + } 133 + 134 + #[test] 135 + fn test_text_then_four_enters() { 136 + let output = render_markdown("test\n\n\n\n"); 137 + insta::assert_yaml_snapshot!(output); 138 + } 139 + 140 + #[test] 141 + fn test_many_blank_lines() { 142 + // Bug report: arrow keys in many blank lines jumps to top 143 + let output = render_markdown("start\n\n\n\n\n\nend"); 144 + insta::assert_yaml_snapshot!(output); 145 + } 146 + 147 + // === Blockquote tests === 148 + 149 + #[test] 150 + fn test_blockquote_simple() { 151 + let output = render_markdown("> quote"); 152 + insta::assert_yaml_snapshot!(output); 153 + } 154 + 155 + #[test] 156 + fn test_blockquote_with_trailing() { 157 + let output = render_markdown("> quote\n\n"); 158 + insta::assert_yaml_snapshot!(output); 159 + } 160 + 161 + #[test] 162 + fn test_blockquote_multiline() { 163 + let output = render_markdown("> line one\n> line two"); 164 + insta::assert_yaml_snapshot!(output); 165 + } 166 + 167 + #[test] 168 + fn test_blockquote_then_text() { 169 + // Bug report: can't type on right side of > 170 + let output = render_markdown("> quote\n\ntext after"); 171 + insta::assert_yaml_snapshot!(output); 172 + } 173 + 174 + // === Wikilink tests === 175 + 176 + #[test] 177 + fn test_wikilink_simple() { 178 + let output = render_markdown("[[link]]"); 179 + insta::assert_yaml_snapshot!(output); 180 + } 181 + 182 + #[test] 183 + fn test_wikilink_partial() { 184 + // Partial wikilink 185 + let output = render_markdown("[[word"); 186 + insta::assert_yaml_snapshot!(output); 187 + } 188 + 189 + #[test] 190 + fn test_wikilink_nested_brackets() { 191 + // Bug report: [[][]] structure causes trouble 192 + let output = render_markdown("[[][]]"); 193 + insta::assert_yaml_snapshot!(output); 194 + } 195 + 196 + #[test] 197 + fn test_wikilink_partial_inside_full() { 198 + // Partial link inside full link 199 + let output = render_markdown("[[outer[[inner]]outer]]"); 200 + insta::assert_yaml_snapshot!(output); 201 + } 202 + 203 + #[test] 204 + fn test_many_opening_brackets() { 205 + // From the screenshot - many [[ in sequence 206 + let output = render_markdown("[[[[[[[[[[[[z]]\n]]]]"); 207 + insta::assert_yaml_snapshot!(output); 208 + } 209 + 210 + // === Soft break / line continuation tests === 211 + 212 + #[test] 213 + fn test_soft_break() { 214 + let output = render_markdown("line one\nline two"); 215 + insta::assert_yaml_snapshot!(output); 216 + } 217 + 218 + #[test] 219 + fn test_hard_break() { 220 + // Two trailing spaces = hard break 221 + let output = render_markdown("line one \nline two"); 222 + insta::assert_yaml_snapshot!(output); 223 + }
+187
docs/graph-data.json
··· 2089 "created_at": "2026-01-07T12:04:06.226305951-05:00", 2090 "updated_at": "2026-01-07T12:04:06.226305951-05:00", 2091 "metadata_json": "{\"confidence\":95}" 2092 } 2093 ], 2094 "edges": [ ··· 4203 "weight": 1.0, 4204 "rationale": "Action resulted in fix", 4205 "created_at": "2026-01-07T12:04:10.057504275-05:00" 4206 } 4207 ] 4208 }
··· 2089 "created_at": "2026-01-07T12:04:06.226305951-05:00", 2090 "updated_at": "2026-01-07T12:04:06.226305951-05:00", 2091 "metadata_json": "{\"confidence\":95}" 2092 + }, 2093 + { 2094 + "id": 192, 2095 + "change_id": "656d5c9e-947a-44ce-ae7c-22a804bb7a4c", 2096 + "node_type": "observation", 2097 + "title": "Wikilink cursor jump: typing [[word]] then ]] then arrow right moves cursor to higher line", 2098 + "description": null, 2099 + "status": "pending", 2100 + "created_at": "2026-01-07T18:18:16.736874380-05:00", 2101 + "updated_at": "2026-01-07T18:18:16.736874380-05:00", 2102 + "metadata_json": "{\"confidence\":95}" 2103 + }, 2104 + { 2105 + "id": 193, 2106 + "change_id": "30fe17ef-81e2-44c9-a9fc-94cd6cbc6d88", 2107 + "node_type": "observation", 2108 + "title": "Bonus ]] appears when clicking inside wikilinks", 2109 + "description": null, 2110 + "status": "pending", 2111 + "created_at": "2026-01-07T18:18:16.903055659-05:00", 2112 + "updated_at": "2026-01-07T18:18:16.903055659-05:00", 2113 + "metadata_json": "{\"confidence\":90}" 2114 + }, 2115 + { 2116 + "id": 194, 2117 + "change_id": "b5a4ab60-5990-44a0-bc58-5b8deaa828a3", 2118 + "node_type": "observation", 2119 + "title": "Select all (Ctrl+A) then delete doesn't work - Ctrl+A doesn't select anything", 2120 + "description": null, 2121 + "status": "pending", 2122 + "created_at": "2026-01-07T18:18:17.052526312-05:00", 2123 + "updated_at": "2026-01-07T18:18:17.052526312-05:00", 2124 + "metadata_json": "{\"confidence\":95}" 2125 + }, 2126 + { 2127 + "id": 195, 2128 + "change_id": "cd8b0c43-931d-42aa-94ee-94abc5242fec", 2129 + "node_type": "observation", 2130 + "title": "Selecting and deleting a link deletes everything BUT the link", 2131 + "description": null, 2132 + "status": "pending", 2133 + "created_at": "2026-01-07T18:18:17.204173140-05:00", 2134 + "updated_at": "2026-01-07T18:18:17.204173140-05:00", 2135 + "metadata_json": "{\"confidence\":90}" 2136 + }, 2137 + { 2138 + "id": 196, 2139 + "change_id": "f93d206d-c992-42dd-9fda-fb33d3119671", 2140 + "node_type": "observation", 2141 + "title": "Enter 4+ times: 4th enter moves cursor backwards 2 lines (related to trailing para fix?)", 2142 + "description": null, 2143 + "status": "pending", 2144 + "created_at": "2026-01-07T18:18:17.358870318-05:00", 2145 + "updated_at": "2026-01-07T18:18:17.358870318-05:00", 2146 + "metadata_json": "{\"confidence\":85}" 2147 + }, 2148 + { 2149 + "id": 197, 2150 + "change_id": "553c2a51-548c-486e-bbfc-5eec7659bca5", 2151 + "node_type": "observation", 2152 + "title": "Arrow keys in many blank lines jumps cursor all the way up", 2153 + "description": null, 2154 + "status": "pending", 2155 + "created_at": "2026-01-07T18:18:17.525602548-05:00", 2156 + "updated_at": "2026-01-07T18:18:17.525602548-05:00", 2157 + "metadata_json": "{\"confidence\":90}" 2158 + }, 2159 + { 2160 + "id": 198, 2161 + "change_id": "aed63653-1a44-4cf6-91d7-55ad805e333e", 2162 + "node_type": "observation", 2163 + "title": "Long lines without breaks overflow right side - no horizontal scroll", 2164 + "description": null, 2165 + "status": "pending", 2166 + "created_at": "2026-01-07T18:18:26.175916664-05:00", 2167 + "updated_at": "2026-01-07T18:18:26.175916664-05:00", 2168 + "metadata_json": "{\"confidence\":95}" 2169 + }, 2170 + { 2171 + "id": 199, 2172 + "change_id": "383df748-e034-41c1-a740-6f581b9d6221", 2173 + "node_type": "observation", 2174 + "title": "Double return on last line: returns once, second time makes text invisible", 2175 + "description": null, 2176 + "status": "pending", 2177 + "created_at": "2026-01-07T18:18:26.336486862-05:00", 2178 + "updated_at": "2026-01-07T18:18:26.336486862-05:00", 2179 + "metadata_json": "{\"confidence\":85}" 2180 + }, 2181 + { 2182 + "id": 200, 2183 + "change_id": "ee54235d-7bf4-452e-90d2-b44a2357fac5", 2184 + "node_type": "observation", 2185 + "title": "Cannot type on right side of blockquote (>) - cursor always ends up on left", 2186 + "description": null, 2187 + "status": "pending", 2188 + "created_at": "2026-01-07T18:18:26.519741428-05:00", 2189 + "updated_at": "2026-01-07T18:18:26.519741428-05:00", 2190 + "metadata_json": "{\"confidence\":90}" 2191 + }, 2192 + { 2193 + "id": 201, 2194 + "change_id": "1ed799fc-2aaa-4262-b5bb-18c562f18c64", 2195 + "node_type": "observation", 2196 + "title": "Nested link structures like [[][]] cause rendering/cursor trouble - partial links inside full links", 2197 + "description": null, 2198 + "status": "pending", 2199 + "created_at": "2026-01-07T18:18:26.679585456-05:00", 2200 + "updated_at": "2026-01-07T18:18:26.679585456-05:00", 2201 + "metadata_json": "{\"confidence\":90}" 2202 + }, 2203 + { 2204 + "id": 202, 2205 + "change_id": "3a60de43-fe8c-4678-b36a-9bff9f0d21fd", 2206 + "node_type": "observation", 2207 + "title": "Login hang on certain pages: editor and homepage confirmed bad, possibly others", 2208 + "description": null, 2209 + "status": "pending", 2210 + "created_at": "2026-01-07T18:18:26.849709681-05:00", 2211 + "updated_at": "2026-01-07T18:18:26.849709681-05:00", 2212 + "metadata_json": "{\"confidence\":95}" 2213 + }, 2214 + { 2215 + "id": 203, 2216 + "change_id": "7f5f61d9-67a2-4743-bf6e-64df19ee991c", 2217 + "node_type": "outcome", 2218 + "title": "Fixed wikilink ]] placement - TagEnd::Link added to is_self_handled_end, closing ]] now emitted outside </a> tag", 2219 + "description": null, 2220 + "status": "pending", 2221 + "created_at": "2026-01-07T19:19:10.569293034-05:00", 2222 + "updated_at": "2026-01-07T19:19:10.569293034-05:00", 2223 + "metadata_json": "{\"confidence\":85}" 2224 + }, 2225 + { 2226 + "id": 204, 2227 + "change_id": "5bddd314-fd5a-487b-b9fb-25a7150a71ed", 2228 + "node_type": "outcome", 2229 + "title": "Fixed blockquote cursor positioning - skip gap emission for blockquote paragraphs, emit > marker inside <p> tag with proper byte/char alignment", 2230 + "description": null, 2231 + "status": "pending", 2232 + "created_at": "2026-01-07T19:19:22.093439371-05:00", 2233 + "updated_at": "2026-01-07T19:19:22.093439371-05:00", 2234 + "metadata_json": "{\"confidence\":85}" 2235 } 2236 ], 2237 "edges": [ ··· 4346 "weight": 1.0, 4347 "rationale": "Action resulted in fix", 4348 "created_at": "2026-01-07T12:04:10.057504275-05:00" 4349 + }, 4350 + { 4351 + "id": 194, 4352 + "from_node_id": 192, 4353 + "to_node_id": 203, 4354 + "from_change_id": "656d5c9e-947a-44ce-ae7c-22a804bb7a4c", 4355 + "to_change_id": "7f5f61d9-67a2-4743-bf6e-64df19ee991c", 4356 + "edge_type": "leads_to", 4357 + "weight": 1.0, 4358 + "rationale": "Fix addresses cursor jump issue", 4359 + "created_at": "2026-01-07T19:19:10.585571504-05:00" 4360 + }, 4361 + { 4362 + "id": 195, 4363 + "from_node_id": 193, 4364 + "to_node_id": 203, 4365 + "from_change_id": "30fe17ef-81e2-44c9-a9fc-94cd6cbc6d88", 4366 + "to_change_id": "7f5f61d9-67a2-4743-bf6e-64df19ee991c", 4367 + "edge_type": "leads_to", 4368 + "weight": 1.0, 4369 + "rationale": "Fix addresses bonus ]] issue", 4370 + "created_at": "2026-01-07T19:19:15.906159790-05:00" 4371 + }, 4372 + { 4373 + "id": 196, 4374 + "from_node_id": 201, 4375 + "to_node_id": 203, 4376 + "from_change_id": "1ed799fc-2aaa-4262-b5bb-18c562f18c64", 4377 + "to_change_id": "7f5f61d9-67a2-4743-bf6e-64df19ee991c", 4378 + "edge_type": "leads_to", 4379 + "weight": 1.0, 4380 + "rationale": "Fix addresses nested link structure issues", 4381 + "created_at": "2026-01-07T19:19:15.923157054-05:00" 4382 + }, 4383 + { 4384 + "id": 197, 4385 + "from_node_id": 200, 4386 + "to_node_id": 204, 4387 + "from_change_id": "ee54235d-7bf4-452e-90d2-b44a2357fac5", 4388 + "to_change_id": "5bddd314-fd5a-487b-b9fb-25a7150a71ed", 4389 + "edge_type": "leads_to", 4390 + "weight": 1.0, 4391 + "rationale": "Fix addresses cannot type right of > issue", 4392 + "created_at": "2026-01-07T19:19:22.168136068-05:00" 4393 } 4394 ] 4395 }