atproto blogging
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

Orual 5795cc05 1f18b2d9

+1435 -39
+3
Cargo.lock
··· 12260 12260 "dioxus-web 0.7.2", 12261 12261 "gloo-events", 12262 12262 "gloo-utils", 12263 + "insta", 12263 12264 "js-sys", 12264 12265 "smol_str", 12265 12266 "tracing", ··· 12274 12275 name = "weaver-editor-core" 12275 12276 version = "0.1.0" 12276 12277 dependencies = [ 12278 + "insta", 12277 12279 "jacquard", 12278 12280 "markdown-weaver", 12279 12281 "markdown-weaver-escape", 12280 12282 "ropey", 12283 + "serde", 12281 12284 "smol_str", 12282 12285 "syntect", 12283 12286 "thiserror 2.0.17",
+3 -6
crates/weaver-app/src/components/editor/component.rs
··· 808 808 }; 809 809 810 810 // Navigation keys (with or without Shift for selection) 811 + // We sync cursor from DOM for these because we let the browser handle them 811 812 let navigation = matches!( 812 813 evt.key(), 813 814 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 814 815 Key::Home | Key::End | Key::PageUp | Key::PageDown 815 816 ); 816 817 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"); 818 + // Ctrl+A/Cmd+A is handled by browser natively, onselectionchange syncs it. 820 819 821 - if navigation || select_all { 820 + if navigation { 822 821 tracing::debug!( 823 822 key = ?evt.key(), 824 - navigation, 825 - select_all, 826 823 "onkeyup navigation - syncing cursor from DOM" 827 824 ); 828 825 let paras = cached_paragraphs();
+1
crates/weaver-editor-browser/Cargo.toml
··· 83 83 84 84 [dev-dependencies] 85 85 wasm-bindgen-test = "0.3" 86 + insta = { version = "1.40", features = ["yaml"] }
+2
crates/weaver-editor-core/Cargo.toml
··· 37 37 ] } 38 38 39 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 716 } 717 717 718 718 // === Selection === 719 - bindings.insert( 720 - KeyCombo::primary(Key::character("a"), is_mac), 721 - EditorAction::SelectAll, 722 - ); 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 + // ); 723 724 724 725 // === Line deletion === 725 726 if is_mac { ··· 764 765 range: Range::caret(0), 765 766 }, 766 767 ); 767 - bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 768 + // bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 768 769 769 770 Self { bindings } 770 771 } ··· 772 773 /// Look up an action for the given key combo. 773 774 /// 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). 775 778 pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> { 776 - self.bindings.get(combo).cloned().map(|a| a.with_range(range)) 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 777 799 } 778 800 779 801 /// Add or replace a keybinding.
+6 -1
crates/weaver-editor-core/src/text.rs
··· 76 76 let search_start = offset.saturating_sub(BLOCK_SYNTAX_ZONE + 1); 77 77 match self.slice(search_start..offset) { 78 78 Some(s) => match s.rfind('\n') { 79 - Some(pos) => (offset - search_start - pos - 1) <= BLOCK_SYNTAX_ZONE, 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 + } 80 85 None => false, // No newline in range, offset > BLOCK_SYNTAX_ZONE. 81 86 }, 82 87 None => false,
+59 -9
crates/weaver-editor-core/src/writer/events.rs
··· 38 38 // For End events, emit any trailing content within the event's range 39 39 // BEFORE calling end_tag (which calls end_node and clears current_node_id) 40 40 // 41 - // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), 41 + // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough, Link), 42 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. 43 + // Otherwise the closing `**` or `]]` span ends up INSIDE the element. 44 44 // These tags handle their own closing syntax in end_tag(). 45 45 // Image and Embed handle ALL their syntax in the Start event, so exclude them too. 46 46 let is_self_handled_end = matches!( ··· 49 49 TagEnd::Strong 50 50 | TagEnd::Emphasis 51 51 | TagEnd::Strikethrough 52 + | TagEnd::Link 52 53 | TagEnd::Image 53 54 | TagEnd::Embed 54 55 ) ··· 60 61 } else if !matches!(&event, Event::End(_)) { 61 62 // For paragraph-level start events, capture pre-gap position so the 62 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. 63 66 let is_para_start = matches!( 64 67 &event, 65 68 Event::Start( ··· 67 70 | Tag::Heading { .. } 68 71 | Tag::CodeBlock(_) 69 72 | Tag::List(_) 70 - | Tag::BlockQuote(_) 73 + | Tag::FootnoteDefinition(_) 71 74 | Tag::HtmlBlock 72 75 ) 73 76 ); ··· 78 81 79 82 // For other events, emit any gap before range.start 80 83 // (emit_syntax handles char offset tracking) 81 - self.emit_gap_before(range.start)?; 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 + } 82 93 } 83 94 // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML 84 95 85 96 // Store last_byte before processing 86 97 let last_byte_before = self.last_byte_offset; 87 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 + 88 115 // Process the event (passing range for tag syntax) 89 116 self.process_event(event, range.clone())?; 90 117 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 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 { 95 122 self.last_byte_offset = range.end; 96 123 } 97 - // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value 124 + // else: Event updated offset, or it's a container - keep current value 98 125 } 99 126 100 127 // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing. ··· 164 191 || !self.current_para.syntax_spans.is_empty() 165 192 || !self.ref_collector.refs.is_empty() 166 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 + 167 217 self.offset_maps_by_para 168 218 .push(std::mem::take(&mut self.current_para.offset_maps)); 169 219 self.syntax_spans_by_para
+2
crates/weaver-editor-core/src/writer/mod.rs
··· 8 8 mod state; 9 9 mod syntax; 10 10 mod tags; 11 + #[cfg(test)] 12 + mod tests; 11 13 12 14 pub use embed::EditorImageResolver; 13 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 232 } 233 233 self.begin_node(node_id.clone()); 234 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 235 + // Emit > syntax if we're inside a blockquote (BEFORE start mapping) 249 236 if let Some(bq_range) = self.pending_blockquote_range.take() { 250 237 if bq_range.start < bq_range.end { 251 238 let raw_text = &self.source[bq_range.clone()]; 252 239 if let Some(gt_pos) = raw_text.find('>') { 253 - // Extract > [!NOTE] or just > 240 + // Extract "> " or "> [!NOTE]" etc 254 241 let after_gt = &raw_text[gt_pos + 1..]; 255 242 let syntax_end = if after_gt.trim_start().starts_with("[!") { 256 243 // Find the closing ] ··· 260 247 gt_pos + 1 261 248 } 262 249 } else { 263 - // Just > and maybe a space 264 - (gt_pos + 1).min(raw_text.len()) 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 + } 265 256 }; 266 257 267 258 let syntax = &raw_text[gt_pos..syntax_end]; ··· 270 261 } 271 262 } 272 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 + 273 279 Ok(()) 274 280 } 275 281 Tag::Heading { ··· 1466 1472 syntax_type: SyntaxType::Inline, 1467 1473 formatted_range: None, // Will be set by finalize 1468 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); 1469 1479 1470 1480 self.last_char_offset = char_end; 1471 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 2089 "created_at": "2026-01-07T12:04:06.226305951-05:00", 2090 2090 "updated_at": "2026-01-07T12:04:06.226305951-05:00", 2091 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}" 2092 2235 } 2093 2236 ], 2094 2237 "edges": [ ··· 4203 4346 "weight": 1.0, 4204 4347 "rationale": "Action resulted in fix", 4205 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" 4206 4393 } 4207 4394 ] 4208 4395 }