newline cursor jumping fixed

Orual 1f18b2d9 5b8bf5d1

+219 -36
+8 -3
crates/weaver-editor-browser/src/dom_sync.rs
··· 611 611 let _ = div.set_attribute("data-hash", &new_hash); 612 612 let div_node: &web_sys::Node = div.as_ref(); 613 613 let _ = editor.insert_before(div_node, cursor_node.as_ref()); 614 - } 615 614 616 - if is_cursor_para { 617 - cursor_para_updated = true; 615 + if is_cursor_para { 616 + if let Err(e) = 617 + restore_cursor_position(cursor_offset, &new_para.offset_map, None) 618 + { 619 + tracing::warn!("Cursor restore for new paragraph failed: {:?}", e); 620 + } 621 + cursor_para_updated = true; 622 + } 618 623 } 619 624 } 620 625 }
+81 -6
crates/weaver-editor-core/src/writer/events.rs
··· 4 4 use std::fmt::Write as _; 5 5 use std::ops::Range; 6 6 7 - use markdown_weaver::{Event, TagEnd}; 7 + use markdown_weaver::{Event, Tag, TagEnd}; 8 8 use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count}; 9 9 10 10 use crate::offset_map::OffsetMapping; ··· 58 58 // Emit gap from last_byte_offset to range.end 59 59 self.emit_gap_before(range.end)?; 60 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( 66 + Tag::Paragraph(_) 67 + | Tag::Heading { .. } 68 + | Tag::CodeBlock(_) 69 + | Tag::List(_) 70 + | Tag::BlockQuote(_) 71 + | Tag::HtmlBlock 72 + ) 73 + ); 74 + if is_para_start && self.paragraphs.should_track_boundaries() { 75 + self.paragraphs.pre_gap_start = 76 + Some((self.last_byte_offset, self.last_char_offset)); 77 + } 78 + 61 79 // For other events, emit any gap before range.start 62 80 // (emit_syntax handles char offset tracking) 63 81 self.emit_gap_before(range.start)?; ··· 79 97 // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value 80 98 } 81 99 82 - // Emit any trailing syntax 83 - self.emit_gap_before(self.source.len())?; 100 + // Check if document ends with a paragraph break (double newline) BEFORE emitting trailing. 101 + // If so, we'll reserve the final newline for a synthetic trailing paragraph. 102 + let ends_with_para_break = self.source.ends_with("\n\n") 103 + || self.source.ends_with("\n\u{200C}\n"); 104 + 105 + // Determine where to stop emitting trailing syntax 106 + let trailing_emit_end = if ends_with_para_break { 107 + // Don't emit the final newline - save it for synthetic paragraph 108 + self.source.len().saturating_sub(1) 109 + } else { 110 + self.source.len() 111 + }; 112 + 113 + // Emit trailing syntax up to the determined point 114 + self.emit_gap_before(trailing_emit_end)?; 84 115 85 116 // Handle unmapped trailing content (stripped by parser) 86 117 // This includes trailing spaces that markdown ignores 87 118 let doc_byte_len = self.source.len(); 88 119 let doc_char_len = self.text_buffer.len_chars(); 89 120 90 - if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 91 - // Emit the trailing content as visible syntax 121 + if !ends_with_para_break 122 + && (self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len) 123 + { 124 + // Emit the trailing content as visible syntax (only if not creating synthetic para) 92 125 if self.last_byte_offset < doc_byte_len { 93 126 let trailing = &self.source[self.last_byte_offset..]; 94 127 if !trailing.is_empty() { ··· 125 158 } 126 159 } 127 160 128 - // Add any remaining accumulated data for the last paragraph 161 + // Add any remaining accumulated data for the last paragraph FIRST 129 162 // (content that wasn't followed by a paragraph boundary) 130 163 if !self.current_para.offset_maps.is_empty() 131 164 || !self.current_para.syntax_spans.is_empty() ··· 137 170 .push(std::mem::take(&mut self.current_para.syntax_spans)); 138 171 self.refs_by_para 139 172 .push(std::mem::take(&mut self.ref_collector.refs)); 173 + } 174 + 175 + // Now create a synthetic trailing paragraph if needed 176 + if ends_with_para_break { 177 + // Get the trailing content we reserved (the final newline) 178 + let trailing_content = &self.source[trailing_emit_end..]; 179 + let trailing_char_len = trailing_content.chars().count(); 180 + 181 + let trailing_start_char = self.last_char_offset; 182 + let trailing_start_byte = self.last_byte_offset; 183 + let trailing_end_char = trailing_start_char + trailing_char_len; 184 + let trailing_end_byte = self.source.len(); 185 + 186 + // Create paragraph range that includes the trailing content 187 + self.paragraphs.ranges.push(( 188 + trailing_start_byte..trailing_end_byte, 189 + trailing_start_char..trailing_end_char, 190 + )); 191 + 192 + // Start a new HTML segment for this trailing paragraph 193 + self.writer.new_segment(); 194 + let node_id = self.gen_node_id(); 195 + 196 + // Write the actual trailing content plus ZWSP for cursor positioning 197 + write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 198 + escape_html(&mut self.writer, trailing_content)?; 199 + self.write("\u{200B}</span>")?; 200 + 201 + // Record offset mapping for the trailing content 202 + let mapping = OffsetMapping { 203 + byte_range: trailing_start_byte..trailing_end_byte, 204 + char_range: trailing_start_char..trailing_end_char, 205 + node_id, 206 + char_offset_in_node: 0, 207 + child_index: None, 208 + utf16_len: trailing_char_len + 1, // Content + ZWSP 209 + }; 210 + 211 + // Create offset_maps/syntax_spans/refs for this trailing paragraph 212 + self.offset_maps_by_para.push(vec![mapping]); 213 + self.syntax_spans_by_para.push(vec![]); 214 + self.refs_by_para.push(vec![]); 140 215 } 141 216 142 217 // Get HTML segments from writer
+3
crates/weaver-editor-core/src/writer/state.rs
··· 163 163 pub ranges: Vec<(Range<usize>, Range<usize>)>, 164 164 /// Start of current paragraph: (byte_offset, char_offset) 165 165 pub current_start: Option<(usize, usize)>, 166 + /// Pre-gap position for paragraph start (captured before gap emission). 167 + /// This ensures the paragraph's char_range includes leading whitespace. 168 + pub pre_gap_start: Option<(usize, usize)>, 166 169 /// List nesting depth (suppress paragraph boundaries inside lists) 167 170 pub list_depth: usize, 168 171 /// In footnote definition (suppress inner paragraph boundaries)
+52 -24
crates/weaver-editor-core/src/writer/tags.rs
··· 147 147 match tag { 148 148 // HTML blocks get their own paragraph to try and corral them better 149 149 Tag::HtmlBlock => { 150 - // Record paragraph start for boundary tracking 151 - // Skip if inside a list or footnote def - they own their paragraph boundary 150 + // Record paragraph start for boundary tracking. 151 + // Use pre_gap_start if available to include leading whitespace in char_range. 152 + // Skip if inside a list or footnote def - they own their paragraph boundary. 152 153 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def { 153 - self.paragraphs.current_start = 154 - Some((self.last_byte_offset, self.last_char_offset)); 154 + self.paragraphs.current_start = self 155 + .paragraphs 156 + .pre_gap_start 157 + .take() 158 + .or(Some((self.last_byte_offset, self.last_char_offset))); 155 159 } 156 160 let node_id = self.gen_node_id(); 157 161 ··· 189 193 // Handle wrapper before block 190 194 self.emit_wrapper_start()?; 191 195 192 - // Record paragraph start for boundary tracking 193 - // Skip if inside a list or footnote def - they own their paragraph boundary 196 + // Record paragraph start for boundary tracking. 197 + // Use pre_gap_start if available to include leading whitespace in char_range. 198 + // Skip if inside a list or footnote def - they own their paragraph boundary. 194 199 if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def { 195 - self.paragraphs.current_start = 196 - Some((self.last_byte_offset, self.last_char_offset)); 200 + self.paragraphs.current_start = self 201 + .paragraphs 202 + .pre_gap_start 203 + .take() 204 + .or(Some((self.last_byte_offset, self.last_char_offset))); 197 205 } 198 206 199 207 let node_id = self.gen_node_id(); ··· 273 281 // Emit wrapper if pending (but don't close on heading end - wraps following block too) 274 282 self.emit_wrapper_start()?; 275 283 276 - // Record paragraph start for boundary tracking 277 - // Treat headings as paragraph-level blocks 278 - self.paragraphs.current_start = 279 - Some((self.last_byte_offset, self.last_char_offset)); 284 + // Record paragraph start for boundary tracking. 285 + // Use pre_gap_start if available to include leading whitespace in char_range. 286 + // Treat headings as paragraph-level blocks. 287 + self.paragraphs.current_start = self 288 + .paragraphs 289 + .pre_gap_start 290 + .take() 291 + .or(Some((self.last_byte_offset, self.last_char_offset))); 280 292 281 293 if !self.end_newline { 282 294 self.write_newline()?; ··· 435 447 Tag::CodeBlock(info) => { 436 448 self.emit_wrapper_start()?; 437 449 438 - // Track code block as paragraph-level block 439 - self.paragraphs.current_start = 440 - Some((self.last_byte_offset, self.last_char_offset)); 450 + // Track code block as paragraph-level block. 451 + // Use pre_gap_start if available to include leading whitespace in char_range. 452 + self.paragraphs.current_start = self 453 + .paragraphs 454 + .pre_gap_start 455 + .take() 456 + .or(Some((self.last_byte_offset, self.last_char_offset))); 441 457 442 458 if !self.end_newline { 443 459 self.write_newline()?; ··· 511 527 } 512 528 Tag::List(Some(1)) => { 513 529 self.emit_wrapper_start()?; 514 - // Track list as paragraph-level block 515 - self.paragraphs.current_start = 516 - Some((self.last_byte_offset, self.last_char_offset)); 530 + // Track list as paragraph-level block. 531 + // Use pre_gap_start if available to include leading whitespace in char_range. 532 + self.paragraphs.current_start = self 533 + .paragraphs 534 + .pre_gap_start 535 + .take() 536 + .or(Some((self.last_byte_offset, self.last_char_offset))); 517 537 self.paragraphs.list_depth += 1; 518 538 if self.end_newline { 519 539 self.write("<ol>") ··· 523 543 } 524 544 Tag::List(Some(start)) => { 525 545 self.emit_wrapper_start()?; 526 - // Track list as paragraph-level block 527 - self.paragraphs.current_start = 528 - Some((self.last_byte_offset, self.last_char_offset)); 546 + // Track list as paragraph-level block. 547 + // Use pre_gap_start if available to include leading whitespace in char_range. 548 + self.paragraphs.current_start = self 549 + .paragraphs 550 + .pre_gap_start 551 + .take() 552 + .or(Some((self.last_byte_offset, self.last_char_offset))); 529 553 self.paragraphs.list_depth += 1; 530 554 if self.end_newline { 531 555 self.write("<ol start=\"")?; ··· 537 561 } 538 562 Tag::List(None) => { 539 563 self.emit_wrapper_start()?; 540 - // Track list as paragraph-level block 541 - self.paragraphs.current_start = 542 - Some((self.last_byte_offset, self.last_char_offset)); 564 + // Track list as paragraph-level block. 565 + // Use pre_gap_start if available to include leading whitespace in char_range. 566 + self.paragraphs.current_start = self 567 + .paragraphs 568 + .pre_gap_start 569 + .take() 570 + .or(Some((self.last_byte_offset, self.last_char_offset))); 543 571 self.paragraphs.list_depth += 1; 544 572 if self.end_newline { 545 573 self.write("<ul>")
+1 -1
crates/weaver-renderer/src/lib.rs
··· 35 35 pub mod math; 36 36 #[cfg(feature = "pckt")] 37 37 pub mod pckt; 38 - #[cfg(not(target_family = "wasm"))] 38 + #[cfg(all(not(target_family = "wasm"), feature = "syntax-highlighting"))] 39 39 pub mod static_site; 40 40 pub mod theme; 41 41 pub mod types;
+8
crates/weaver-renderer/src/static_site.rs
··· 231 231 Ok(()) 232 232 } 233 233 234 + #[cfg(feature = "syntax-css")] 234 235 async fn generate_css_files(&self) -> Result<(), miette::Report> { 235 236 use crate::css::{generate_base_css, generate_syntax_css}; 236 237 ··· 255 256 .into_diagnostic()?; 256 257 257 258 Ok(()) 259 + } 260 + 261 + #[cfg(not(feature = "syntax-css"))] 262 + async fn generate_css_files(&self) -> Result<(), miette::Report> { 263 + Err(miette::miette!( 264 + "CSS generation requires the 'syntax-css' feature" 265 + )) 258 266 } 259 267 260 268 async fn generate_default_index(&self) -> Result<(), miette::Report> {
+9
crates/weaver-renderer/src/static_site/document.rs
··· 1 + #[cfg(feature = "syntax-css")] 1 2 use crate::css::{generate_base_css, generate_syntax_css}; 2 3 use crate::static_site::context::{KaTeXSource, StaticSiteContext}; 3 4 use crate::theme::default_resolved_theme; ··· 97 98 .await 98 99 .into_diagnostic()?; 99 100 } 101 + #[cfg(feature = "syntax-css")] 100 102 CssMode::Inline => { 101 103 let default_theme = default_resolved_theme(); 102 104 let theme = context.theme.as_deref().unwrap_or(&default_theme); ··· 115 117 .await 116 118 .into_diagnostic()?; 117 119 writer.write_all(b" </style>\n").await.into_diagnostic()?; 120 + } 121 + #[cfg(not(feature = "syntax-css"))] 122 + CssMode::Inline => { 123 + // CSS generation not available without syntax-css feature 124 + return Err(miette::miette!( 125 + "Inline CSS mode requires the 'syntax-css' feature" 126 + )); 118 127 } 119 128 } 120 129
+57 -2
docs/graph-data.json
··· 1788 1788 "node_type": "goal", 1789 1789 "title": "Refactor EditorDocument to wrap LoroTextBuffer and impl TextBuffer+UndoManager", 1790 1790 "description": null, 1791 - "status": "pending", 1791 + "status": "completed", 1792 1792 "created_at": "2026-01-06T16:00:43.381698744-05:00", 1793 - "updated_at": "2026-01-06T16:00:43.381698744-05:00", 1793 + "updated_at": "2026-01-06T19:36:25.522798888-05:00", 1794 1794 "metadata_json": "{\"confidence\":90,\"prompt\":\"User: refactor EditorDocument struct (rename it) to wrap LoroTextBuffer and implement EditorDocument trait. Replace EditInfo signal with Signal<()>. Migrate callers to trait methods. Remove LoroTextAdapter later.\"}" 1795 1795 }, 1796 1796 { ··· 2055 2055 "status": "pending", 2056 2056 "created_at": "2026-01-06T19:26:35.964103095-05:00", 2057 2057 "updated_at": "2026-01-06T19:26:35.964103095-05:00", 2058 + "metadata_json": "{\"confidence\":95}" 2059 + }, 2060 + { 2061 + "id": 189, 2062 + "change_id": "15ebf616-39db-49ec-927d-88620156a8a0", 2063 + "node_type": "observation", 2064 + "title": "Bug: double-enter paragraph creation moves cursor to wrong position - jumps back to top of previous paragraph instead of landing in new one. Likely offset mapping or paragraph insertion issue.", 2065 + "description": null, 2066 + "status": "pending", 2067 + "created_at": "2026-01-07T09:26:20.476799309-05:00", 2068 + "updated_at": "2026-01-07T09:26:20.476799309-05:00", 2069 + "metadata_json": "{\"confidence\":90}" 2070 + }, 2071 + { 2072 + "id": 190, 2073 + "change_id": "1b36bbd6-f50e-405b-b1cf-3b5718d0f132", 2074 + "node_type": "action", 2075 + "title": "Fixing cursor jump bug in new paragraph creation - restore_cursor_position not called for new paragraph elements", 2076 + "description": null, 2077 + "status": "pending", 2078 + "created_at": "2026-01-07T11:59:37.640443792-05:00", 2079 + "updated_at": "2026-01-07T11:59:37.640443792-05:00", 2080 + "metadata_json": "{\"confidence\":95}" 2081 + }, 2082 + { 2083 + "id": 191, 2084 + "change_id": "dd1da1bc-c368-426f-ac90-1be30eb8b091", 2085 + "node_type": "outcome", 2086 + "title": "Fixed cursor jump bug - restore_cursor_position now called for newly created paragraph elements in dom_sync.rs:615-622", 2087 + "description": null, 2088 + "status": "pending", 2089 + "created_at": "2026-01-07T12:04:06.226305951-05:00", 2090 + "updated_at": "2026-01-07T12:04:06.226305951-05:00", 2058 2091 "metadata_json": "{\"confidence\":95}" 2059 2092 } 2060 2093 ], ··· 4148 4181 "weight": 1.0, 4149 4182 "rationale": "Loading logic extraction part of component.rs split", 4150 4183 "created_at": "2026-01-06T19:26:48.871648902-05:00" 4184 + }, 4185 + { 4186 + "id": 192, 4187 + "from_node_id": 189, 4188 + "to_node_id": 190, 4189 + "from_change_id": "15ebf616-39db-49ec-927d-88620156a8a0", 4190 + "to_change_id": "1b36bbd6-f50e-405b-b1cf-3b5718d0f132", 4191 + "edge_type": "leads_to", 4192 + "weight": 1.0, 4193 + "rationale": "Bug report led to fix implementation", 4194 + "created_at": "2026-01-07T11:59:54.175808294-05:00" 4195 + }, 4196 + { 4197 + "id": 193, 4198 + "from_node_id": 190, 4199 + "to_node_id": 191, 4200 + "from_change_id": "1b36bbd6-f50e-405b-b1cf-3b5718d0f132", 4201 + "to_change_id": "dd1da1bc-c368-426f-ac90-1be30eb8b091", 4202 + "edge_type": "leads_to", 4203 + "weight": 1.0, 4204 + "rationale": "Action resulted in fix", 4205 + "created_at": "2026-01-07T12:04:10.057504275-05:00" 4151 4206 } 4152 4207 ] 4153 4208 }