editor refactored to use core, partially

Orual b01b0d49 74d7abbb

+218 -4150
+1
Cargo.lock
··· 12049 "wasm-bindgen-futures", 12050 "weaver-api", 12051 "weaver-common", 12052 "weaver-renderer", 12053 "web-sys", 12054 "web-time",
··· 12049 "wasm-bindgen-futures", 12050 "weaver-api", 12051 "weaver-common", 12052 + "weaver-editor-core", 12053 "weaver-renderer", 12054 "web-sys", 12055 "web-time",
+1
crates/weaver-app/Cargo.toml
··· 48 49 dioxus = { version = "0.7.1", features = ["router"] } 50 weaver-common = { path = "../weaver-common" } 51 jacquard = { workspace = true}#, features = ["streaming"] } 52 jacquard-lexicon = { workspace = true } 53 jacquard-identity = { workspace = true }
··· 48 49 dioxus = { version = "0.7.1", features = ["router"] } 50 weaver-common = { path = "../weaver-common" } 51 + weaver-editor-core = { path = "../weaver-editor-core" } 52 jacquard = { workspace = true}#, features = ["streaming"] } 53 jacquard-lexicon = { workspace = true } 54 jacquard-identity = { workspace = true }
+1 -1
crates/weaver-app/src/components/editor/actions.rs
··· 753 use super::input::{ 754 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, 755 }; 756 - use super::offset_map::SnapDirection; 757 758 match action { 759 EditorAction::Insert { text, range } => {
··· 753 use super::input::{ 754 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, 755 }; 756 + use weaver_editor_core::SnapDirection; 757 758 match action { 759 EditorAction::Insert { text, range } => {
+2 -2
crates/weaver-app/src/components/editor/component.rs
··· 15 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 16 use super::formatting; 17 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 18 - use super::offset_map::SnapDirection; 19 use super::paragraph::ParagraphRender; 20 use super::platform; 21 #[allow(unused_imports)] ··· 45 use jacquard::types::ident::AtIdentifier; 46 use weaver_api::sh_weaver::embed::images::Image; 47 use weaver_common::WeaverExt; 48 49 /// Result of loading document state. 50 enum LoadResult { ··· 1868 position: usize, 1869 selection: Option<(usize, usize)>, 1870 color: u32, 1871 - offset_map: Vec<super::offset_map::OffsetMapping>, 1872 ) -> Element { 1873 use super::cursor::{get_cursor_rect_relative, get_selection_rects_relative}; 1874
··· 15 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 16 use super::formatting; 17 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 18 use super::paragraph::ParagraphRender; 19 use super::platform; 20 #[allow(unused_imports)] ··· 44 use jacquard::types::ident::AtIdentifier; 45 use weaver_api::sh_weaver::embed::images::Image; 46 use weaver_common::WeaverExt; 47 + use weaver_editor_core::SnapDirection; 48 49 /// Result of loading document state. 50 enum LoadResult { ··· 1868 position: usize, 1869 selection: Option<(usize, usize)>, 1870 color: u32, 1871 + offset_map: Vec<weaver_editor_core::OffsetMapping>, 1872 ) -> Element { 1873 use super::cursor::{get_cursor_rect_relative, get_selection_rects_relative}; 1874
+2 -2
crates/weaver-app/src/components/editor/cursor.rs
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 10 - use super::offset_map::OffsetMapping; 11 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 12 - use super::offset_map::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 13 14 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 15 use wasm_bindgen::JsCast;
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 10 + use weaver_editor_core::OffsetMapping; 11 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 12 + use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 13 14 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 15 use wasm_bindgen::JsCast;
+1 -1
crates/weaver-app/src/components/editor/document.rs
··· 144 145 /// Pending snap direction for cursor restoration after edits. 146 /// Set by input handlers, consumed by cursor restoration. 147 - pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>, 148 149 /// Collected refs (wikilinks, AT embeds) from the most recent render. 150 /// Updated by the render pipeline, read by publish for populating records.
··· 144 145 /// Pending snap direction for cursor restoration after edits. 146 /// Set by input handlers, consumed by cursor restoration. 147 + pub pending_snap: Signal<Option<weaver_editor_core::SnapDirection>>, 148 149 /// Collected refs (wikilinks, AT embeds) from the most recent render. 150 /// Updated by the render pipeline, read by publish for populating records.
+1 -1
crates/weaver-app/src/components/editor/dom_sync.rs
··· 8 #[allow(unused_imports)] 9 use super::document::{EditorDocument, Selection}; 10 #[allow(unused_imports)] 11 - use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 12 use super::paragraph::ParagraphRender; 13 #[allow(unused_imports)] 14 use dioxus::prelude::*;
··· 8 #[allow(unused_imports)] 9 use super::document::{EditorDocument, Selection}; 10 #[allow(unused_imports)] 11 + use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 12 use super::paragraph::ParagraphRender; 13 #[allow(unused_imports)] 14 use dioxus::prelude::*;
+1 -1
crates/weaver-app/src/components/editor/input.rs
··· 6 7 use super::document::EditorDocument; 8 use super::formatting::{self, FormatAction}; 9 - use super::offset_map::SnapDirection; 10 11 /// Check if we need to intercept this key event. 12 /// Returns true for content-modifying operations, false for navigation.
··· 6 7 use super::document::EditorDocument; 8 use super::formatting::{self, FormatAction}; 9 + use weaver_editor_core::SnapDirection; 10 11 /// Check if we need to intercept this key event. 12 /// Returns true for content-modifying operations, false for navigation.
+7 -4
crates/weaver-app/src/components/editor/mod.rs
··· 15 mod image_upload; 16 mod input; 17 mod log_buffer; 18 - mod offset_map; 19 mod paragraph; 20 mod platform; 21 mod publish; ··· 54 #[allow(unused_imports)] 55 pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 56 57 - // Rendering 58 #[allow(unused_imports)] 59 - pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 60 #[allow(unused_imports)] 61 pub use paragraph::ParagraphRender; 62 #[allow(unused_imports)] 63 pub use render::{RenderCache, render_paragraphs_incremental}; 64 #[allow(unused_imports)] 65 - pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, WriterResult}; 66 67 // Storage 68 #[allow(unused_imports)]
··· 15 mod image_upload; 16 mod input; 17 mod log_buffer; 18 mod paragraph; 19 mod platform; 20 mod publish; ··· 53 #[allow(unused_imports)] 54 pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 55 56 + // Rendering - re-export core types 57 #[allow(unused_imports)] 58 + pub use weaver_editor_core::{ 59 + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, RenderResult, 60 + SegmentedWriter, SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, find_mapping_for_byte, 61 + }; 62 #[allow(unused_imports)] 63 pub use paragraph::ParagraphRender; 64 #[allow(unused_imports)] 65 pub use render::{RenderCache, render_paragraphs_incremental}; 66 + // App-specific image resolver 67 #[allow(unused_imports)] 68 + pub use writer::embed::EditorImageResolver; 69 70 // Storage 71 #[allow(unused_imports)]
-530
crates/weaver-app/src/components/editor/offset_map.rs
··· 1 - //! Offset mapping between source text and rendered DOM. 2 - //! 3 - //! When rendering markdown to HTML, some characters disappear (table pipes) 4 - //! and content gets split across nodes (syntax highlighting). Offset maps 5 - //! track how source byte positions map to DOM node positions. 6 - 7 - use std::ops::Range; 8 - 9 - /// Result of rendering markdown with offset tracking. 10 - #[derive(Debug, Clone, PartialEq)] 11 - pub struct RenderResult { 12 - /// Rendered HTML string 13 - pub html: String, 14 - 15 - /// Mappings from source bytes to DOM positions 16 - pub offset_map: Vec<OffsetMapping>, 17 - } 18 - 19 - /// Maps a source range to a position in the rendered DOM. 20 - /// 21 - /// # Example 22 - /// 23 - /// Source: `| foo | bar |` 24 - /// Bytes: 0 2-5 7-10 12 25 - /// Chars: 0 2-5 7-10 12 (ASCII, so same) 26 - /// 27 - /// Rendered: 28 - /// ```html 29 - /// <table id="t0"> 30 - /// <tr><td id="t0-c0">foo</td><td id="t0-c1">bar</td></tr> 31 - /// </table> 32 - /// ``` 33 - /// 34 - /// Mappings: 35 - /// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible 36 - /// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible 37 - /// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible 38 - /// - etc. 39 - #[derive(Debug, Clone, PartialEq)] 40 - pub struct OffsetMapping { 41 - /// Source byte range (UTF-8 bytes, from parser) 42 - pub byte_range: Range<usize>, 43 - 44 - /// Source char range (Unicode scalar values, for rope indexing) 45 - pub char_range: Range<usize>, 46 - 47 - /// DOM node ID containing this content 48 - /// For invisible content, this is the nearest visible container 49 - pub node_id: String, 50 - 51 - /// Position within the node 52 - /// - If child_index is Some: cursor at that child index in the element 53 - /// - If child_index is None: UTF-16 offset in text content 54 - pub char_offset_in_node: usize, 55 - 56 - /// If Some, position cursor at this child index in the element (not in text) 57 - /// Used for positions after <br /> or at empty lines 58 - pub child_index: Option<usize>, 59 - 60 - /// Length of this mapping in UTF-16 chars in DOM 61 - /// If 0, these source bytes aren't rendered (table pipes, etc) 62 - pub utf16_len: usize, 63 - } 64 - 65 - impl OffsetMapping { 66 - /// Check if this mapping contains the given byte offset 67 - pub fn contains_byte(&self, byte_offset: usize) -> bool { 68 - self.byte_range.contains(&byte_offset) 69 - } 70 - 71 - /// Check if this mapping contains the given char offset 72 - pub fn contains_char(&self, char_offset: usize) -> bool { 73 - self.char_range.contains(&char_offset) 74 - } 75 - 76 - /// Check if this mapping represents invisible content 77 - pub fn is_invisible(&self) -> bool { 78 - self.utf16_len == 0 79 - } 80 - } 81 - 82 - /// Find the offset mapping containing the given byte offset. 83 - /// 84 - /// Returns the mapping and whether the cursor should snap to the next 85 - /// visible position (for invisible content). 86 - pub fn find_mapping_for_byte( 87 - offset_map: &[OffsetMapping], 88 - byte_offset: usize, 89 - ) -> Option<(&OffsetMapping, bool)> { 90 - // Binary search for the mapping 91 - // Note: We allow cursor at the end boundary of a mapping (cursor after text) 92 - let idx = offset_map 93 - .binary_search_by(|mapping| { 94 - if mapping.byte_range.end < byte_offset { 95 - std::cmp::Ordering::Less 96 - } else if mapping.byte_range.start > byte_offset { 97 - std::cmp::Ordering::Greater 98 - } else { 99 - std::cmp::Ordering::Equal 100 - } 101 - }) 102 - .ok()?; 103 - 104 - let mapping = &offset_map[idx]; 105 - let should_snap = mapping.is_invisible(); 106 - 107 - Some((mapping, should_snap)) 108 - } 109 - 110 - /// Find the offset mapping containing the given char offset. 111 - /// 112 - /// This is the primary lookup method for cursor restoration, since 113 - /// cursor positions are tracked as char offsets in the rope. 114 - /// 115 - /// Returns the mapping and whether the cursor should snap to the next 116 - /// visible position (for invisible content). 117 - #[allow(dead_code)] 118 - pub fn find_mapping_for_char( 119 - offset_map: &[OffsetMapping], 120 - char_offset: usize, 121 - ) -> Option<(&OffsetMapping, bool)> { 122 - // Binary search for the mapping 123 - // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. 124 - // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), 125 - // prefer the NEXT mapping so cursor goes "down" to new content. 126 - let result = offset_map.binary_search_by(|mapping| { 127 - if mapping.char_range.end <= char_offset { 128 - // Cursor is at or after end of this mapping - look forward 129 - std::cmp::Ordering::Less 130 - } else if mapping.char_range.start > char_offset { 131 - // Cursor is before this mapping 132 - std::cmp::Ordering::Greater 133 - } else { 134 - // Cursor is within [start, end) 135 - std::cmp::Ordering::Equal 136 - } 137 - }); 138 - 139 - let mapping = match result { 140 - Ok(idx) => &offset_map[idx], 141 - Err(idx) => { 142 - // No exact match - cursor is at boundary between mappings (or past end) 143 - // If cursor is exactly at end of previous mapping, return that mapping 144 - // This handles cursor at end of document or end of last mapping 145 - if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { 146 - &offset_map[idx - 1] 147 - } else { 148 - return None; 149 - } 150 - } 151 - }; 152 - 153 - let should_snap = mapping.is_invisible(); 154 - Some((mapping, should_snap)) 155 - } 156 - 157 - /// Direction hint for cursor snapping. 158 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 159 - pub enum SnapDirection { 160 - Backward, 161 - Forward, 162 - } 163 - 164 - /// Result of finding a valid cursor position. 165 - #[derive(Debug, Clone)] 166 - #[allow(dead_code)] 167 - pub struct SnappedPosition<'a> { 168 - pub mapping: &'a OffsetMapping, 169 - pub offset_in_mapping: usize, 170 - pub snapped: Option<SnapDirection>, 171 - } 172 - 173 - #[allow(dead_code)] 174 - impl SnappedPosition<'_> { 175 - /// Get the absolute char offset for this position. 176 - pub fn char_offset(&self) -> usize { 177 - self.mapping.char_range.start + self.offset_in_mapping 178 - } 179 - } 180 - 181 - /// Find the nearest valid cursor position to a char offset. 182 - /// 183 - /// A valid position is one that maps to visible content (utf16_len > 0). 184 - /// If the position is already valid, returns it directly. Otherwise, 185 - /// searches in the preferred direction first, falling back to the other 186 - /// direction if needed. 187 - #[allow(dead_code)] 188 - pub fn find_nearest_valid_position( 189 - offset_map: &[OffsetMapping], 190 - char_offset: usize, 191 - preferred_direction: Option<SnapDirection>, 192 - ) -> Option<SnappedPosition<'_>> { 193 - if offset_map.is_empty() { 194 - return None; 195 - } 196 - 197 - // Try exact match first 198 - if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { 199 - if !should_snap { 200 - // Position is valid, return it directly 201 - let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); 202 - return Some(SnappedPosition { 203 - mapping, 204 - offset_in_mapping, 205 - snapped: None, 206 - }); 207 - } 208 - } 209 - 210 - // Position is invalid or not found - search for nearest valid 211 - let search_order = match preferred_direction { 212 - Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], 213 - Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], 214 - }; 215 - 216 - for direction in search_order { 217 - if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { 218 - return Some(pos); 219 - } 220 - } 221 - 222 - None 223 - } 224 - 225 - /// Search for a valid position in a specific direction. 226 - #[allow(dead_code)] 227 - fn find_valid_in_direction( 228 - offset_map: &[OffsetMapping], 229 - char_offset: usize, 230 - direction: SnapDirection, 231 - ) -> Option<SnappedPosition<'_>> { 232 - match direction { 233 - SnapDirection::Forward => { 234 - // Find first visible mapping at or after char_offset 235 - for mapping in offset_map { 236 - if mapping.char_range.start >= char_offset && !mapping.is_invisible() { 237 - return Some(SnappedPosition { 238 - mapping, 239 - offset_in_mapping: 0, 240 - snapped: Some(SnapDirection::Forward), 241 - }); 242 - } 243 - // Also check if char_offset falls within this visible mapping 244 - if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 245 - let offset_in_mapping = char_offset - mapping.char_range.start; 246 - return Some(SnappedPosition { 247 - mapping, 248 - offset_in_mapping, 249 - snapped: Some(SnapDirection::Forward), 250 - }); 251 - } 252 - } 253 - None 254 - } 255 - SnapDirection::Backward => { 256 - // Find last visible mapping at or before char_offset 257 - for mapping in offset_map.iter().rev() { 258 - if mapping.char_range.end <= char_offset && !mapping.is_invisible() { 259 - // Snap to end of this mapping 260 - let offset_in_mapping = mapping.char_range.len(); 261 - return Some(SnappedPosition { 262 - mapping, 263 - offset_in_mapping, 264 - snapped: Some(SnapDirection::Backward), 265 - }); 266 - } 267 - // Also check if char_offset falls within this visible mapping 268 - if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 269 - let offset_in_mapping = char_offset - mapping.char_range.start; 270 - return Some(SnappedPosition { 271 - mapping, 272 - offset_in_mapping, 273 - snapped: Some(SnapDirection::Backward), 274 - }); 275 - } 276 - } 277 - None 278 - } 279 - } 280 - } 281 - 282 - /// Check if a char offset is at a valid (non-invisible) cursor position. 283 - #[allow(dead_code)] 284 - pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 285 - find_mapping_for_char(offset_map, char_offset) 286 - .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) 287 - .unwrap_or(false) 288 - } 289 - 290 - #[cfg(test)] 291 - mod tests { 292 - use super::*; 293 - 294 - #[test] 295 - fn test_find_mapping_by_byte() { 296 - let mappings = vec![ 297 - OffsetMapping { 298 - byte_range: 0..2, 299 - char_range: 0..2, 300 - node_id: "n0".to_string(), 301 - char_offset_in_node: 0, 302 - child_index: None, 303 - utf16_len: 0, // invisible 304 - }, 305 - OffsetMapping { 306 - byte_range: 2..5, 307 - char_range: 2..5, 308 - node_id: "n0".to_string(), 309 - char_offset_in_node: 0, 310 - child_index: None, 311 - utf16_len: 3, 312 - }, 313 - OffsetMapping { 314 - byte_range: 5..7, 315 - char_range: 5..7, 316 - node_id: "n0".to_string(), 317 - char_offset_in_node: 3, 318 - child_index: None, 319 - utf16_len: 0, // invisible 320 - }, 321 - ]; 322 - 323 - // Byte 0 (invisible) 324 - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); 325 - assert_eq!(mapping.byte_range, 0..2); 326 - assert!(should_snap); 327 - 328 - // Byte 3 (visible) 329 - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); 330 - assert_eq!(mapping.byte_range, 2..5); 331 - assert!(!should_snap); 332 - 333 - // Byte 6 (invisible) 334 - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); 335 - assert_eq!(mapping.byte_range, 5..7); 336 - assert!(should_snap); 337 - } 338 - 339 - #[test] 340 - fn test_find_mapping_by_char() { 341 - let mappings = vec![ 342 - OffsetMapping { 343 - byte_range: 0..2, 344 - char_range: 0..2, 345 - node_id: "n0".to_string(), 346 - char_offset_in_node: 0, 347 - child_index: None, 348 - utf16_len: 0, // invisible 349 - }, 350 - OffsetMapping { 351 - byte_range: 2..5, 352 - char_range: 2..5, 353 - node_id: "n0".to_string(), 354 - char_offset_in_node: 0, 355 - child_index: None, 356 - utf16_len: 3, 357 - }, 358 - OffsetMapping { 359 - byte_range: 5..7, 360 - char_range: 5..7, 361 - node_id: "n0".to_string(), 362 - char_offset_in_node: 3, 363 - child_index: None, 364 - utf16_len: 0, // invisible 365 - }, 366 - ]; 367 - 368 - // Char 0 (invisible) 369 - let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); 370 - assert_eq!(mapping.char_range, 0..2); 371 - assert!(should_snap); 372 - 373 - // Char 3 (visible) 374 - let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); 375 - assert_eq!(mapping.char_range, 2..5); 376 - assert!(!should_snap); 377 - 378 - // Char 6 (invisible) 379 - let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); 380 - assert_eq!(mapping.char_range, 5..7); 381 - assert!(should_snap); 382 - } 383 - 384 - #[test] 385 - fn test_contains_byte() { 386 - let mapping = OffsetMapping { 387 - byte_range: 10..20, 388 - char_range: 10..20, 389 - node_id: "test".to_string(), 390 - char_offset_in_node: 0, 391 - child_index: None, 392 - utf16_len: 5, 393 - }; 394 - 395 - assert!(!mapping.contains_byte(9)); 396 - assert!(mapping.contains_byte(10)); 397 - assert!(mapping.contains_byte(15)); 398 - assert!(mapping.contains_byte(19)); 399 - assert!(!mapping.contains_byte(20)); 400 - } 401 - 402 - #[test] 403 - fn test_contains_char() { 404 - let mapping = OffsetMapping { 405 - byte_range: 10..20, 406 - char_range: 8..15, // emoji example: fewer chars than bytes 407 - node_id: "test".to_string(), 408 - char_offset_in_node: 0, 409 - child_index: None, 410 - utf16_len: 5, 411 - }; 412 - 413 - assert!(!mapping.contains_char(7)); 414 - assert!(mapping.contains_char(8)); 415 - assert!(mapping.contains_char(12)); 416 - assert!(mapping.contains_char(14)); 417 - assert!(!mapping.contains_char(15)); 418 - } 419 - 420 - fn make_test_mappings() -> Vec<OffsetMapping> { 421 - vec![ 422 - OffsetMapping { 423 - byte_range: 0..2, 424 - char_range: 0..2, 425 - node_id: "n0".to_string(), 426 - char_offset_in_node: 0, 427 - child_index: None, 428 - utf16_len: 0, // invisible: "![" 429 - }, 430 - OffsetMapping { 431 - byte_range: 2..5, 432 - char_range: 2..5, 433 - node_id: "n0".to_string(), 434 - char_offset_in_node: 0, 435 - child_index: None, 436 - utf16_len: 3, // visible: "alt" 437 - }, 438 - OffsetMapping { 439 - byte_range: 5..15, 440 - char_range: 5..15, 441 - node_id: "n0".to_string(), 442 - char_offset_in_node: 3, 443 - child_index: None, 444 - utf16_len: 0, // invisible: "](url.png)" 445 - }, 446 - OffsetMapping { 447 - byte_range: 15..20, 448 - char_range: 15..20, 449 - node_id: "n0".to_string(), 450 - char_offset_in_node: 3, 451 - child_index: None, 452 - utf16_len: 5, // visible: " text" 453 - }, 454 - ] 455 - } 456 - 457 - #[test] 458 - fn test_find_nearest_valid_position_exact_match() { 459 - let mappings = make_test_mappings(); 460 - 461 - // Position 3 is in visible mapping (2..5) 462 - let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); 463 - assert_eq!(pos.char_offset(), 3); 464 - assert!(pos.snapped.is_none()); 465 - } 466 - 467 - #[test] 468 - fn test_find_nearest_valid_position_snap_forward() { 469 - let mappings = make_test_mappings(); 470 - 471 - // Position 0 is invisible, should snap forward to 2 472 - let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); 473 - assert_eq!(pos.char_offset(), 2); 474 - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 475 - } 476 - 477 - #[test] 478 - fn test_find_nearest_valid_position_snap_backward() { 479 - let mappings = make_test_mappings(); 480 - 481 - // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) 482 - let pos = 483 - find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 484 - assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 485 - assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 486 - } 487 - 488 - #[test] 489 - fn test_find_nearest_valid_position_default_forward() { 490 - let mappings = make_test_mappings(); 491 - 492 - // Position 0 is invisible, None direction defaults to forward 493 - let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); 494 - assert_eq!(pos.char_offset(), 2); 495 - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 496 - } 497 - 498 - #[test] 499 - fn test_find_nearest_valid_position_snap_forward_from_invisible() { 500 - let mappings = make_test_mappings(); 501 - 502 - // Position 10 is in invisible range (5..15), forward finds visible (15..20) 503 - let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); 504 - assert_eq!(pos.char_offset(), 15); 505 - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 506 - } 507 - 508 - #[test] 509 - fn test_is_valid_cursor_position() { 510 - let mappings = make_test_mappings(); 511 - 512 - // Invisible positions 513 - assert!(!is_valid_cursor_position(&mappings, 0)); 514 - assert!(!is_valid_cursor_position(&mappings, 1)); 515 - assert!(!is_valid_cursor_position(&mappings, 10)); 516 - 517 - // Visible positions 518 - assert!(is_valid_cursor_position(&mappings, 2)); 519 - assert!(is_valid_cursor_position(&mappings, 3)); 520 - assert!(is_valid_cursor_position(&mappings, 4)); 521 - assert!(is_valid_cursor_position(&mappings, 15)); 522 - assert!(is_valid_cursor_position(&mappings, 17)); 523 - } 524 - 525 - #[test] 526 - fn test_find_nearest_valid_position_empty() { 527 - let mappings: Vec<OffsetMapping> = vec![]; 528 - assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); 529 - } 530 - }
···
+1 -2
crates/weaver-app/src/components/editor/paragraph.rs
··· 3 //! Paragraphs are discovered during markdown rendering by tracking 4 //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. 5 6 - use super::offset_map::OffsetMapping; 7 - use super::writer::SyntaxSpanInfo; 8 use loro::LoroText; 9 use std::ops::Range; 10 11 /// A rendered paragraph with its source range and offset mappings. 12 #[derive(Debug, Clone, PartialEq)]
··· 3 //! Paragraphs are discovered during markdown rendering by tracking 4 //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. 5 6 use loro::LoroText; 7 use std::ops::Range; 8 + use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; 9 10 /// A rendered paragraph with its source range and offset mappings. 11 #[derive(Debug, Clone, PartialEq)]
+18 -21
crates/weaver-app/src/components/editor/render.rs
··· 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 7 use super::document::EditInfo; 8 - #[allow(unused_imports)] 9 - use super::offset_map::{OffsetMapping, RenderResult}; 10 use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 11 - #[allow(unused_imports)] 12 - use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; 13 use loro::LoroText; 14 use markdown_weaver::Parser; 15 use std::ops::Range; 16 use weaver_common::{EntryIndex, ResolvedContent}; 17 18 /// Cache for incremental paragraph rendering. 19 /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. ··· 413 let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 414 .into_offset_iter(); 415 416 - let para_doc = loro::LoroDoc::new(); 417 - let para_text = para_doc.get_text("content"); 418 - let _ = para_text.insert(0, &para_source); 419 420 - let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( 421 - &para_source, 422 - &para_text, 423 - parser, 424 - ) 425 - .with_node_id_prefix(&cached_para.id) 426 - .with_image_resolver(&resolver) 427 - .with_embed_provider(resolved_content); 428 429 if let Some(idx) = entry_index { 430 writer = writer.with_entry_index(idx); ··· 613 // Use provided resolver or empty default 614 let resolver = image_resolver.cloned().unwrap_or_default(); 615 616 - // Create a temporary LoroText for the slice (needed by writer) 617 - let slice_doc = loro::LoroDoc::new(); 618 - let slice_text = slice_doc.get_text("content"); 619 - let _ = slice_text.insert(0, parse_slice); 620 621 // Determine starting paragraph ID for freshly parsed paragraphs 622 // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML ··· 665 }); 666 667 // Build writer with all resolvers and auto-incrementing paragraph prefixes 668 - let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( 669 parse_slice, 670 - &slice_text, 671 parser, 672 ) 673 .with_auto_incrementing_prefix(parsed_para_id_start)
··· 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 7 use super::document::EditInfo; 8 use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 9 + use super::writer::embed::EditorImageResolver; 10 use loro::LoroText; 11 use markdown_weaver::Parser; 12 use std::ops::Range; 13 use weaver_common::{EntryIndex, ResolvedContent}; 14 + use weaver_editor_core::{ 15 + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SyntaxSpanInfo, 16 + }; 17 18 /// Cache for incremental paragraph rendering. 19 /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. ··· 413 let parser = Parser::new_ext(&para_source, weaver_renderer::default_md_options()) 414 .into_offset_iter(); 415 416 + let para_rope = EditorRope::from(para_source.as_str()); 417 418 + let mut writer = 419 + EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( 420 + &para_source, 421 + &para_rope, 422 + parser, 423 + ) 424 + .with_node_id_prefix(&cached_para.id) 425 + .with_image_resolver(&resolver) 426 + .with_embed_provider(resolved_content); 427 428 if let Some(idx) = entry_index { 429 writer = writer.with_entry_index(idx); ··· 612 // Use provided resolver or empty default 613 let resolver = image_resolver.cloned().unwrap_or_default(); 614 615 + // Create EditorRope for efficient offset conversions 616 + let slice_rope = EditorRope::from(parse_slice); 617 618 // Determine starting paragraph ID for freshly parsed paragraphs 619 // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML ··· 662 }); 663 664 // Build writer with all resolvers and auto-incrementing paragraph prefixes 665 + let mut writer = EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( 666 parse_slice, 667 + &slice_rope, 668 parser, 669 ) 670 .with_auto_incrementing_prefix(parsed_para_id_start)
+2 -21
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
··· 8 char_range: 9 - 0 10 - 18 11 - html: "<blockquote>\n<p id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span> This is a quote\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 2 ··· 62 utf16_len: 1 63 source_hash: 11268153476336590706 64 - byte_range: 65 - - 18 66 - - 22 67 - char_range: 68 - - 18 69 - - 22 70 - html: "<span id=\"gap-20-22\">​</span>" 71 - offset_map: 72 - - byte_range: 73 - - 18 74 - - 22 75 - char_range: 76 - - 18 77 - - 22 78 - node_id: gap-20-22 79 - char_offset_in_node: 0 80 - child_index: ~ 81 - utf16_len: 1 82 - source_hash: 6078396554664461735 83 - - byte_range: 84 - 22 85 - 41 86 char_range: 87 - 22 88 - 41 89 - html: "<span id=\"p-1-n0\" class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"18\" data-char-end=\"22\">&gt;\n&gt; </span></span>\n<p id=\"p-1-n1\" dir=\"ltr\">With multiple lines</p>\n" 90 offset_map: 91 - byte_range: 92 - 18
··· 8 char_range: 9 - 0 10 - 18 11 + html: "<blockquote><p id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span> This is a quote\n</p>" 12 offset_map: 13 - byte_range: 14 - 2 ··· 62 utf16_len: 1 63 source_hash: 11268153476336590706 64 - byte_range: 65 - 22 66 - 41 67 char_range: 68 - 22 69 - 41 70 + html: "<span id=\"p-1-n0\" class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"18\" data-char-end=\"22\">&gt;\n&gt; </span></span><p id=\"p-1-n1\" dir=\"ltr\">With multiple lines</p>" 71 offset_map: 72 - byte_range: 73 - 18
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap
··· 8 char_range: 9 - 0 10 - 18 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"7\">**</span><strong>bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">**</span> text</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 18 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"7\">**</span><strong>bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\">**</span> text</p>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap
··· 8 char_range: 9 - 0 10 - 27 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span><strong>bold italic</strong><span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"21\">**</span></em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">*</span> text</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 27 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span><strong>bold italic</strong><span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"21\">**</span></em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">*</span> text</p>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap
··· 8 char_range: 9 - 0 10 - 24 11 - html: "<span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"8\" spellcheck=\"false\">```rust</span>\n<pre data-node-id=\"p-0-n0\"><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"21\" data-char-end=\"24\" spellcheck=\"false\">```</span>" 12 offset_map: 13 - byte_range: 14 - 8
··· 8 char_range: 9 - 0 10 - 24 11 + html: "<span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"8\" spellcheck=\"false\">```rust</span><pre data-node-id=\"p-0-n0\"><code class=\"wvc-code language-Rust\"><span class=\"wvc-source wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-storage wvc-type wvc-function wvc-rust\">fn</span> </span><span class=\"wvc-entity wvc-name wvc-function wvc-rust\">main</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust\">(</span></span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-function wvc-parameters wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust\">)</span></span></span></span><span class=\"wvc-meta wvc-function wvc-rust\"> </span><span class=\"wvc-meta wvc-function wvc-rust\"><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust\">{</span></span><span class=\"wvc-meta wvc-block wvc-rust\"><span class=\"wvc-punctuation wvc-section wvc-block wvc-end wvc-rust\">}</span></span></span>\n</span></code></pre><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"21\" data-char-end=\"24\" spellcheck=\"false\">```</span>" 12 offset_map: 13 - byte_range: 14 - 8
+2 -2
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
··· 8 char_range: 9 - 0 10 - 10 11 - html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading\n</h1>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 57 char_range: 58 - 11 59 - 26 60 - html: "<span id=\"p-1-n0\">\n</span>\n<p id=\"p-1-n1\" dir=\"ltr\">Paragraph below</p>\n" 61 offset_map: 62 - byte_range: 63 - 10
··· 8 char_range: 9 - 0 10 - 10 11 + html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading\n</h1>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 57 char_range: 58 - 11 59 - 26 60 + html: "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">Paragraph below</p>" 61 offset_map: 62 - byte_range: 63 - 10
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
··· 8 char_range: 9 - 0 10 - 19 11 - html: "<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>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 19 11 + html: "<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>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap
··· 8 char_range: 9 - 0 10 - 11 11 - html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading 1</h1>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 11 11 + html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading 1</h1>" 12 offset_map: 13 - byte_range: 14 - 0
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
··· 8 char_range: 9 - 0 10 - 5 11 - html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>H1\n</h1>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 57 char_range: 58 - 6 59 - 12 60 - html: "<span id=\"p-1-n0\">\n</span>\n<h2 data-node-id=\"p-1-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"9\">## </span>H2\n</h2>\n" 61 offset_map: 62 - byte_range: 63 - 5 ··· 116 char_range: 117 - 13 118 - 20 119 - html: "<span id=\"p-2-n0\">\n</span>\n<h3 data-node-id=\"p-2-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"17\">### </span>H3\n</h3>\n" 120 offset_map: 121 - byte_range: 122 - 12 ··· 175 char_range: 176 - 21 177 - 28 178 - html: "<span id=\"p-3-n0\">\n</span>\n<h4 data-node-id=\"p-3-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"26\">#### </span>H4</h4>\n" 179 offset_map: 180 - byte_range: 181 - 20
··· 8 char_range: 9 - 0 10 - 5 11 + html: "<h1 data-node-id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>H1\n</h1>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 57 char_range: 58 - 6 59 - 12 60 + html: "<span id=\"p-1-n0\">\n</span>\n<h2 data-node-id=\"p-1-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"9\">## </span>H2\n</h2>" 61 offset_map: 62 - byte_range: 63 - 5 ··· 116 char_range: 117 - 13 118 - 20 119 + html: "<span id=\"p-2-n0\">\n</span>\n<h3 data-node-id=\"p-2-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"17\">### </span>H3\n</h3>" 120 offset_map: 121 - byte_range: 122 - 12 ··· 175 char_range: 176 - 21 177 - 28 178 + html: "<span id=\"p-3-n0\">\n</span>\n<h4 data-node-id=\"p-3-n1\" dir=\"ltr\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"26\">#### </span>H4</h4>" 179 offset_map: 180 - byte_range: 181 - 20
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap
··· 8 char_range: 9 - 0 10 - 16 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"10\" data-char-end=\"11\" spellcheck=\"false\">`</span> here</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 16 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"10\" data-char-end=\"11\" spellcheck=\"false\">`</span> here</p>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap
··· 8 char_range: 9 - 0 10 - 18 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"13\">*</span> text</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 18 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Some <span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"13\">*</span> text</p>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap
··· 8 char_range: 9 - 0 10 - 16 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello 你好 world 🎉</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 16 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello 你好 world 🎉</p>" 12 offset_map: 13 - byte_range: 14 - 0
+2 -21
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
··· 8 char_range: 9 - 0 10 - 6 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">First\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 42 utf16_len: 1 43 source_hash: 6799294212516041738 44 - byte_range: 45 - - 6 46 - - 9 47 - char_range: 48 - - 6 49 - - 9 50 - html: "<span id=\"gap-8-9\">​</span>" 51 - offset_map: 52 - - byte_range: 53 - - 6 54 - - 9 55 - char_range: 56 - - 6 57 - - 9 58 - node_id: gap-8-9 59 - char_offset_in_node: 0 60 - child_index: ~ 61 - utf16_len: 1 62 - source_hash: 4789919281594481934 63 - - byte_range: 64 - 9 65 - 15 66 char_range: 67 - 9 68 - 15 69 - html: "<span id=\"p-1-n0\">\n\n\n</span>\n<p id=\"p-1-n1\" dir=\"ltr\">Second</p>\n" 70 offset_map: 71 - byte_range: 72 - 6
··· 8 char_range: 9 - 0 10 - 6 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">First\n</p>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 42 utf16_len: 1 43 source_hash: 6799294212516041738 44 - byte_range: 45 - 9 46 - 15 47 char_range: 48 - 9 49 - 15 50 + html: "<span id=\"p-1-n0\">\n\n\n</span><p id=\"p-1-n1\" dir=\"ltr\">Second</p>" 51 offset_map: 52 - byte_range: 53 - 6
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap
··· 8 char_range: 9 - 0 10 - 32 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\" spellcheck=\"false\">`</span></p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 32 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\"><span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">**</span><strong>Bold</strong><span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"6\" data-char-end=\"8\">**</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s2\" data-char-start=\"13\" data-char-end=\"14\">*</span><em>italic</em><span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"20\" data-char-end=\"21\">*</span> and <span class=\"md-syntax-inline\" data-syn-id=\"s4\" data-char-start=\"26\" data-char-end=\"27\" spellcheck=\"false\">`</span><code>code</code><span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"31\" data-char-end=\"32\" spellcheck=\"false\">`</span></p>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -20
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
··· 3 expression: result 4 --- 5 - byte_range: 6 - - 0 7 - - 11 8 - char_range: 9 - - 0 10 - - 11 11 - html: "<span id=\"gap-2-11\">​</span>" 12 - offset_map: 13 - - byte_range: 14 - - 0 15 - - 11 16 - char_range: 17 - - 0 18 - - 11 19 - node_id: gap-2-11 20 - char_offset_in_node: 0 21 - child_index: ~ 22 - utf16_len: 1 23 - source_hash: 10732713550789592057 24 - - byte_range: 25 - 11 26 - 33 27 char_range: 28 - 11 29 - 33 30 - html: "<ul>\n<li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\" spellcheck=\"false\">- </span>Parent\n \n<ul>\n<li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\" spellcheck=\"false\">- </span>Child 1\n</li>\n<span id=\"p-0-n2\"> </span>\n<li data-node-id=\"p-0-n3\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"23\" data-char-end=\"25\" spellcheck=\"false\">- </span>Child 2\n</li>\n</ul>\n" 31 offset_map: 32 - byte_range: 33 - 0
··· 3 expression: result 4 --- 5 - byte_range: 6 - 11 7 - 33 8 char_range: 9 - 11 10 - 33 11 + html: "<ul><li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\" spellcheck=\"false\">- </span>Parent\n <ul><li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"11\" data-char-end=\"13\" spellcheck=\"false\">- </span>Child 1\n</li><span id=\"p-0-n2\"> </span><li data-node-id=\"p-0-n3\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"23\" data-char-end=\"25\" spellcheck=\"false\">- </span>Child 2\n</li></ul>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -19
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap
··· 2 source: crates/weaver-app/src/components/editor/tests.rs 3 expression: result 4 --- 5 - - byte_range: 6 - - 0 7 - - 3 8 - char_range: 9 - - 0 10 - - 3 11 - html: "<span id=\"gap-0-3\">​</span>" 12 - offset_map: 13 - - byte_range: 14 - - 0 15 - - 3 16 - char_range: 17 - - 0 18 - - 3 19 - node_id: gap-0-3 20 - char_offset_in_node: 0 21 - child_index: ~ 22 - utf16_len: 1 23 - source_hash: 0
··· 2 source: crates/weaver-app/src/components/editor/tests.rs 3 expression: result 4 --- 5 + []
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 8 char_range: 9 - 0 10 - 27 11 - html: "<ol>\n<li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\" spellcheck=\"false\">1. </span>First\n</li>\n<li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\" spellcheck=\"false\">2. </span>Second\n</li>\n<li data-node-id=\"p-0-n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"22\" spellcheck=\"false\">3. </span>Third</li>\n</ol>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 27 11 + html: "<ol><li data-node-id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\" spellcheck=\"false\">1. </span>First\n</li><li data-node-id=\"p-0-n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\" spellcheck=\"false\">2. </span>Second\n</li><li data-node-id=\"p-0-n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"22\" spellcheck=\"false\">3. </span>Third</li></ol>" 12 offset_map: 13 - byte_range: 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap
··· 8 char_range: 9 - 0 10 - 11 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello world</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 11 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello world</p>" 12 offset_map: 13 - byte_range: 14 - 0
+3 -3
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
··· 8 char_range: 9 - 0 10 - 5 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">One.\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 47 char_range: 48 - 6 49 - 11 50 - html: "<span id=\"p-1-n0\">\n</span>\n<p id=\"p-1-n1\" dir=\"ltr\">Two.\n</p>\n" 51 offset_map: 52 - byte_range: 53 - 5 ··· 96 char_range: 97 - 12 98 - 18 99 - html: "<span id=\"p-2-n0\">\n</span>\n<p id=\"p-2-n1\" dir=\"ltr\">Three.</p>\n" 100 offset_map: 101 - byte_range: 102 - 11
··· 8 char_range: 9 - 0 10 - 5 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">One.\n</p>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 47 char_range: 48 - 6 49 - 11 50 + html: "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">Two.\n</p>" 51 offset_map: 52 - byte_range: 53 - 5 ··· 96 char_range: 97 - 12 98 - 18 99 + html: "<span id=\"p-2-n0\">\n</span><p id=\"p-2-n1\" dir=\"ltr\">Three.</p>" 100 offset_map: 101 - byte_range: 102 - 11
+1 -20
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
··· 8 char_range: 9 - 0 10 - 6 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 41 child_index: ~ 42 utf16_len: 1 43 source_hash: 1280328235052234789 44 - - byte_range: 45 - - 6 46 - - 7 47 - char_range: 48 - - 6 49 - - 7 50 - html: "<span id=\"gap-6-7\">​</span>" 51 - offset_map: 52 - - byte_range: 53 - - 6 54 - - 7 55 - char_range: 56 - - 6 57 - - 7 58 - node_id: gap-6-7 59 - char_offset_in_node: 0 60 - child_index: ~ 61 - utf16_len: 1 62 - source_hash: 0
··· 8 char_range: 9 - 0 10 - 6 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello\n</p>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 41 child_index: ~ 42 utf16_len: 1 43 source_hash: 1280328235052234789
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
··· 8 char_range: 9 - 0 10 - 6 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 6 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">Hello\n</p>" 12 offset_map: 13 - byte_range: 14 - 0
+2 -2
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
··· 8 char_range: 9 - 0 10 - 17 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">First paragraph.\n</p>\n" 12 offset_map: 13 - byte_range: 14 - 0 ··· 47 char_range: 48 - 18 49 - 35 50 - html: "<span id=\"p-1-n0\">\n</span>\n<p id=\"p-1-n1\" dir=\"ltr\">Second paragraph.</p>\n" 51 offset_map: 52 - byte_range: 53 - 17
··· 8 char_range: 9 - 0 10 - 17 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">First paragraph.\n</p>" 12 offset_map: 13 - byte_range: 14 - 0 ··· 47 char_range: 48 - 18 49 - 35 50 + html: "<span id=\"p-1-n0\">\n</span><p id=\"p-1-n1\" dir=\"ltr\">Second paragraph.</p>" 51 offset_map: 52 - byte_range: 53 - 17
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap
··· 8 char_range: 9 - 0 10 - 4 11 - html: "<p id=\"p-0-n0\" dir=\"ltr\">你好世界</p>\n" 12 offset_map: 13 - byte_range: 14 - 0
··· 8 char_range: 9 - 0 10 - 4 11 + html: "<p id=\"p-0-n0\" dir=\"ltr\">你好世界</p>" 12 offset_map: 13 - byte_range: 14 - 0
+9 -9
crates/weaver-app/src/components/editor/tests.rs
··· 1 //! Snapshot tests for the markdown editor rendering pipeline. 2 3 - use super::offset_map::{OffsetMapping, find_mapping_for_char}; 4 use super::paragraph::ParagraphRender; 5 use super::render::render_paragraphs_incremental; 6 use loro::LoroDoc; ··· 45 TestOffsetMapping { 46 byte_range: (m.byte_range.start, m.byte_range.end), 47 char_range: (m.char_range.start, m.char_range.end), 48 - node_id: m.node_id.clone(), 49 char_offset_in_node: m.char_offset_in_node, 50 child_index: m.child_index, 51 utf16_len: m.utf16_len, ··· 246 let mappings = vec![OffsetMapping { 247 byte_range: 0..5, 248 char_range: 0..5, 249 - node_id: "n0".to_string(), 250 char_offset_in_node: 0, 251 child_index: None, 252 utf16_len: 5, ··· 264 let mappings = vec![OffsetMapping { 265 byte_range: 0..5, 266 char_range: 0..5, 267 - node_id: "n0".to_string(), 268 char_offset_in_node: 0, 269 child_index: None, 270 utf16_len: 5, ··· 280 let mappings = vec![OffsetMapping { 281 byte_range: 0..10, 282 char_range: 0..10, 283 - node_id: "n0".to_string(), 284 char_offset_in_node: 0, 285 child_index: None, 286 utf16_len: 10, ··· 295 let mappings = vec![OffsetMapping { 296 byte_range: 5..10, 297 char_range: 5..10, 298 - node_id: "n0".to_string(), 299 char_offset_in_node: 0, 300 child_index: None, 301 utf16_len: 5, ··· 311 let mappings = vec![OffsetMapping { 312 byte_range: 0..5, 313 char_range: 0..5, 314 - node_id: "n0".to_string(), 315 char_offset_in_node: 0, 316 child_index: None, 317 utf16_len: 5, ··· 335 let mappings = vec![OffsetMapping { 336 byte_range: 0..2, 337 char_range: 0..2, 338 - node_id: "n0".to_string(), 339 char_offset_in_node: 0, 340 child_index: None, 341 utf16_len: 0, // invisible ··· 726 char_range: 727 - 0 728 - 6 729 - html: "<blockquote>\n<p id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span>quote</p>\n" 730 offset_map: 731 - byte_range: 732 - 1
··· 1 //! Snapshot tests for the markdown editor rendering pipeline. 2 3 + use weaver_editor_core::{OffsetMapping, find_mapping_for_char}; 4 use super::paragraph::ParagraphRender; 5 use super::render::render_paragraphs_incremental; 6 use loro::LoroDoc; ··· 45 TestOffsetMapping { 46 byte_range: (m.byte_range.start, m.byte_range.end), 47 char_range: (m.char_range.start, m.char_range.end), 48 + node_id: m.node_id.to_string(), 49 char_offset_in_node: m.char_offset_in_node, 50 child_index: m.child_index, 51 utf16_len: m.utf16_len, ··· 246 let mappings = vec![OffsetMapping { 247 byte_range: 0..5, 248 char_range: 0..5, 249 + node_id: "n0".into(), 250 char_offset_in_node: 0, 251 child_index: None, 252 utf16_len: 5, ··· 264 let mappings = vec![OffsetMapping { 265 byte_range: 0..5, 266 char_range: 0..5, 267 + node_id: "n0".into(), 268 char_offset_in_node: 0, 269 child_index: None, 270 utf16_len: 5, ··· 280 let mappings = vec![OffsetMapping { 281 byte_range: 0..10, 282 char_range: 0..10, 283 + node_id: "n0".into(), 284 char_offset_in_node: 0, 285 child_index: None, 286 utf16_len: 10, ··· 295 let mappings = vec![OffsetMapping { 296 byte_range: 5..10, 297 char_range: 5..10, 298 + node_id: "n0".into(), 299 char_offset_in_node: 0, 300 child_index: None, 301 utf16_len: 5, ··· 311 let mappings = vec![OffsetMapping { 312 byte_range: 0..5, 313 char_range: 0..5, 314 + node_id: "n0".into(), 315 char_offset_in_node: 0, 316 child_index: None, 317 utf16_len: 5, ··· 335 let mappings = vec![OffsetMapping { 336 byte_range: 0..2, 337 char_range: 0..2, 338 + node_id: "n0".into(), 339 char_offset_in_node: 0, 340 child_index: None, 341 utf16_len: 0, // invisible ··· 726 char_range: 727 - 0 728 - 6 729 + html: "<blockquote><p id=\"p-0-n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span>quote</p>" 730 offset_map: 731 - byte_range: 732 - 1
+5 -3
crates/weaver-app/src/components/editor/visibility.rs
··· 3 //! Implements Obsidian-style formatting character visibility: syntax markers 4 //! are hidden when cursor is not near them, revealed when cursor approaches. 5 6 use super::document::Selection; 7 use super::paragraph::ParagraphRender; 8 use super::writer::{SyntaxSpanInfo, SyntaxType}; ··· 13 #[derive(Debug, Clone, Default)] 14 pub struct VisibilityState { 15 /// Set of syn_ids that should be visible 16 - pub visible_span_ids: HashSet<String>, 17 } 18 19 impl VisibilityState { ··· 253 syntax_type: SyntaxType, 254 ) -> SyntaxSpanInfo { 255 SyntaxSpanInfo { 256 - syn_id: syn_id.to_string(), 257 char_range: start..end, 258 syntax_type, 259 formatted_range: None, ··· 268 formatted_range: Range<usize>, 269 ) -> SyntaxSpanInfo { 270 SyntaxSpanInfo { 271 - syn_id: syn_id.to_string(), 272 char_range: start..end, 273 syntax_type, 274 formatted_range: Some(formatted_range),
··· 3 //! Implements Obsidian-style formatting character visibility: syntax markers 4 //! are hidden when cursor is not near them, revealed when cursor approaches. 5 6 + use weaver_editor_core::SmolStr; 7 + 8 use super::document::Selection; 9 use super::paragraph::ParagraphRender; 10 use super::writer::{SyntaxSpanInfo, SyntaxType}; ··· 15 #[derive(Debug, Clone, Default)] 16 pub struct VisibilityState { 17 /// Set of syn_ids that should be visible 18 + pub visible_span_ids: HashSet<SmolStr>, 19 } 20 21 impl VisibilityState { ··· 255 syntax_type: SyntaxType, 256 ) -> SyntaxSpanInfo { 257 SyntaxSpanInfo { 258 + syn_id: syn_id.into(), 259 char_range: start..end, 260 syntax_type, 261 formatted_range: None, ··· 270 formatted_range: Range<usize>, 271 ) -> SyntaxSpanInfo { 272 SyntaxSpanInfo { 273 + syn_id: syn_id.into(), 274 char_range: start..end, 275 syntax_type, 276 formatted_range: Some(formatted_range),
+12 -1321
crates/weaver-app/src/components/editor/writer.rs
··· 1 - //! HTML writer for markdown editor with visible formatting characters. 2 - //! 3 - //! Based on ClientWriter from weaver-renderer, but modified to preserve 4 - //! formatting characters (**, *, #, etc) wrapped in styled spans. 5 //! 6 - //! Uses Parser::into_offset_iter() to track gaps between events, which 7 - //! represent consumed formatting characters. 8 - pub mod embed; 9 - pub mod segmented; 10 - pub mod syntax; 11 - pub mod tags; 12 - 13 - use super::offset_map::OffsetMapping; 14 - use crate::components::editor::writer::segmented::SegmentedWriter; 15 - pub use embed::{EditorImageResolver, EmbedContentProvider, ImageResolver}; 16 - use loro::LoroText; 17 - use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes}; 18 - use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count}; 19 - use std::collections::HashMap; 20 - use std::fmt; 21 - use std::ops::Range; 22 - pub use syntax::{SyntaxSpanInfo, SyntaxType}; 23 - use weaver_common::EntryIndex; 24 - 25 - /// Result of rendering with the EditorWriter. 26 - #[derive(Debug, Clone)] 27 - pub struct WriterResult { 28 - /// HTML segments, one per paragraph (parallel to paragraph_ranges) 29 - pub html_segments: Vec<String>, 30 - 31 - /// Offset mappings from source to DOM positions, grouped by paragraph 32 - /// Each inner Vec corresponds to a paragraph in html_segments 33 - pub offset_maps_by_paragraph: Vec<Vec<OffsetMapping>>, 34 - 35 - /// Paragraph boundaries in source: (byte_range, char_range) 36 - /// These are extracted during rendering by tracking Tag::Paragraph events 37 - pub paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, 38 - 39 - /// Syntax spans that can be conditionally hidden, grouped by paragraph 40 - pub syntax_spans_by_paragraph: Vec<Vec<SyntaxSpanInfo>>, 41 - 42 - /// Refs (wikilinks, AT embeds) collected during this render pass, grouped by paragraph 43 - pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>, 44 - } 45 - 46 - /// Tracks the type of wrapper element emitted for WeaverBlock prefix 47 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 48 - enum WrapperElement { 49 - Aside, 50 - Div, 51 - } 52 - 53 - #[derive(Debug, Clone, Copy)] 54 - pub enum TableState { 55 - Head, 56 - Body, 57 - } 58 - 59 - /// HTML writer that preserves markdown formatting characters. 60 - /// 61 - /// This writer processes offset-iter events to detect gaps (consumed formatting) 62 - /// and emits them as styled spans for visibility in the editor. 63 - /// 64 - /// Output is segmented by paragraph boundaries - each paragraph's HTML goes into 65 - /// a separate String in the output segments Vec. 66 - pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E = (), R = ()> { 67 - source: &'a str, 68 - source_text: &'a LoroText, 69 - events: I, 70 - writer: SegmentedWriter, 71 - last_byte_offset: usize, 72 - last_char_offset: usize, 73 - 74 - end_newline: bool, 75 - in_non_writing_block: bool, 76 - 77 - table_state: TableState, 78 - table_alignments: Vec<Alignment>, 79 - table_cell_index: usize, 80 - 81 - numbers: HashMap<String, usize>, 82 - 83 - embed_provider: Option<E>, 84 - image_resolver: Option<R>, 85 - entry_index: Option<&'a EntryIndex>, 86 - 87 - code_buffer: Option<(Option<String>, String)>, // (lang, content) 88 - code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content 89 - code_buffer_char_range: Option<Range<usize>>, // char range of buffered code content 90 - code_block_char_start: Option<usize>, // char offset where code block started 91 - code_block_opening_span_idx: Option<usize>, // index of opening fence syntax span 92 - pending_blockquote_range: Option<Range<usize>>, // range for emitting > inside next paragraph 93 - 94 - // Table rendering mode 95 - render_tables_as_markdown: bool, 96 - table_start_offset: Option<usize>, // track start of table for markdown rendering 97 - 98 - // Offset mapping tracking - current paragraph 99 - offset_maps: Vec<OffsetMapping>, 100 - node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 101 - auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value 102 - static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index 103 - current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) 104 - next_node_id: usize, 105 - current_node_id: Option<String>, // node ID for current text container 106 - current_node_char_offset: usize, // UTF-16 offset within current node 107 - current_node_child_count: usize, // number of child elements/text nodes in current container 108 - 109 - // Incremental UTF-16 offset tracking (replaces rope.chars_to_wchars) 110 - // Maps char_offset -> utf16_offset at checkpoints we've traversed. 111 - // Can be reused for future lookups or passed to subsequent writers. 112 - utf16_checkpoints: Vec<(usize, usize)>, // (char_offset, utf16_offset) 113 - 114 - // Paragraph boundary tracking for incremental rendering 115 - paragraph_ranges: Vec<(Range<usize>, Range<usize>)>, // (byte_range, char_range) 116 - current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) 117 - list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists 118 - in_footnote_def: bool, // Suppress inner paragraph boundaries in footnote definitions 119 - 120 - // Syntax span tracking for conditional visibility - current paragraph 121 - syntax_spans: Vec<SyntaxSpanInfo>, 122 - next_syn_id: usize, 123 - /// Stack of pending inline formats: (syn_id of opening span, char start of region) 124 - /// Used to set formatted_range when closing paired inline markers 125 - pending_inline_formats: Vec<(String, usize)>, 126 - 127 - /// Collected refs (wikilinks, AT embeds, AT links) for current paragraph 128 - ref_collector: weaver_common::RefCollector, 129 - 130 - // Per-paragraph accumulated results (completed paragraphs) 131 - offset_maps_by_para: Vec<Vec<OffsetMapping>>, 132 - syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>, 133 - refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>, 134 - 135 - // WeaverBlock prefix system 136 - /// Pending WeaverBlock attrs to apply to the next block element 137 - pending_block_attrs: Option<WeaverAttributes<'static>>, 138 - /// Type of wrapper element currently open (needs closing on block end) 139 - active_wrapper: Option<WrapperElement>, 140 - /// Buffer for WeaverBlock text content (to parse for attrs) 141 - weaver_block_buffer: String, 142 - /// Start char offset of current WeaverBlock (for syntax span) 143 - weaver_block_char_start: Option<usize>, 144 - 145 - // Footnote syntax linking (ref ↔ definition visibility) 146 - /// Maps footnote name → (syntax_span_index, char_start) for linking ref and def 147 - footnote_ref_spans: HashMap<String, (usize, usize)>, 148 - /// Current footnote definition being processed (name, syntax_span_index, char_start) 149 - current_footnote_def: Option<(String, usize, usize)>, 150 - 151 - _phantom: std::marker::PhantomData<&'a ()>, 152 - } 153 - 154 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 155 - EditorWriter<'a, I, E, R> 156 - { 157 - pub fn new(source: &'a str, source_text: &'a LoroText, events: I) -> Self { 158 - Self::new_with_all_offsets(source, source_text, events, 0, 0, 0, 0) 159 - } 160 - 161 - /// Get the next paragraph ID that would be assigned (for tracking allocations). 162 - #[allow(dead_code)] 163 - pub fn next_paragraph_id(&self) -> Option<usize> { 164 - self.auto_increment_prefix 165 - } 166 - 167 - /// Override the auto-incrementing prefix for a specific paragraph index. 168 - /// Use this when you need a specific paragraph (e.g., cursor paragraph) to have 169 - /// a stable prefix for DOM/offset_map compatibility. 170 - pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self { 171 - self.static_prefix_override = Some((index, prefix.to_string())); 172 - // If this is for paragraph 0, apply it immediately 173 - if index == 0 { 174 - self.node_id_prefix = Some(prefix.to_string()); 175 - self.next_node_id = 0; 176 - } 177 - self 178 - } 179 - 180 - /// Finalize the current paragraph: move accumulated items to per-para vectors, 181 - /// start a new output segment for the next paragraph. 182 - fn finalize_paragraph(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { 183 - // Record paragraph boundary 184 - self.paragraph_ranges.push((byte_range, char_range)); 185 - 186 - // Move current paragraph's data to per-para vectors 187 - self.offset_maps_by_para 188 - .push(std::mem::take(&mut self.offset_maps)); 189 - self.syntax_spans_by_para 190 - .push(std::mem::take(&mut self.syntax_spans)); 191 - self.refs_by_para 192 - .push(std::mem::take(&mut self.ref_collector.refs)); 193 - 194 - // Advance to next paragraph 195 - self.current_paragraph_index += 1; 196 - 197 - // Determine prefix for next paragraph 198 - if let Some((override_idx, ref override_prefix)) = self.static_prefix_override { 199 - if self.current_paragraph_index == override_idx { 200 - // Use the static override for this paragraph 201 - self.node_id_prefix = Some(override_prefix.clone()); 202 - self.next_node_id = 0; 203 - } else if let Some(ref mut current_id) = self.auto_increment_prefix { 204 - // Use auto-increment (skip the override index to avoid collision) 205 - *current_id += 1; 206 - self.node_id_prefix = Some(format!("p-{}", *current_id)); 207 - self.next_node_id = 0; 208 - } 209 - } else if let Some(ref mut current_id) = self.auto_increment_prefix { 210 - // Normal auto-increment 211 - *current_id += 1; 212 - self.node_id_prefix = Some(format!("p-{}", *current_id)); 213 - self.next_node_id = 0; 214 - } 215 216 - // Start new output segment for next paragraph 217 - self.writer.new_segment(); 218 - } 219 - 220 - #[inline] 221 - fn write_newline(&mut self) -> fmt::Result { 222 - self.end_newline = true; 223 - self.writer.write_str("\n") 224 - } 225 - 226 - #[inline] 227 - fn write(&mut self, s: &str) -> fmt::Result { 228 - if !s.is_empty() { 229 - self.end_newline = s.ends_with('\n'); 230 - } 231 - self.writer.write_str(s) 232 - } 233 234 - /// Generate a unique syntax span ID 235 - fn gen_syn_id(&mut self) -> String { 236 - let id = format!("s{}", self.next_syn_id); 237 - self.next_syn_id += 1; 238 - id 239 - } 240 241 - /// Finalize a paired inline format (Strong, Emphasis, Strikethrough). 242 - /// Pops the pending format info and sets formatted_range on both opening and closing spans. 243 - fn finalize_paired_inline_format(&mut self) { 244 - if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() { 245 - let format_end = self.last_char_offset; 246 - let formatted_range = format_start..format_end; 247 - 248 - // Update the opening span's formatted_range 249 - if let Some(opening_span) = self 250 - .syntax_spans 251 - .iter_mut() 252 - .find(|s| s.syn_id == opening_syn_id) 253 - { 254 - opening_span.formatted_range = Some(formatted_range.clone()); 255 - } else { 256 - tracing::warn!( 257 - "[FINALIZE_PAIRED] Could not find opening span {}", 258 - opening_syn_id 259 - ); 260 - } 261 - 262 - // Update the closing span's formatted_range (the most recent one) 263 - // The closing syntax was just emitted by emit_gap_before, so it's the last span 264 - if let Some(closing_span) = self.syntax_spans.last_mut() { 265 - // Only update if it's an inline span (closing syntax should be inline) 266 - if closing_span.syntax_type == SyntaxType::Inline { 267 - closing_span.formatted_range = Some(formatted_range); 268 - } 269 - } 270 - } 271 - } 272 - 273 - /// Generate a unique node ID. 274 - /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. 275 - /// Otherwise produces `n{counter}` for backwards compatibility. 276 - fn gen_node_id(&mut self) -> String { 277 - let id = if let Some(ref prefix) = self.node_id_prefix { 278 - format!("{}-n{}", prefix, self.next_node_id) 279 - } else { 280 - format!("n{}", self.next_node_id) 281 - }; 282 - self.next_node_id += 1; 283 - id 284 - } 285 - 286 - /// Start tracking a new text container node 287 - fn begin_node(&mut self, node_id: String) { 288 - self.current_node_id = Some(node_id); 289 - self.current_node_char_offset = 0; 290 - self.current_node_child_count = 0; 291 - } 292 - 293 - /// Stop tracking current node 294 - fn end_node(&mut self) { 295 - self.current_node_id = None; 296 - self.current_node_char_offset = 0; 297 - self.current_node_child_count = 0; 298 - } 299 - 300 - /// Compute UTF-16 length for a text slice with fast path for ASCII. 301 - #[inline] 302 - fn utf16_len_for_slice(text: &str) -> usize { 303 - let byte_len = text.len(); 304 - let char_len = text.chars().count(); 305 - 306 - // Fast path: if byte_len == char_len, all ASCII, so utf16_len == char_len 307 - if byte_len == char_len { 308 - char_len 309 - } else { 310 - // Slow path: has multi-byte chars, need to count UTF-16 code units 311 - text.encode_utf16().count() 312 - } 313 - } 314 - 315 - /// Record an offset mapping for the given byte and char ranges. 316 - /// 317 - /// Builds up utf16_checkpoints incrementally for efficient lookups. 318 - fn record_mapping(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { 319 - if let Some(ref node_id) = self.current_node_id { 320 - // Get UTF-16 length using fast path 321 - let text_slice = &self.source[byte_range.clone()]; 322 - let utf16_len = Self::utf16_len_for_slice(text_slice); 323 - 324 - // Record checkpoint at end of this range for future lookups 325 - let last_checkpoint = self.utf16_checkpoints.last().copied().unwrap_or((0, 0)); 326 - let new_utf16_offset = last_checkpoint.1 + utf16_len; 327 - 328 - // Only add checkpoint if we've advanced 329 - if char_range.end > last_checkpoint.0 { 330 - self.utf16_checkpoints 331 - .push((char_range.end, new_utf16_offset)); 332 - } 333 - 334 - let mapping = OffsetMapping { 335 - byte_range: byte_range.clone(), 336 - char_range: char_range.clone(), 337 - node_id: node_id.clone(), 338 - char_offset_in_node: self.current_node_char_offset, 339 - child_index: None, // text-based position 340 - utf16_len, 341 - }; 342 - self.offset_maps.push(mapping); 343 - self.current_node_char_offset += utf16_len; 344 - } else { 345 - tracing::debug!("[RECORD_MAPPING] SKIPPED - current_node_id is None!"); 346 - } 347 - } 348 - 349 - /// Process markdown events and write HTML. 350 - /// 351 - /// Returns offset mappings and paragraph boundaries. The HTML is written 352 - /// to the writer passed in the constructor. 353 - pub fn run(mut self) -> Result<WriterResult, fmt::Error> { 354 - while let Some((event, range)) = self.events.next() { 355 - tracing::trace!( 356 - target: "weaver::writer", 357 - event = ?event, 358 - byte_range = ?range, 359 - "processing event" 360 - ); 361 - 362 - // For End events, emit any trailing content within the event's range 363 - // BEFORE calling end_tag (which calls end_node and clears current_node_id) 364 - // 365 - // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), 366 - // the closing syntax must be emitted AFTER the closing HTML tag, not before. 367 - // Otherwise the closing `**` span ends up INSIDE the <strong> element. 368 - // These tags handle their own closing syntax in end_tag(). 369 - // Image and Embed handle ALL their syntax in the Start event, so exclude them too. 370 - use markdown_weaver::TagEnd; 371 - let is_self_handled_end = matches!( 372 - &event, 373 - Event::End( 374 - TagEnd::Strong 375 - | TagEnd::Emphasis 376 - | TagEnd::Strikethrough 377 - | TagEnd::Image 378 - | TagEnd::Embed 379 - ) 380 - ); 381 - 382 - if matches!(&event, Event::End(_)) && !is_self_handled_end { 383 - // Emit gap from last_byte_offset to range.end 384 - self.emit_gap_before(range.end)?; 385 - } else if !matches!(&event, Event::End(_)) { 386 - // For other events, emit any gap before range.start 387 - // (emit_syntax handles char offset tracking) 388 - self.emit_gap_before(range.start)?; 389 - } 390 - // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML 391 - 392 - // Store last_byte before processing 393 - let last_byte_before = self.last_byte_offset; 394 - 395 - // Process the event (passing range for tag syntax) 396 - self.process_event(event, range.clone())?; 397 - 398 - // Update tracking - but don't override if start_tag manually updated it 399 - // (for inline formatting tags that emit opening syntax) 400 - if self.last_byte_offset == last_byte_before { 401 - // Event didn't update offset, so we update it 402 - self.last_byte_offset = range.end; 403 - } 404 - // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value 405 - } 406 - 407 - // Emit any trailing syntax 408 - self.emit_gap_before(self.source.len())?; 409 - 410 - // Handle unmapped trailing content (stripped by parser) 411 - // This includes trailing spaces that markdown ignores 412 - let doc_byte_len = self.source.len(); 413 - let doc_char_len = self.source_text.len_unicode(); 414 - 415 - if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 416 - // Emit the trailing content as visible syntax 417 - if self.last_byte_offset < doc_byte_len { 418 - let trailing = &self.source[self.last_byte_offset..]; 419 - if !trailing.is_empty() { 420 - let char_start = self.last_char_offset; 421 - let trailing_char_len = trailing.chars().count(); 422 - 423 - let char_end = char_start + trailing_char_len; 424 - let syn_id = self.gen_syn_id(); 425 - 426 - write!( 427 - &mut self.writer, 428 - "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 429 - syn_id, char_start, char_end 430 - )?; 431 - escape_html(&mut self.writer, trailing)?; 432 - self.write("</span>")?; 433 - 434 - // Record syntax span info 435 - // self.syntax_spans.push(SyntaxSpanInfo { 436 - // syn_id, 437 - // char_range: char_start..char_end, 438 - // syntax_type: SyntaxType::Inline, 439 - // formatted_range: None, 440 - // }); 441 - 442 - // Record mapping if we have a node 443 - if let Some(ref node_id) = self.current_node_id { 444 - let mapping = OffsetMapping { 445 - byte_range: self.last_byte_offset..doc_byte_len, 446 - char_range: char_start..char_end, 447 - node_id: node_id.clone(), 448 - char_offset_in_node: self.current_node_char_offset, 449 - child_index: None, 450 - utf16_len: trailing_char_len, // visible 451 - }; 452 - self.offset_maps.push(mapping); 453 - self.current_node_char_offset += trailing_char_len; 454 - } 455 - 456 - self.last_char_offset = char_start + trailing_char_len; 457 - } 458 - } 459 - } 460 - 461 - // Add any remaining accumulated data for the last paragraph 462 - // (content that wasn't followed by a paragraph boundary) 463 - if !self.offset_maps.is_empty() 464 - || !self.syntax_spans.is_empty() 465 - || !self.ref_collector.refs.is_empty() 466 - { 467 - self.offset_maps_by_para.push(self.offset_maps); 468 - self.syntax_spans_by_para.push(self.syntax_spans); 469 - self.refs_by_para.push(self.ref_collector.refs); 470 - } 471 - 472 - // Get HTML segments from writer 473 - let html_segments = self.writer.into_segments(); 474 - 475 - Ok(WriterResult { 476 - html_segments, 477 - offset_maps_by_paragraph: self.offset_maps_by_para, 478 - paragraph_ranges: self.paragraph_ranges, 479 - syntax_spans_by_paragraph: self.syntax_spans_by_para, 480 - collected_refs_by_paragraph: self.refs_by_para, 481 - }) 482 - } 483 - 484 - // Consume raw text events until end tag, for alt attributes 485 - #[allow(dead_code)] 486 - fn raw_text(&mut self) -> Result<(), fmt::Error> { 487 - use Event::*; 488 - let mut nest = 0; 489 - while let Some((event, _range)) = self.events.next() { 490 - match event { 491 - Start(_) => nest += 1, 492 - End(_) => { 493 - if nest == 0 { 494 - break; 495 - } 496 - nest -= 1; 497 - } 498 - Html(_) => {} 499 - InlineHtml(text) | Code(text) | Text(text) => { 500 - // Don't use escape_html_body_text here. 501 - // The output of this function is used in the `alt` attribute. 502 - escape_html(&mut self.writer, &text)?; 503 - self.end_newline = text.ends_with('\n'); 504 - } 505 - InlineMath(text) => { 506 - self.write("$")?; 507 - escape_html(&mut self.writer, &text)?; 508 - self.write("$")?; 509 - } 510 - DisplayMath(text) => { 511 - self.write("$$")?; 512 - escape_html(&mut self.writer, &text)?; 513 - self.write("$$")?; 514 - } 515 - SoftBreak | HardBreak | Rule => { 516 - self.write(" ")?; 517 - } 518 - FootnoteReference(name) => { 519 - let len = self.numbers.len() + 1; 520 - let number = *self.numbers.entry(name.into_string()).or_insert(len); 521 - write!(&mut self.writer, "[{}]", number)?; 522 - } 523 - TaskListMarker(true) => self.write("[x]")?, 524 - TaskListMarker(false) => self.write("[ ]")?, 525 - WeaverBlock(_) => {} 526 - } 527 - } 528 - Ok(()) 529 - } 530 - 531 - /// Consume events until End tag without writing anything. 532 - /// Used when we've already extracted content from source and just need to advance the iterator. 533 - fn consume_until_end(&mut self) { 534 - use Event::*; 535 - let mut nest = 0; 536 - while let Some((event, _range)) = self.events.next() { 537 - match event { 538 - Start(_) => nest += 1, 539 - End(_) => { 540 - if nest == 0 { 541 - break; 542 - } 543 - nest -= 1; 544 - } 545 - _ => {} 546 - } 547 - } 548 - } 549 - 550 - /// Parse WeaverBlock text content into attributes. 551 - /// Format: comma-separated, colon for key:value, otherwise class. 552 - /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")] 553 - fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { 554 - let mut classes = Vec::new(); 555 - let mut attrs = Vec::new(); 556 - 557 - for part in text.split(',') { 558 - let part = part.trim(); 559 - if part.is_empty() { 560 - continue; 561 - } 562 - 563 - if let Some((key, value)) = part.split_once(':') { 564 - let key = key.trim(); 565 - let value = value.trim(); 566 - if !key.is_empty() && !value.is_empty() { 567 - attrs.push(( 568 - CowStr::from(key.to_string()), 569 - CowStr::from(value.to_string()), 570 - )); 571 - } 572 - } else { 573 - // No colon - treat as class, strip leading dot if present 574 - let class = part.strip_prefix('.').unwrap_or(part); 575 - if !class.is_empty() { 576 - classes.push(CowStr::from(class.to_string())); 577 - } 578 - } 579 - } 580 - 581 - WeaverAttributes { classes, attrs } 582 - } 583 - 584 - /// Emit wrapper element start based on pending block attrs. 585 - /// Returns true if a wrapper was emitted. 586 - fn emit_wrapper_start(&mut self) -> Result<bool, fmt::Error> { 587 - if let Some(attrs) = self.pending_block_attrs.take() { 588 - let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); 589 - 590 - if is_aside { 591 - self.write("<aside")?; 592 - self.active_wrapper = Some(WrapperElement::Aside); 593 - } else { 594 - self.write("<div")?; 595 - self.active_wrapper = Some(WrapperElement::Div); 596 - } 597 - 598 - // Write classes (excluding "aside" if using <aside> element) 599 - let classes: Vec<_> = if is_aside { 600 - attrs 601 - .classes 602 - .iter() 603 - .filter(|c| c.as_ref() != "aside") 604 - .collect() 605 - } else { 606 - attrs.classes.iter().collect() 607 - }; 608 - 609 - if !classes.is_empty() { 610 - self.write(" class=\"")?; 611 - for (i, class) in classes.iter().enumerate() { 612 - if i > 0 { 613 - self.write(" ")?; 614 - } 615 - escape_html(&mut self.writer, class)?; 616 - } 617 - self.write("\"")?; 618 - } 619 - 620 - // Write other attrs 621 - for (attr, value) in &attrs.attrs { 622 - self.write(" ")?; 623 - escape_html(&mut self.writer, attr)?; 624 - self.write("=\"")?; 625 - escape_html(&mut self.writer, value)?; 626 - self.write("\"")?; 627 - } 628 - 629 - self.write(">")?; 630 - Ok(true) 631 - } else { 632 - Ok(false) 633 - } 634 - } 635 - 636 - /// Close active wrapper element if one is open 637 - fn close_wrapper(&mut self) -> Result<(), fmt::Error> { 638 - if let Some(wrapper) = self.active_wrapper.take() { 639 - match wrapper { 640 - WrapperElement::Aside => self.write("</aside>")?, 641 - WrapperElement::Div => self.write("</div>")?, 642 - } 643 - } 644 - Ok(()) 645 - } 646 - 647 - fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> { 648 - use Event::*; 649 - 650 - match event { 651 - Start(tag) => self.start_tag(tag, range)?, 652 - End(tag) => self.end_tag(tag, range)?, 653 - Text(text) => { 654 - // If buffering code, append to buffer instead of writing 655 - if let Some((_, ref mut buffer)) = self.code_buffer { 656 - buffer.push_str(&text); 657 - 658 - // Track byte and char ranges for code block content 659 - let text_char_len = text.chars().count(); 660 - let text_byte_len = text.len(); 661 - if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { 662 - // Extend existing ranges 663 - code_byte_range.end = range.end; 664 - if let Some(ref mut code_char_range) = self.code_buffer_char_range { 665 - code_char_range.end = self.last_char_offset + text_char_len; 666 - } 667 - } else { 668 - // First text in code block - start tracking 669 - self.code_buffer_byte_range = Some(range.clone()); 670 - self.code_buffer_char_range = 671 - Some(self.last_char_offset..self.last_char_offset + text_char_len); 672 - } 673 - // Update offsets so paragraph boundary is correct 674 - self.last_char_offset += text_char_len; 675 - self.last_byte_offset += text_byte_len; 676 - } else if !self.in_non_writing_block { 677 - // Escape HTML and count chars in one pass 678 - let char_start = self.last_char_offset; 679 - let text_char_len = 680 - escape_html_body_text_with_char_count(&mut self.writer, &text)?; 681 - let char_end = char_start + text_char_len; 682 - 683 - // Text becomes a text node child of the current container 684 - if text_char_len > 0 { 685 - self.current_node_child_count += 1; 686 - } 687 - 688 - // Record offset mapping 689 - self.record_mapping(range.clone(), char_start..char_end); 690 - 691 - // Update char offset tracking 692 - self.last_char_offset = char_end; 693 - self.end_newline = text.ends_with('\n'); 694 - } 695 - } 696 - Code(text) => { 697 - let format_start = self.last_char_offset; 698 - let raw_text = &self.source[range.clone()]; 699 - 700 - // Track opening span index so we can set formatted_range later 701 - let opening_span_idx = if raw_text.starts_with('`') { 702 - let syn_id = self.gen_syn_id(); 703 - let char_start = self.last_char_offset; 704 - let backtick_char_end = char_start + 1; 705 - write!( 706 - &mut self.writer, 707 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>", 708 - syn_id, char_start, backtick_char_end 709 - )?; 710 - self.syntax_spans.push(SyntaxSpanInfo { 711 - syn_id, 712 - char_range: char_start..backtick_char_end, 713 - syntax_type: SyntaxType::Inline, 714 - formatted_range: None, // Set after we know the full range 715 - }); 716 - self.last_char_offset += 1; 717 - Some(self.syntax_spans.len() - 1) 718 - } else { 719 - None 720 - }; 721 - 722 - self.write("<code>")?; 723 - 724 - // Track offset mapping for code content 725 - let content_char_start = self.last_char_offset; 726 - let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; 727 - let content_char_end = content_char_start + text_char_len; 728 - 729 - // Record offset mapping (code content is visible) 730 - self.record_mapping(range.clone(), content_char_start..content_char_end); 731 - self.last_char_offset = content_char_end; 732 - 733 - self.write("</code>")?; 734 - 735 - // Emit closing backtick and track it 736 - if raw_text.ends_with('`') { 737 - let syn_id = self.gen_syn_id(); 738 - let backtick_char_start = self.last_char_offset; 739 - let backtick_char_end = backtick_char_start + 1; 740 - write!( 741 - &mut self.writer, 742 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">`</span>", 743 - syn_id, backtick_char_start, backtick_char_end 744 - )?; 745 - 746 - // Now we know the full formatted range 747 - let formatted_range = format_start..backtick_char_end; 748 - 749 - self.syntax_spans.push(SyntaxSpanInfo { 750 - syn_id, 751 - char_range: backtick_char_start..backtick_char_end, 752 - syntax_type: SyntaxType::Inline, 753 - formatted_range: Some(formatted_range.clone()), 754 - }); 755 - 756 - // Update opening span with formatted_range 757 - if let Some(idx) = opening_span_idx { 758 - self.syntax_spans[idx].formatted_range = Some(formatted_range); 759 - } 760 - 761 - self.last_char_offset += 1; 762 - } 763 - } 764 - InlineMath(text) => { 765 - // Math rendering follows embed pattern: syntax spans hide when cursor outside, 766 - // rendered content always visible 767 - let raw_text = &self.source[range.clone()]; 768 - let syn_id = self.gen_syn_id(); 769 - let opening_char_start = self.last_char_offset; 770 - 771 - // Calculate char positions 772 - let text_char_len = text.chars().count(); 773 - let opening_char_end = opening_char_start + 1; // "$" 774 - let content_char_start = opening_char_end; 775 - let content_char_end = content_char_start + text_char_len; 776 - let closing_char_start = content_char_end; 777 - let closing_char_end = closing_char_start + 1; // "$" 778 - let formatted_range = opening_char_start..closing_char_end; 779 - 780 - // 1. Emit opening $ syntax span 781 - if raw_text.starts_with('$') { 782 - write!( 783 - &mut self.writer, 784 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>", 785 - syn_id, opening_char_start, opening_char_end 786 - )?; 787 - self.syntax_spans.push(SyntaxSpanInfo { 788 - syn_id: syn_id.clone(), 789 - char_range: opening_char_start..opening_char_end, 790 - syntax_type: SyntaxType::Inline, 791 - formatted_range: Some(formatted_range.clone()), 792 - }); 793 - self.record_mapping( 794 - range.start..range.start + 1, 795 - opening_char_start..opening_char_end, 796 - ); 797 - } 798 - 799 - // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) 800 - write!( 801 - &mut self.writer, 802 - "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 803 - syn_id, content_char_start, content_char_end 804 - )?; 805 - escape_html(&mut self.writer, &text)?; 806 - self.write("</span>")?; 807 - self.syntax_spans.push(SyntaxSpanInfo { 808 - syn_id: syn_id.clone(), 809 - char_range: content_char_start..content_char_end, 810 - syntax_type: SyntaxType::Inline, 811 - formatted_range: Some(formatted_range.clone()), 812 - }); 813 - self.record_mapping( 814 - range.start + 1..range.end - 1, 815 - content_char_start..content_char_end, 816 - ); 817 - 818 - // 3. Emit closing $ syntax span 819 - if raw_text.ends_with('$') { 820 - write!( 821 - &mut self.writer, 822 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$</span>", 823 - syn_id, closing_char_start, closing_char_end 824 - )?; 825 - self.syntax_spans.push(SyntaxSpanInfo { 826 - syn_id: syn_id.clone(), 827 - char_range: closing_char_start..closing_char_end, 828 - syntax_type: SyntaxType::Inline, 829 - formatted_range: Some(formatted_range.clone()), 830 - }); 831 - self.record_mapping( 832 - range.end - 1..range.end, 833 - closing_char_start..closing_char_end, 834 - ); 835 - } 836 - 837 - // 4. Emit rendered MathML (always visible, not tied to syn_id) 838 - // Include data-char-target so clicking moves cursor into the math region 839 - // contenteditable="false" so DOM walker skips this for offset counting 840 - match weaver_renderer::math::render_math(&text, false) { 841 - weaver_renderer::math::MathResult::Success(mathml) => { 842 - write!( 843 - &mut self.writer, 844 - "<span class=\"math math-inline math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 845 - content_char_start, mathml 846 - )?; 847 - } 848 - weaver_renderer::math::MathResult::Error { html, .. } => { 849 - // Show error indicator (also always visible) 850 - self.write(&html)?; 851 - } 852 - } 853 - 854 - self.last_char_offset = closing_char_end; 855 - } 856 - DisplayMath(text) => { 857 - // Math rendering follows embed pattern: syntax spans hide when cursor outside, 858 - // rendered content always visible 859 - let raw_text = &self.source[range.clone()]; 860 - let syn_id = self.gen_syn_id(); 861 - let opening_char_start = self.last_char_offset; 862 - 863 - // Calculate char positions 864 - let text_char_len = text.chars().count(); 865 - let opening_char_end = opening_char_start + 2; // "$$" 866 - let content_char_start = opening_char_end; 867 - let content_char_end = content_char_start + text_char_len; 868 - let closing_char_start = content_char_end; 869 - let closing_char_end = closing_char_start + 2; // "$$" 870 - let formatted_range = opening_char_start..closing_char_end; 871 - 872 - // 1. Emit opening $$ syntax span 873 - // Use Block syntax type so visibility is based on "cursor in same paragraph" 874 - if raw_text.starts_with("$$") { 875 - write!( 876 - &mut self.writer, 877 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>", 878 - syn_id, opening_char_start, opening_char_end 879 - )?; 880 - self.syntax_spans.push(SyntaxSpanInfo { 881 - syn_id: syn_id.clone(), 882 - char_range: opening_char_start..opening_char_end, 883 - syntax_type: SyntaxType::Block, 884 - formatted_range: Some(formatted_range.clone()), 885 - }); 886 - self.record_mapping( 887 - range.start..range.start + 2, 888 - opening_char_start..opening_char_end, 889 - ); 890 - } 891 - 892 - // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) 893 - write!( 894 - &mut self.writer, 895 - "<span class=\"math-source\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 896 - syn_id, content_char_start, content_char_end 897 - )?; 898 - escape_html(&mut self.writer, &text)?; 899 - self.write("</span>")?; 900 - self.syntax_spans.push(SyntaxSpanInfo { 901 - syn_id: syn_id.clone(), 902 - char_range: content_char_start..content_char_end, 903 - syntax_type: SyntaxType::Block, 904 - formatted_range: Some(formatted_range.clone()), 905 - }); 906 - self.record_mapping( 907 - range.start + 2..range.end - 2, 908 - content_char_start..content_char_end, 909 - ); 910 - 911 - // 3. Emit closing $$ syntax span 912 - if raw_text.ends_with("$$") { 913 - write!( 914 - &mut self.writer, 915 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">$$</span>", 916 - syn_id, closing_char_start, closing_char_end 917 - )?; 918 - self.syntax_spans.push(SyntaxSpanInfo { 919 - syn_id: syn_id.clone(), 920 - char_range: closing_char_start..closing_char_end, 921 - syntax_type: SyntaxType::Block, 922 - formatted_range: Some(formatted_range.clone()), 923 - }); 924 - self.record_mapping( 925 - range.end - 2..range.end, 926 - closing_char_start..closing_char_end, 927 - ); 928 - } 929 - 930 - // 4. Emit rendered MathML (always visible, not tied to syn_id) 931 - // Include data-char-target so clicking moves cursor into the math region 932 - // contenteditable="false" so DOM walker skips this for offset counting 933 - match weaver_renderer::math::render_math(&text, true) { 934 - weaver_renderer::math::MathResult::Success(mathml) => { 935 - write!( 936 - &mut self.writer, 937 - "<span class=\"math math-display math-rendered math-clickable\" contenteditable=\"false\" data-char-target=\"{}\">{}</span>", 938 - content_char_start, mathml 939 - )?; 940 - } 941 - weaver_renderer::math::MathResult::Error { html, .. } => { 942 - // Show error indicator (also always visible) 943 - self.write(&html)?; 944 - } 945 - } 946 - 947 - self.last_char_offset = closing_char_end; 948 - } 949 - Html(html) => { 950 - // Track offset mapping for raw HTML 951 - let char_start = self.last_char_offset; 952 - let html_char_len = html.chars().count(); 953 - let char_end = char_start + html_char_len; 954 - 955 - self.write(&html)?; 956 - 957 - // Record mapping for inline HTML 958 - self.record_mapping(range.clone(), char_start..char_end); 959 - self.last_char_offset = char_end; 960 - } 961 - InlineHtml(html) => { 962 - // Track offset mapping for raw HTML 963 - let char_start = self.last_char_offset; 964 - let html_char_len = html.chars().count(); 965 - let char_end = char_start + html_char_len; 966 - self.write(r#"<span class="html-embed html-embed-inline">"#)?; 967 - self.write(&html)?; 968 - self.write("</span>")?; 969 - // Record mapping for inline HTML 970 - self.record_mapping(range.clone(), char_start..char_end); 971 - self.last_char_offset = char_end; 972 - } 973 - SoftBreak => { 974 - // Emit <br> for visual line break, plus a space for cursor positioning. 975 - // This space maps to the \n so the cursor can land here when navigating. 976 - let char_start = self.last_char_offset; 977 - 978 - // Emit <br> 979 - self.write("<br />")?; 980 - self.current_node_child_count += 1; 981 - 982 - // Emit space for cursor positioning - this gives the browser somewhere 983 - // to place the cursor when navigating to this line 984 - self.write("\u{200B}")?; 985 - self.current_node_child_count += 1; 986 - 987 - // Map the space to the newline position - cursor landing here means 988 - // we're at the end of the line (after the \n) 989 - if let Some(ref node_id) = self.current_node_id { 990 - let mapping = OffsetMapping { 991 - byte_range: range.clone(), 992 - char_range: char_start..char_start + 1, 993 - node_id: node_id.clone(), 994 - char_offset_in_node: self.current_node_char_offset, 995 - child_index: None, 996 - utf16_len: 1, // the space we emitted 997 - }; 998 - self.offset_maps.push(mapping); 999 - self.current_node_char_offset += 1; 1000 - } 1001 - 1002 - self.last_char_offset = char_start + 1; // +1 for the \n 1003 - } 1004 - HardBreak => { 1005 - // Emit the two spaces as visible (dimmed) text, then <br> 1006 - let gap = &self.source[range.clone()]; 1007 - if gap.ends_with('\n') { 1008 - let spaces = &gap[..gap.len() - 1]; // everything except the \n 1009 - let char_start = self.last_char_offset; 1010 - let spaces_char_len = spaces.chars().count(); 1011 - let char_end = char_start + spaces_char_len; 1012 - 1013 - // Emit and map the visible spaces 1014 - let syn_id = self.gen_syn_id(); 1015 - write!( 1016 - &mut self.writer, 1017 - "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1018 - syn_id, char_start, char_end 1019 - )?; 1020 - escape_html(&mut self.writer, spaces)?; 1021 - self.write("</span>")?; 1022 - 1023 - // Count this span as a child 1024 - self.current_node_child_count += 1; 1025 - 1026 - self.record_mapping( 1027 - range.start..range.start + spaces.len(), 1028 - char_start..char_end, 1029 - ); 1030 - 1031 - // Now the actual line break <br> 1032 - self.write("<br />")?; 1033 - 1034 - // Count the <br> as a child 1035 - self.current_node_child_count += 1; 1036 - 1037 - // After <br>, emit plain zero-width space for cursor positioning 1038 - self.write("\u{200B}")?; 1039 - 1040 - // Count the zero-width space text node as a child 1041 - self.current_node_child_count += 1; 1042 - 1043 - // Map the newline position to the zero-width space text node 1044 - if let Some(ref node_id) = self.current_node_id { 1045 - let newline_char_offset = char_start + spaces_char_len; 1046 - let mapping = OffsetMapping { 1047 - byte_range: range.start + spaces.len()..range.end, 1048 - char_range: newline_char_offset..newline_char_offset + 1, 1049 - node_id: node_id.clone(), 1050 - char_offset_in_node: self.current_node_char_offset, 1051 - child_index: None, // text node - TreeWalker will find it 1052 - utf16_len: 1, // zero-width space is 1 UTF-16 unit 1053 - }; 1054 - self.offset_maps.push(mapping); 1055 - 1056 - // Increment char offset - TreeWalker will encounter this text node 1057 - self.current_node_char_offset += 1; 1058 - } 1059 - 1060 - self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n 1061 - } else { 1062 - // Fallback: just <br> 1063 - self.write("<br />")?; 1064 - } 1065 - } 1066 - Rule => { 1067 - if !self.end_newline { 1068 - self.write("\n")?; 1069 - } 1070 - 1071 - // Emit syntax span before the rendered element 1072 - if range.start < range.end { 1073 - let raw_text = &self.source[range]; 1074 - let trimmed = raw_text.trim(); 1075 - if !trimmed.is_empty() { 1076 - let syn_id = self.gen_syn_id(); 1077 - let char_start = self.last_char_offset; 1078 - let char_len = trimmed.chars().count(); 1079 - let char_end = char_start + char_len; 1080 - 1081 - write!( 1082 - &mut self.writer, 1083 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1084 - syn_id, char_start, char_end 1085 - )?; 1086 - escape_html(&mut self.writer, trimmed)?; 1087 - self.write("</span>")?; 1088 - 1089 - self.syntax_spans.push(SyntaxSpanInfo { 1090 - syn_id, 1091 - char_range: char_start..char_end, 1092 - syntax_type: SyntaxType::Block, 1093 - formatted_range: None, 1094 - }); 1095 - } 1096 - } 1097 - 1098 - // Wrap <hr /> in toggle-block for future cursor-based toggling 1099 - self.write("<div class=\"toggle-block\"><hr /></div>")?; 1100 - } 1101 - FootnoteReference(name) => { 1102 - // Emit [^name] as styled (but NOT hidden) inline span 1103 - let raw_text = &self.source[range.clone()]; 1104 - let char_start = self.last_char_offset; 1105 - let syntax_char_len = raw_text.chars().count(); 1106 - let char_end = char_start + syntax_char_len; 1107 - 1108 - // Use footnote-ref class for styling, not md-syntax-inline (which hides) 1109 - write!( 1110 - &mut self.writer, 1111 - "<span class=\"footnote-ref\" data-char-start=\"{}\" data-char-end=\"{}\" data-footnote=\"{}\">", 1112 - char_start, char_end, name 1113 - )?; 1114 - escape_html(&mut self.writer, raw_text)?; 1115 - self.write("</span>")?; 1116 - 1117 - // Record offset mapping 1118 - self.record_mapping(range.clone(), char_start..char_end); 1119 - 1120 - // Count as child 1121 - self.current_node_child_count += 1; 1122 - 1123 - // Update tracking 1124 - self.last_char_offset = char_end; 1125 - self.last_byte_offset = range.end; 1126 - } 1127 - TaskListMarker(checked) => { 1128 - // Emit the [ ] or [x] syntax 1129 - if range.start < range.end { 1130 - let raw_text = &self.source[range]; 1131 - if let Some(bracket_pos) = raw_text.find('[') { 1132 - let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); 1133 - let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; 1134 - 1135 - let syn_id = self.gen_syn_id(); 1136 - let char_start = self.last_char_offset; 1137 - let syntax_char_len = syntax.chars().count(); 1138 - let char_end = char_start + syntax_char_len; 1139 - 1140 - write!( 1141 - &mut self.writer, 1142 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1143 - syn_id, char_start, char_end 1144 - )?; 1145 - escape_html(&mut self.writer, syntax)?; 1146 - self.write("</span> ")?; 1147 - 1148 - self.syntax_spans.push(SyntaxSpanInfo { 1149 - syn_id, 1150 - char_range: char_start..char_end, 1151 - syntax_type: SyntaxType::Inline, 1152 - formatted_range: None, 1153 - }); 1154 - } 1155 - } 1156 - 1157 - if checked { 1158 - self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>")?; 1159 - } else { 1160 - self.write("<input disabled=\"\" type=\"checkbox\"/>")?; 1161 - } 1162 - } 1163 - WeaverBlock(text) => { 1164 - // Buffer WeaverBlock content for parsing on End 1165 - self.weaver_block_buffer.push_str(&text); 1166 - } 1167 - } 1168 - Ok(()) 1169 - } 1170 - 1171 - pub fn new_with_all_offsets( 1172 - source: &'a str, 1173 - source_text: &'a LoroText, 1174 - events: I, 1175 - node_id_offset: usize, 1176 - syn_id_offset: usize, 1177 - char_offset_base: usize, 1178 - byte_offset_base: usize, 1179 - ) -> Self { 1180 - Self { 1181 - source, 1182 - source_text, 1183 - events, 1184 - writer: SegmentedWriter::new(), 1185 - last_byte_offset: byte_offset_base, 1186 - last_char_offset: char_offset_base, 1187 - end_newline: true, 1188 - in_non_writing_block: false, 1189 - table_state: TableState::Head, 1190 - table_alignments: vec![], 1191 - table_cell_index: 0, 1192 - numbers: HashMap::new(), 1193 - embed_provider: None, 1194 - image_resolver: None, 1195 - entry_index: None, 1196 - code_buffer: None, 1197 - code_buffer_byte_range: None, 1198 - code_buffer_char_range: None, 1199 - code_block_char_start: None, 1200 - code_block_opening_span_idx: None, 1201 - pending_blockquote_range: None, 1202 - render_tables_as_markdown: true, 1203 - table_start_offset: None, 1204 - offset_maps: Vec::new(), 1205 - node_id_prefix: None, 1206 - auto_increment_prefix: None, 1207 - static_prefix_override: None, 1208 - current_paragraph_index: 0, 1209 - next_node_id: node_id_offset, 1210 - current_node_id: None, 1211 - current_node_char_offset: 0, 1212 - current_node_child_count: 0, 1213 - utf16_checkpoints: vec![(0, 0)], 1214 - paragraph_ranges: Vec::new(), 1215 - current_paragraph_start: None, 1216 - list_depth: 0, 1217 - in_footnote_def: false, 1218 - syntax_spans: Vec::new(), 1219 - next_syn_id: syn_id_offset, 1220 - pending_inline_formats: Vec::new(), 1221 - ref_collector: weaver_common::RefCollector::new(), 1222 - offset_maps_by_para: Vec::new(), 1223 - syntax_spans_by_para: Vec::new(), 1224 - refs_by_para: Vec::new(), 1225 - pending_block_attrs: None, 1226 - active_wrapper: None, 1227 - weaver_block_buffer: String::new(), 1228 - weaver_block_char_start: None, 1229 - footnote_ref_spans: HashMap::new(), 1230 - current_footnote_def: None, 1231 - _phantom: std::marker::PhantomData, 1232 - } 1233 - } 1234 - 1235 - /// Add an embed content provider 1236 - pub fn with_embed_provider(mut self, provider: E) -> EditorWriter<'a, I, E, R> { 1237 - self.embed_provider = Some(provider); 1238 - self 1239 - } 1240 - 1241 - /// Add an image resolver for mapping markdown image URLs to CDN URLs 1242 - pub fn with_image_resolver<R2: ImageResolver>( 1243 - self, 1244 - resolver: R2, 1245 - ) -> EditorWriter<'a, I, E, R2> { 1246 - EditorWriter { 1247 - source: self.source, 1248 - source_text: self.source_text, 1249 - events: self.events, 1250 - writer: self.writer, 1251 - last_byte_offset: self.last_byte_offset, 1252 - last_char_offset: self.last_char_offset, 1253 - end_newline: self.end_newline, 1254 - in_non_writing_block: self.in_non_writing_block, 1255 - table_state: self.table_state, 1256 - table_alignments: self.table_alignments, 1257 - table_cell_index: self.table_cell_index, 1258 - numbers: self.numbers, 1259 - embed_provider: self.embed_provider, 1260 - image_resolver: Some(resolver), 1261 - entry_index: self.entry_index, 1262 - code_buffer: self.code_buffer, 1263 - code_buffer_byte_range: self.code_buffer_byte_range, 1264 - code_buffer_char_range: self.code_buffer_char_range, 1265 - code_block_char_start: self.code_block_char_start, 1266 - code_block_opening_span_idx: self.code_block_opening_span_idx, 1267 - pending_blockquote_range: self.pending_blockquote_range, 1268 - render_tables_as_markdown: self.render_tables_as_markdown, 1269 - table_start_offset: self.table_start_offset, 1270 - offset_maps: self.offset_maps, 1271 - node_id_prefix: self.node_id_prefix, 1272 - auto_increment_prefix: self.auto_increment_prefix, 1273 - static_prefix_override: self.static_prefix_override, 1274 - current_paragraph_index: self.current_paragraph_index, 1275 - next_node_id: self.next_node_id, 1276 - current_node_id: self.current_node_id, 1277 - current_node_char_offset: self.current_node_char_offset, 1278 - current_node_child_count: self.current_node_child_count, 1279 - utf16_checkpoints: self.utf16_checkpoints, 1280 - paragraph_ranges: self.paragraph_ranges, 1281 - current_paragraph_start: self.current_paragraph_start, 1282 - list_depth: self.list_depth, 1283 - in_footnote_def: self.in_footnote_def, 1284 - syntax_spans: self.syntax_spans, 1285 - next_syn_id: self.next_syn_id, 1286 - pending_inline_formats: self.pending_inline_formats, 1287 - ref_collector: self.ref_collector, 1288 - offset_maps_by_para: self.offset_maps_by_para, 1289 - syntax_spans_by_para: self.syntax_spans_by_para, 1290 - refs_by_para: self.refs_by_para, 1291 - pending_block_attrs: self.pending_block_attrs, 1292 - active_wrapper: self.active_wrapper, 1293 - weaver_block_buffer: self.weaver_block_buffer, 1294 - weaver_block_char_start: self.weaver_block_char_start, 1295 - footnote_ref_spans: self.footnote_ref_spans, 1296 - current_footnote_def: self.current_footnote_def, 1297 - _phantom: std::marker::PhantomData, 1298 - } 1299 - } 1300 - 1301 - /// Add an entry index for wikilink resolution feedback 1302 - pub fn with_entry_index(mut self, index: &'a EntryIndex) -> Self { 1303 - self.entry_index = Some(index); 1304 - self 1305 - } 1306 - 1307 - /// Set a prefix for node IDs (typically the paragraph ID). 1308 - /// This makes node IDs paragraph-scoped and stable across re-renders. 1309 - /// Use this for single-paragraph renders where the paragraph ID is known. 1310 - pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { 1311 - self.node_id_prefix = Some(prefix.to_string()); 1312 - self.next_node_id = 0; // Reset counter since each paragraph is independent 1313 - self 1314 - } 1315 - 1316 - /// Enable auto-incrementing paragraph prefixes for multi-paragraph renders. 1317 - /// Each paragraph gets prefix "p-{N}" where N starts at `start_id` and increments. 1318 - /// Node IDs reset to 0 for each paragraph, giving "p-{N}-n0", "p-{N}-n1", etc. 1319 - pub fn with_auto_incrementing_prefix(mut self, start_id: usize) -> Self { 1320 - self.auto_increment_prefix = Some(start_id); 1321 - self.node_id_prefix = Some(format!("p-{}", start_id)); 1322 - self.next_node_id = 0; 1323 - self 1324 - } 1325 - }
··· 1 + //! HTML writer for markdown editor - re-exports from weaver-editor-core. 2 //! 3 + //! The core EditorWriter lives in weaver-editor-core. This module provides: 4 + //! - Re-exports of core types for convenience 5 + //! - App-specific EditorImageResolver for image URL resolution 6 7 + pub mod embed; 8 9 + // Re-export everything from core 10 + pub use weaver_editor_core::{ 11 + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SegmentedWriter, 12 + SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, 13 + }; 14 15 + // App-specific image resolver 16 + pub use embed::EditorImageResolver;
+6 -223
crates/weaver-app/src/components/editor/writer/embed.rs
··· 1 - use core::fmt; 2 - use std::ops::Range; 3 4 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 5 - use markdown_weaver::{CowStr, EmbedType, Event, Tag}; 6 - use markdown_weaver_escape::{StrWrite, escape_html}; 7 - use weaver_common::ResolvedContent; 8 9 - use crate::components::editor::{ 10 - SyntaxSpanInfo, SyntaxType, document::EditorImage, writer::EditorWriter, 11 - }; 12 13 - /// Synchronous callback for injecting embed content 14 - /// 15 - /// Takes the embed tag and returns optional HTML content to inject. 16 - pub trait EmbedContentProvider { 17 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 18 - } 19 - 20 - impl EmbedContentProvider for () { 21 - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 22 - None 23 - } 24 - } 25 - 26 - impl EmbedContentProvider for &ResolvedContent { 27 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 28 - if let Tag::Embed { dest_url, .. } = tag { 29 - let url = dest_url.as_ref(); 30 - if url.starts_with("at://") { 31 - if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { 32 - return ResolvedContent::get_embed_content(self, &at_uri) 33 - .map(|s| s.to_string()); 34 - } 35 - } 36 - } 37 - None 38 - } 39 - } 40 - 41 - /// Resolves image URLs to CDN URLs based on stored images. 42 - /// 43 - /// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). 44 - /// This trait maps those names to the actual CDN URL using the blob CID and owner DID. 45 - pub trait ImageResolver { 46 - /// Resolve an image URL from markdown to a CDN URL. 47 - /// 48 - /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. 49 - fn resolve_image_url(&self, url: &str) -> Option<String>; 50 - } 51 - 52 - impl ImageResolver for () { 53 - fn resolve_image_url(&self, _url: &str) -> Option<String> { 54 - None 55 - } 56 - } 57 - 58 - /// Concrete image resolver that maps image names to URLs. 59 - /// 60 /// Resolved image path type 61 #[derive(Clone, Debug)] 62 enum ResolvedImage { ··· 201 } 202 } 203 204 - impl ImageResolver for &EditorImageResolver { 205 - fn resolve_image_url(&self, url: &str) -> Option<String> { 206 - (*self).resolve_image_url(url) 207 - } 208 - } 209 - 210 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 211 - EditorWriter<'a, I, E, R> 212 - { 213 - pub(crate) fn write_embed( 214 - &mut self, 215 - range: Range<usize>, 216 - embed_type: EmbedType, 217 - dest_url: CowStr<'_>, 218 - title: CowStr<'_>, 219 - id: CowStr<'_>, 220 - attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 221 - ) -> Result<(), fmt::Error> { 222 - // Embed rendering: all syntax elements share one syn_id for visibility toggling 223 - // Structure: ![[ url-as-link ]] <embed-content> 224 - let raw_text = &self.source[range.clone()]; 225 - let syn_id = self.gen_syn_id(); 226 - let opening_char_start = self.last_char_offset; 227 - 228 - // Extract the URL from raw text (between ![[ and ]]) 229 - let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { 230 - &raw_text[3..raw_text.len() - 2] 231 - } else { 232 - dest_url.as_ref() 233 - }; 234 - 235 - // Calculate char positions 236 - let url_char_len = url_text.chars().count(); 237 - let opening_char_end = opening_char_start + 3; // "![[" 238 - let url_char_start = opening_char_end; 239 - let url_char_end = url_char_start + url_char_len; 240 - let closing_char_start = url_char_end; 241 - let closing_char_end = closing_char_start + 2; // "]]" 242 - let formatted_range = opening_char_start..closing_char_end; 243 - 244 - // 1. Emit opening ![[ syntax span 245 - if raw_text.starts_with("![[") { 246 - write!( 247 - &mut self.writer, 248 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>", 249 - syn_id, opening_char_start, opening_char_end 250 - )?; 251 - 252 - self.syntax_spans.push(SyntaxSpanInfo { 253 - syn_id: syn_id.clone(), 254 - char_range: opening_char_start..opening_char_end, 255 - syntax_type: SyntaxType::Inline, 256 - formatted_range: Some(formatted_range.clone()), 257 - }); 258 - 259 - self.record_mapping( 260 - range.start..range.start + 3, 261 - opening_char_start..opening_char_end, 262 - ); 263 - } 264 - 265 - // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) 266 - let url = dest_url.as_ref(); 267 - let link_href = if url.starts_with("at://") { 268 - format!("https://alpha.weaver.sh/record/{}", url) 269 - } else { 270 - url.to_string() 271 - }; 272 - 273 - write!( 274 - &mut self.writer, 275 - "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">", 276 - link_href, syn_id, url_char_start, url_char_end 277 - )?; 278 - escape_html(&mut self.writer, url_text)?; 279 - self.write("</a>")?; 280 - 281 - self.syntax_spans.push(SyntaxSpanInfo { 282 - syn_id: syn_id.clone(), 283 - char_range: url_char_start..url_char_end, 284 - syntax_type: SyntaxType::Inline, 285 - formatted_range: Some(formatted_range.clone()), 286 - }); 287 - 288 - self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); 289 - 290 - // 3. Emit closing ]] syntax span 291 - if raw_text.ends_with("]]") { 292 - write!( 293 - &mut self.writer, 294 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 295 - syn_id, closing_char_start, closing_char_end 296 - )?; 297 - 298 - self.syntax_spans.push(SyntaxSpanInfo { 299 - syn_id: syn_id.clone(), 300 - char_range: closing_char_start..closing_char_end, 301 - syntax_type: SyntaxType::Inline, 302 - formatted_range: Some(formatted_range.clone()), 303 - }); 304 - 305 - self.record_mapping( 306 - range.end - 2..range.end, 307 - closing_char_start..closing_char_end, 308 - ); 309 - } 310 - 311 - // Collect AT URI for later resolution 312 - if url.starts_with("at://") || url.starts_with("did:") { 313 - self.ref_collector.add_at_embed( 314 - url, 315 - if title.is_empty() { 316 - None 317 - } else { 318 - Some(title.as_ref()) 319 - }, 320 - ); 321 - } 322 - 323 - // 4. Emit the actual embed content 324 - // Try to get content from attributes first 325 - let content_from_attrs = if let Some(ref attrs) = attrs { 326 - attrs 327 - .attrs 328 - .iter() 329 - .find(|(k, _)| k.as_ref() == "content") 330 - .map(|(_, v)| v.as_ref().to_string()) 331 - } else { 332 - None 333 - }; 334 - 335 - // If no content in attrs, try provider 336 - let content = if let Some(content) = content_from_attrs { 337 - Some(content) 338 - } else if let Some(ref provider) = self.embed_provider { 339 - let tag = Tag::Embed { 340 - embed_type, 341 - dest_url: dest_url.clone(), 342 - title: title.clone(), 343 - id: id.clone(), 344 - attrs: attrs.clone(), 345 - }; 346 - provider.get_embed_content(&tag) 347 - } else { 348 - None 349 - }; 350 - 351 - if let Some(html_content) = content { 352 - // Write the pre-rendered content directly 353 - self.write(&html_content)?; 354 - } else { 355 - // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) 356 - self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?; 357 - self.write("<span class=\"embed-loading\">Loading embed...</span>")?; 358 - self.write("</div>")?; 359 - } 360 - 361 - // Consume the text events for the URL (they're still in the iterator) 362 - // Use consume_until_end() since we already wrote the URL from source 363 - self.consume_until_end(); 364 - 365 - // Update offsets 366 - self.last_char_offset = closing_char_end; 367 - self.last_byte_offset = range.end; 368 - 369 - Ok(()) 370 - } 371 - }
··· 1 + //! App-specific image resolver for the editor. 2 + //! 3 + //! Provides EditorImageResolver which maps image names to URLs based on 4 + //! image state (pending upload, draft, published). 5 6 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 7 + use weaver_editor_core::ImageResolver; 8 9 + use crate::components::editor::document::EditorImage; 10 11 /// Resolved image path type 12 #[derive(Clone, Debug)] 13 enum ResolvedImage { ··· 152 } 153 } 154
-66
crates/weaver-app/src/components/editor/writer/segmented.rs
··· 1 - use core::fmt; 2 - 3 - use markdown_weaver_escape::StrWrite; 4 - 5 - /// Writer that segments output by paragraph boundaries. 6 - /// 7 - /// Each paragraph's HTML is written to a separate String in the segments Vec. 8 - /// Call `new_segment()` at paragraph boundaries to start a new segment. 9 - #[derive(Debug, Clone, Default)] 10 - pub struct SegmentedWriter { 11 - pub segments: Vec<String>, 12 - } 13 - 14 - #[allow(dead_code)] 15 - impl SegmentedWriter { 16 - pub fn new() -> Self { 17 - Self { 18 - segments: vec![String::new()], 19 - } 20 - } 21 - 22 - /// Start a new segment for the next paragraph. 23 - pub fn new_segment(&mut self) { 24 - self.segments.push(String::new()); 25 - } 26 - 27 - /// Get the completed segments. 28 - pub fn into_segments(self) -> Vec<String> { 29 - self.segments 30 - } 31 - 32 - /// Get current segment count. 33 - pub fn segment_count(&self) -> usize { 34 - self.segments.len() 35 - } 36 - } 37 - 38 - impl StrWrite for SegmentedWriter { 39 - type Error = fmt::Error; 40 - 41 - #[inline] 42 - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { 43 - if let Some(segment) = self.segments.last_mut() { 44 - segment.push_str(s); 45 - } 46 - Ok(()) 47 - } 48 - 49 - #[inline] 50 - fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { 51 - if let Some(segment) = self.segments.last_mut() { 52 - fmt::Write::write_fmt(segment, args)?; 53 - } 54 - Ok(()) 55 - } 56 - } 57 - 58 - impl fmt::Write for SegmentedWriter { 59 - fn write_str(&mut self, s: &str) -> fmt::Result { 60 - <Self as StrWrite>::write_str(self, s) 61 - } 62 - 63 - fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { 64 - <Self as StrWrite>::write_fmt(self, args) 65 - } 66 - }
···
-264
crates/weaver-app/src/components/editor/writer/syntax.rs
··· 1 - use core::fmt; 2 - use std::ops::Range; 3 - 4 - use markdown_weaver::Event; 5 - use markdown_weaver_escape::{StrWrite, escape_html}; 6 - 7 - use crate::components::editor::writer::{ 8 - EditorWriter, 9 - embed::{EmbedContentProvider, ImageResolver}, 10 - }; 11 - 12 - /// Classification of markdown syntax characters 13 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 - pub enum SyntaxType { 15 - /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 16 - Inline, 17 - /// Block formatting: #, >, -, *, 1., ```, --- 18 - Block, 19 - } 20 - 21 - /// Information about a syntax span for conditional visibility 22 - #[derive(Debug, Clone, PartialEq, Eq)] 23 - pub struct SyntaxSpanInfo { 24 - /// Unique identifier for this syntax span (e.g., "s0", "s1") 25 - pub syn_id: String, 26 - /// Source char range this syntax covers (just this marker) 27 - pub char_range: Range<usize>, 28 - /// Whether this is inline or block-level syntax 29 - pub syntax_type: SyntaxType, 30 - /// For paired inline syntax (**, *, etc), the full formatted region 31 - /// from opening marker through content to closing marker. 32 - /// When cursor is anywhere in this range, the syntax is visible. 33 - pub formatted_range: Option<Range<usize>>, 34 - } 35 - 36 - impl SyntaxSpanInfo { 37 - /// Adjust all position fields by a character delta. 38 - /// 39 - /// This adjusts both `char_range` and `formatted_range` (if present) together, 40 - /// ensuring they stay in sync. Use this instead of manually adjusting fields 41 - /// to avoid forgetting one. 42 - pub fn adjust_positions(&mut self, char_delta: isize) { 43 - self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 44 - self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 45 - if let Some(ref mut fr) = self.formatted_range { 46 - fr.start = (fr.start as isize + char_delta) as usize; 47 - fr.end = (fr.end as isize + char_delta) as usize; 48 - } 49 - } 50 - } 51 - 52 - /// Classify syntax text as inline or block level 53 - pub(crate) fn classify_syntax(text: &str) -> SyntaxType { 54 - let trimmed = text.trim_start(); 55 - 56 - // Check for block-level markers 57 - if trimmed.starts_with('#') 58 - || trimmed.starts_with('>') 59 - || trimmed.starts_with("```") 60 - || trimmed.starts_with("---") 61 - || (trimmed.starts_with('-') 62 - && trimmed 63 - .chars() 64 - .nth(1) 65 - .map(|c| c.is_whitespace()) 66 - .unwrap_or(false)) 67 - || (trimmed.starts_with('*') 68 - && trimmed 69 - .chars() 70 - .nth(1) 71 - .map(|c| c.is_whitespace()) 72 - .unwrap_or(false)) 73 - || trimmed 74 - .chars() 75 - .next() 76 - .map(|c| c.is_ascii_digit()) 77 - .unwrap_or(false) 78 - && trimmed.contains('.') 79 - { 80 - SyntaxType::Block 81 - } else { 82 - SyntaxType::Inline 83 - } 84 - } 85 - 86 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 87 - EditorWriter<'a, I, E, R> 88 - { 89 - /// Emit syntax span for a given range and record offset mapping 90 - pub(crate) fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> { 91 - if range.start < range.end { 92 - let syntax = &self.source[range.clone()]; 93 - if !syntax.is_empty() { 94 - let char_start = self.last_char_offset; 95 - let syntax_char_len = syntax.chars().count(); 96 - let char_end = char_start + syntax_char_len; 97 - 98 - tracing::trace!( 99 - target: "weaver::writer", 100 - byte_range = ?range, 101 - char_range = ?(char_start..char_end), 102 - syntax = %syntax.escape_debug(), 103 - "emit_syntax" 104 - ); 105 - 106 - // Whitespace-only content (trailing spaces, newlines) should be emitted 107 - // as plain text, not wrapped in a hideable syntax span 108 - let is_whitespace_only = syntax.trim().is_empty(); 109 - 110 - if is_whitespace_only { 111 - // Emit as plain text with tracking span (not hideable) 112 - let created_node = if self.current_node_id.is_none() { 113 - let node_id = self.gen_node_id(); 114 - write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 115 - self.begin_node(node_id); 116 - true 117 - } else { 118 - false 119 - }; 120 - 121 - escape_html(&mut self.writer, syntax)?; 122 - 123 - // Record offset mapping BEFORE end_node (which clears current_node_id) 124 - self.record_mapping(range.clone(), char_start..char_end); 125 - self.last_char_offset = char_end; 126 - self.last_byte_offset = range.end; 127 - 128 - if created_node { 129 - self.write("</span>")?; 130 - self.end_node(); 131 - } 132 - } else { 133 - // Real syntax - wrap in hideable span 134 - let syntax_type = classify_syntax(syntax); 135 - let class = match syntax_type { 136 - SyntaxType::Inline => "md-syntax-inline", 137 - SyntaxType::Block => "md-syntax-block", 138 - }; 139 - 140 - // Generate unique ID for this syntax span 141 - let syn_id = self.gen_syn_id(); 142 - 143 - // If we're outside any node, create a wrapper span for tracking 144 - let created_node = if self.current_node_id.is_none() { 145 - let node_id = self.gen_node_id(); 146 - write!( 147 - &mut self.writer, 148 - "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 149 - node_id, class, syn_id, char_start, char_end 150 - )?; 151 - self.begin_node(node_id); 152 - true 153 - } else { 154 - write!( 155 - &mut self.writer, 156 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 157 - class, syn_id, char_start, char_end 158 - )?; 159 - false 160 - }; 161 - 162 - escape_html(&mut self.writer, syntax)?; 163 - self.write("</span>")?; 164 - 165 - // Record syntax span info for visibility toggling 166 - self.syntax_spans.push(SyntaxSpanInfo { 167 - syn_id, 168 - char_range: char_start..char_end, 169 - syntax_type, 170 - formatted_range: None, 171 - }); 172 - 173 - // Record offset mapping for this syntax 174 - self.record_mapping(range.clone(), char_start..char_end); 175 - self.last_char_offset = char_end; 176 - self.last_byte_offset = range.end; 177 - 178 - // Close wrapper if we created one 179 - if created_node { 180 - self.write("</span>")?; 181 - self.end_node(); 182 - } 183 - } 184 - } 185 - } 186 - Ok(()) 187 - } 188 - 189 - /// Emit syntax span inside current node with full offset tracking. 190 - /// 191 - /// Use this for syntax markers that appear inside block elements (headings, lists, 192 - /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates 193 - /// wrapper nodes, this assumes we're already inside a tracked node. 194 - /// 195 - /// - Writes `<span class="md-syntax-{class}">{syntax}</span>` 196 - /// - Records offset mapping (for cursor positioning) 197 - /// - Updates both `last_char_offset` and `last_byte_offset` 198 - pub(crate) fn emit_inner_syntax( 199 - &mut self, 200 - syntax: &str, 201 - byte_start: usize, 202 - syntax_type: SyntaxType, 203 - ) -> Result<(), fmt::Error> { 204 - if syntax.is_empty() { 205 - return Ok(()); 206 - } 207 - 208 - let char_start = self.last_char_offset; 209 - let syntax_char_len = syntax.chars().count(); 210 - let char_end = char_start + syntax_char_len; 211 - let byte_end = byte_start + syntax.len(); 212 - 213 - let class_str = match syntax_type { 214 - SyntaxType::Inline => "md-syntax-inline", 215 - SyntaxType::Block => "md-syntax-block", 216 - }; 217 - 218 - // Generate unique ID for this syntax span 219 - let syn_id = self.gen_syn_id(); 220 - 221 - write!( 222 - &mut self.writer, 223 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 224 - class_str, syn_id, char_start, char_end 225 - )?; 226 - escape_html(&mut self.writer, syntax)?; 227 - self.write("</span>")?; 228 - 229 - // Record syntax span info for visibility toggling 230 - self.syntax_spans.push(SyntaxSpanInfo { 231 - syn_id, 232 - char_range: char_start..char_end, 233 - syntax_type, 234 - formatted_range: None, 235 - }); 236 - 237 - // Record offset mapping for cursor positioning 238 - self.record_mapping(byte_start..byte_end, char_start..char_end); 239 - 240 - self.last_char_offset = char_end; 241 - self.last_byte_offset = byte_end; 242 - 243 - Ok(()) 244 - } 245 - 246 - /// Emit any gap between last position and next offset 247 - pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { 248 - // Skip gap emission if we're inside a table being rendered as markdown 249 - if self.table_start_offset.is_some() && self.render_tables_as_markdown { 250 - return Ok(()); 251 - } 252 - 253 - // Skip gap emission if we're buffering code block content 254 - // The code block handler manages its own syntax emission 255 - if self.code_buffer.is_some() { 256 - return Ok(()); 257 - } 258 - 259 - if next_offset > self.last_byte_offset { 260 - self.emit_syntax(self.last_byte_offset..next_offset)?; 261 - } 262 - Ok(()) 263 - } 264 - }
···
-1509
crates/weaver-app/src/components/editor/writer/tags.rs
··· 1 - use core::fmt; 2 - use std::ops::Range; 3 - 4 - use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag}; 5 - use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 6 - 7 - use crate::components::editor::{ 8 - OffsetMapping, SyntaxSpanInfo, SyntaxType, 9 - writer::{ 10 - EditorWriter, TableState, 11 - embed::{EmbedContentProvider, ImageResolver}, 12 - syntax::classify_syntax, 13 - }, 14 - }; 15 - 16 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 17 - EditorWriter<'a, I, E, R> 18 - { 19 - /// Detect text direction by scanning source from a byte offset. 20 - /// Looks for the first strong directional character. 21 - /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found. 22 - fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> { 23 - if start_byte >= self.source.len() { 24 - return None; 25 - } 26 - 27 - // Scan from start_byte through the source looking for first strong directional char 28 - let text = &self.source[start_byte..]; 29 - weaver_renderer::utils::detect_text_direction(text) 30 - } 31 - 32 - pub(crate) fn start_tag( 33 - &mut self, 34 - tag: Tag<'_>, 35 - range: Range<usize>, 36 - ) -> Result<(), fmt::Error> { 37 - // Check if this is a block-level tag that should have syntax inside 38 - let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); 39 - 40 - // For inline tags, emit syntax before tag 41 - if !is_block_tag && range.start < range.end { 42 - let raw_text = &self.source[range.clone()]; 43 - let opening_syntax = match &tag { 44 - Tag::Strong => { 45 - if raw_text.starts_with("**") { 46 - Some("**") 47 - } else if raw_text.starts_with("__") { 48 - Some("__") 49 - } else { 50 - None 51 - } 52 - } 53 - Tag::Emphasis => { 54 - if raw_text.starts_with("*") { 55 - Some("*") 56 - } else if raw_text.starts_with("_") { 57 - Some("_") 58 - } else { 59 - None 60 - } 61 - } 62 - Tag::Strikethrough => { 63 - if raw_text.starts_with("~~") { 64 - Some("~~") 65 - } else { 66 - None 67 - } 68 - } 69 - Tag::Link { link_type, .. } => { 70 - if matches!(link_type, LinkType::WikiLink { .. }) { 71 - if raw_text.starts_with("[[") { 72 - Some("[[") 73 - } else { 74 - None 75 - } 76 - } else if raw_text.starts_with('[') { 77 - Some("[") 78 - } else { 79 - None 80 - } 81 - } 82 - // Note: Tag::Image and Tag::Embed handle their own syntax spans 83 - // in their respective handlers, so don't emit here 84 - _ => None, 85 - }; 86 - 87 - if let Some(syntax) = opening_syntax { 88 - let syntax_type = classify_syntax(syntax); 89 - let class = match syntax_type { 90 - SyntaxType::Inline => "md-syntax-inline", 91 - SyntaxType::Block => "md-syntax-block", 92 - }; 93 - 94 - let char_start = self.last_char_offset; 95 - let syntax_char_len = syntax.chars().count(); 96 - let char_end = char_start + syntax_char_len; 97 - let syntax_byte_len = syntax.len(); 98 - 99 - // Generate unique ID for this syntax span 100 - let syn_id = self.gen_syn_id(); 101 - 102 - write!( 103 - &mut self.writer, 104 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 105 - class, syn_id, char_start, char_end 106 - )?; 107 - escape_html(&mut self.writer, syntax)?; 108 - self.write("</span>")?; 109 - 110 - // Record syntax span info for visibility toggling 111 - self.syntax_spans.push(SyntaxSpanInfo { 112 - syn_id: syn_id.clone(), 113 - char_range: char_start..char_end, 114 - syntax_type, 115 - formatted_range: None, // Will be updated when closing tag is emitted 116 - }); 117 - 118 - // Record offset mapping for cursor positioning 119 - // This is critical - without it, current_node_char_offset is wrong 120 - // and all subsequent cursor positions are shifted 121 - let byte_start = range.start; 122 - let byte_end = range.start + syntax_byte_len; 123 - self.record_mapping(byte_start..byte_end, char_start..char_end); 124 - 125 - // For paired inline syntax, track opening span for formatted_range 126 - if matches!( 127 - tag, 128 - Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } 129 - ) { 130 - self.pending_inline_formats.push((syn_id, char_start)); 131 - } 132 - 133 - // Update tracking - we've consumed this opening syntax 134 - self.last_char_offset = char_end; 135 - self.last_byte_offset = range.start + syntax_byte_len; 136 - } 137 - } 138 - 139 - // Emit the opening tag 140 - match tag { 141 - // HTML blocks get their own paragraph to try and corral them better 142 - Tag::HtmlBlock => { 143 - // Record paragraph start for boundary tracking 144 - // Skip if inside a list or footnote def - they own their paragraph boundary 145 - if self.list_depth == 0 && !self.in_footnote_def { 146 - self.current_paragraph_start = 147 - Some((self.last_byte_offset, self.last_char_offset)); 148 - } 149 - let node_id = self.gen_node_id(); 150 - 151 - if self.end_newline { 152 - write!( 153 - &mut self.writer, 154 - r#"<p id="{}" class="html-embed html-embed-block">"#, 155 - node_id 156 - )?; 157 - } else { 158 - write!( 159 - &mut self.writer, 160 - r#"<p id="{}" class="html-embed html-embed-block">"#, 161 - node_id 162 - )?; 163 - } 164 - self.begin_node(node_id.clone()); 165 - 166 - // Map the start position of the paragraph (before any content) 167 - // This allows cursor to be placed at the very beginning 168 - let para_start_char = self.last_char_offset; 169 - let mapping = OffsetMapping { 170 - byte_range: range.start..range.start, 171 - char_range: para_start_char..para_start_char, 172 - node_id, 173 - char_offset_in_node: 0, 174 - child_index: Some(0), // position before first child 175 - utf16_len: 0, 176 - }; 177 - self.offset_maps.push(mapping); 178 - 179 - Ok(()) 180 - } 181 - Tag::Paragraph(_) => { 182 - // Handle wrapper before block 183 - self.emit_wrapper_start()?; 184 - 185 - // Record paragraph start for boundary tracking 186 - // Skip if inside a list or footnote def - they own their paragraph boundary 187 - if self.list_depth == 0 && !self.in_footnote_def { 188 - self.current_paragraph_start = 189 - Some((self.last_byte_offset, self.last_char_offset)); 190 - } 191 - 192 - let node_id = self.gen_node_id(); 193 - 194 - // Detect text direction for this paragraph 195 - let dir = self.detect_paragraph_direction(self.last_byte_offset); 196 - 197 - if self.end_newline { 198 - if let Some(dir_value) = dir { 199 - write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?; 200 - } else { 201 - write!(&mut self.writer, "<p id=\"{}\">", node_id)?; 202 - } 203 - } else { 204 - if let Some(dir_value) = dir { 205 - write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?; 206 - } else { 207 - write!(&mut self.writer, "<p id=\"{}\">", node_id)?; 208 - } 209 - } 210 - self.begin_node(node_id.clone()); 211 - 212 - // Map the start position of the paragraph (before any content) 213 - // This allows cursor to be placed at the very beginning 214 - let para_start_char = self.last_char_offset; 215 - let mapping = OffsetMapping { 216 - byte_range: range.start..range.start, 217 - char_range: para_start_char..para_start_char, 218 - node_id, 219 - char_offset_in_node: 0, 220 - child_index: Some(0), // position before first child 221 - utf16_len: 0, 222 - }; 223 - self.offset_maps.push(mapping); 224 - 225 - // Emit > syntax if we're inside a blockquote 226 - if let Some(bq_range) = self.pending_blockquote_range.take() { 227 - if bq_range.start < bq_range.end { 228 - let raw_text = &self.source[bq_range.clone()]; 229 - if let Some(gt_pos) = raw_text.find('>') { 230 - // Extract > [!NOTE] or just > 231 - let after_gt = &raw_text[gt_pos + 1..]; 232 - let syntax_end = if after_gt.trim_start().starts_with("[!") { 233 - // Find the closing ] 234 - if let Some(close_bracket) = after_gt.find(']') { 235 - gt_pos + 1 + close_bracket + 1 236 - } else { 237 - gt_pos + 1 238 - } 239 - } else { 240 - // Just > and maybe a space 241 - (gt_pos + 1).min(raw_text.len()) 242 - }; 243 - 244 - let syntax = &raw_text[gt_pos..syntax_end]; 245 - let syntax_byte_start = bq_range.start + gt_pos; 246 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 247 - } 248 - } 249 - } 250 - Ok(()) 251 - } 252 - Tag::Heading { 253 - level, 254 - id, 255 - classes, 256 - attrs, 257 - } => { 258 - // Emit wrapper if pending (but don't close on heading end - wraps following block too) 259 - self.emit_wrapper_start()?; 260 - 261 - // Record paragraph start for boundary tracking 262 - // Treat headings as paragraph-level blocks 263 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 264 - 265 - if !self.end_newline { 266 - self.write_newline()?; 267 - } 268 - 269 - // Generate node ID for offset tracking 270 - let node_id = self.gen_node_id(); 271 - 272 - // Detect text direction for this heading 273 - let dir = self.detect_paragraph_direction(self.last_byte_offset); 274 - 275 - self.write("<")?; 276 - write!(&mut self.writer, "{}", level)?; 277 - 278 - // Add our tracking ID as data attribute (preserve user's id if present) 279 - self.write(" data-node-id=\"")?; 280 - self.write(&node_id)?; 281 - self.write("\"")?; 282 - 283 - if let Some(id) = id { 284 - self.write(" id=\"")?; 285 - escape_html(&mut self.writer, &id)?; 286 - self.write("\"")?; 287 - } 288 - if !classes.is_empty() { 289 - self.write(" class=\"")?; 290 - for (i, class) in classes.iter().enumerate() { 291 - if i > 0 { 292 - self.write(" ")?; 293 - } 294 - escape_html(&mut self.writer, class)?; 295 - } 296 - self.write("\"")?; 297 - } 298 - 299 - // Add dir attribute if text direction was detected 300 - if let Some(dir_value) = dir { 301 - self.write(" dir=\"")?; 302 - self.write(dir_value)?; 303 - self.write("\"")?; 304 - } 305 - 306 - for (attr, value) in attrs { 307 - self.write(" ")?; 308 - escape_html(&mut self.writer, &attr)?; 309 - if let Some(val) = value { 310 - self.write("=\"")?; 311 - escape_html(&mut self.writer, &val)?; 312 - self.write("\"")?; 313 - } else { 314 - self.write("=\"\"")?; 315 - } 316 - } 317 - self.write(">")?; 318 - 319 - // Begin node tracking for offset mapping 320 - self.begin_node(node_id.clone()); 321 - 322 - // Map the start position of the heading (before any content) 323 - // This allows cursor to be placed at the very beginning 324 - let heading_start_char = self.last_char_offset; 325 - let mapping = OffsetMapping { 326 - byte_range: range.start..range.start, 327 - char_range: heading_start_char..heading_start_char, 328 - node_id: node_id.clone(), 329 - char_offset_in_node: 0, 330 - child_index: Some(0), // position before first child 331 - utf16_len: 0, 332 - }; 333 - self.offset_maps.push(mapping); 334 - 335 - // Emit # syntax inside the heading tag 336 - if range.start < range.end { 337 - let raw_text = &self.source[range.clone()]; 338 - let count = level as usize; 339 - let pattern = "#".repeat(count); 340 - 341 - // Find where the # actually starts (might have leading whitespace) 342 - if let Some(hash_pos) = raw_text.find(&pattern) { 343 - // Extract "# " or "## " etc 344 - let syntax_end = (hash_pos + count + 1).min(raw_text.len()); 345 - let syntax = &raw_text[hash_pos..syntax_end]; 346 - let syntax_byte_start = range.start + hash_pos; 347 - 348 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 349 - } 350 - } 351 - Ok(()) 352 - } 353 - Tag::Table(alignments) => { 354 - if self.render_tables_as_markdown { 355 - // Store start offset and skip HTML rendering 356 - self.table_start_offset = Some(range.start); 357 - self.in_non_writing_block = true; // Suppress content output 358 - Ok(()) 359 - } else { 360 - self.emit_wrapper_start()?; 361 - self.table_alignments = alignments; 362 - self.write("<table>") 363 - } 364 - } 365 - Tag::TableHead => { 366 - if self.render_tables_as_markdown { 367 - Ok(()) // Skip HTML rendering 368 - } else { 369 - self.table_state = TableState::Head; 370 - self.table_cell_index = 0; 371 - self.write("<thead><tr>") 372 - } 373 - } 374 - Tag::TableRow => { 375 - if self.render_tables_as_markdown { 376 - Ok(()) // Skip HTML rendering 377 - } else { 378 - self.table_cell_index = 0; 379 - self.write("<tr>") 380 - } 381 - } 382 - Tag::TableCell => { 383 - if self.render_tables_as_markdown { 384 - Ok(()) // Skip HTML rendering 385 - } else { 386 - match self.table_state { 387 - TableState::Head => self.write("<th")?, 388 - TableState::Body => self.write("<td")?, 389 - } 390 - match self.table_alignments.get(self.table_cell_index) { 391 - Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 392 - Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 393 - Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 394 - _ => self.write(">"), 395 - } 396 - } 397 - } 398 - Tag::BlockQuote(kind) => { 399 - self.emit_wrapper_start()?; 400 - 401 - let class_str = match kind { 402 - None => "", 403 - Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", 404 - Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", 405 - Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", 406 - Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", 407 - Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", 408 - }; 409 - if self.end_newline { 410 - write!(&mut self.writer, "<blockquote{}>", class_str)?; 411 - } else { 412 - write!(&mut self.writer, "<blockquote{}>", class_str)?; 413 - } 414 - 415 - // Store range for emitting > inside the next paragraph 416 - self.pending_blockquote_range = Some(range); 417 - Ok(()) 418 - } 419 - Tag::CodeBlock(info) => { 420 - self.emit_wrapper_start()?; 421 - 422 - // Track code block as paragraph-level block 423 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 424 - 425 - if !self.end_newline { 426 - self.write_newline()?; 427 - } 428 - 429 - // Generate node ID for code block 430 - let node_id = self.gen_node_id(); 431 - 432 - match info { 433 - CodeBlockKind::Fenced(info) => { 434 - // Emit opening ```language and track both char and byte offsets 435 - if range.start < range.end { 436 - let raw_text = &self.source[range.clone()]; 437 - if let Some(fence_pos) = raw_text.find("```") { 438 - let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 439 - let syntax = &raw_text[fence_pos..fence_end]; 440 - let syntax_char_len = syntax.chars().count() + 1; // +1 for newline 441 - let syntax_byte_len = syntax.len() + 1; // +1 for newline 442 - 443 - let syn_id = self.gen_syn_id(); 444 - let char_start = self.last_char_offset; 445 - let char_end = char_start + syntax_char_len; 446 - 447 - write!( 448 - &mut self.writer, 449 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 450 - syn_id, char_start, char_end 451 - )?; 452 - escape_html(&mut self.writer, syntax)?; 453 - self.write("</span>")?; 454 - 455 - // Track opening span index for formatted_range update later 456 - self.code_block_opening_span_idx = Some(self.syntax_spans.len()); 457 - self.code_block_char_start = Some(char_start); 458 - 459 - self.syntax_spans.push(SyntaxSpanInfo { 460 - syn_id, 461 - char_range: char_start..char_end, 462 - syntax_type: SyntaxType::Block, 463 - formatted_range: None, // Will be set in TagEnd::CodeBlock 464 - }); 465 - 466 - self.last_char_offset += syntax_char_len; 467 - self.last_byte_offset = range.start + fence_pos + syntax_byte_len; 468 - } 469 - } 470 - 471 - let lang = info.split(' ').next().unwrap(); 472 - let lang_opt = if lang.is_empty() { 473 - None 474 - } else { 475 - Some(lang.to_string()) 476 - }; 477 - // Start buffering 478 - self.code_buffer = Some((lang_opt, String::new())); 479 - 480 - // Begin node tracking for offset mapping 481 - self.begin_node(node_id); 482 - Ok(()) 483 - } 484 - CodeBlockKind::Indented => { 485 - // Ignore indented code blocks (as per executive decision) 486 - self.code_buffer = Some((None, String::new())); 487 - 488 - // Begin node tracking for offset mapping 489 - self.begin_node(node_id); 490 - Ok(()) 491 - } 492 - } 493 - } 494 - Tag::List(Some(1)) => { 495 - self.emit_wrapper_start()?; 496 - // Track list as paragraph-level block 497 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 498 - self.list_depth += 1; 499 - if self.end_newline { 500 - self.write("<ol>") 501 - } else { 502 - self.write("<ol>") 503 - } 504 - } 505 - Tag::List(Some(start)) => { 506 - self.emit_wrapper_start()?; 507 - // Track list as paragraph-level block 508 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 509 - self.list_depth += 1; 510 - if self.end_newline { 511 - self.write("<ol start=\"")?; 512 - } else { 513 - self.write("<ol start=\"")?; 514 - } 515 - write!(&mut self.writer, "{}", start)?; 516 - self.write("\">") 517 - } 518 - Tag::List(None) => { 519 - self.emit_wrapper_start()?; 520 - // Track list as paragraph-level block 521 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 522 - self.list_depth += 1; 523 - if self.end_newline { 524 - self.write("<ul>") 525 - } else { 526 - self.write("<ul>") 527 - } 528 - } 529 - Tag::Item => { 530 - // Generate node ID for list item 531 - let node_id = self.gen_node_id(); 532 - 533 - if self.end_newline { 534 - write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?; 535 - } else { 536 - write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?; 537 - } 538 - 539 - // Begin node tracking 540 - self.begin_node(node_id); 541 - 542 - // Emit list marker syntax inside the <li> tag and track both offsets 543 - if range.start < range.end { 544 - let raw_text = &self.source[range.clone()]; 545 - 546 - // Try to find the list marker (-, *, or digit.) 547 - let trimmed = raw_text.trim_start(); 548 - let leading_ws_bytes = raw_text.len() - trimmed.len(); 549 - let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count(); 550 - 551 - if let Some(marker) = trimmed.chars().next() { 552 - if marker == '-' || marker == '*' { 553 - // Unordered list: extract "- " or "* " 554 - let marker_end = trimmed 555 - .find(|c: char| c != '-' && c != '*') 556 - .map(|pos| pos + 1) 557 - .unwrap_or(1); 558 - let syntax = &trimmed[..marker_end.min(trimmed.len())]; 559 - let char_start = self.last_char_offset; 560 - let syntax_char_len = leading_ws_chars + syntax.chars().count(); 561 - let syntax_byte_len = leading_ws_bytes + syntax.len(); 562 - let char_end = char_start + syntax_char_len; 563 - 564 - let syn_id = self.gen_syn_id(); 565 - write!( 566 - &mut self.writer, 567 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 568 - syn_id, char_start, char_end 569 - )?; 570 - escape_html(&mut self.writer, syntax)?; 571 - self.write("</span>")?; 572 - 573 - self.syntax_spans.push(SyntaxSpanInfo { 574 - syn_id, 575 - char_range: char_start..char_end, 576 - syntax_type: SyntaxType::Block, 577 - formatted_range: None, 578 - }); 579 - 580 - // Record offset mapping for cursor positioning 581 - self.record_mapping( 582 - range.start..range.start + syntax_byte_len, 583 - char_start..char_end, 584 - ); 585 - self.last_char_offset = char_end; 586 - self.last_byte_offset = range.start + syntax_byte_len; 587 - } else if marker.is_ascii_digit() { 588 - // Ordered list: extract "1. " or similar (including trailing space) 589 - if let Some(dot_pos) = trimmed.find('.') { 590 - let syntax_end = (dot_pos + 2).min(trimmed.len()); 591 - let syntax = &trimmed[..syntax_end]; 592 - let char_start = self.last_char_offset; 593 - let syntax_char_len = leading_ws_chars + syntax.chars().count(); 594 - let syntax_byte_len = leading_ws_bytes + syntax.len(); 595 - let char_end = char_start + syntax_char_len; 596 - 597 - let syn_id = self.gen_syn_id(); 598 - write!( 599 - &mut self.writer, 600 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 601 - syn_id, char_start, char_end 602 - )?; 603 - escape_html(&mut self.writer, syntax)?; 604 - self.write("</span>")?; 605 - 606 - self.syntax_spans.push(SyntaxSpanInfo { 607 - syn_id, 608 - char_range: char_start..char_end, 609 - syntax_type: SyntaxType::Block, 610 - formatted_range: None, 611 - }); 612 - 613 - // Record offset mapping for cursor positioning 614 - self.record_mapping( 615 - range.start..range.start + syntax_byte_len, 616 - char_start..char_end, 617 - ); 618 - self.last_char_offset = char_end; 619 - self.last_byte_offset = range.start + syntax_byte_len; 620 - } 621 - } 622 - } 623 - } 624 - Ok(()) 625 - } 626 - Tag::DefinitionList => { 627 - self.emit_wrapper_start()?; 628 - if self.end_newline { 629 - self.write("<dl>") 630 - } else { 631 - self.write("<dl>") 632 - } 633 - } 634 - Tag::DefinitionListTitle => { 635 - let node_id = self.gen_node_id(); 636 - 637 - if self.end_newline { 638 - write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?; 639 - } else { 640 - write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?; 641 - } 642 - 643 - self.begin_node(node_id); 644 - Ok(()) 645 - } 646 - Tag::DefinitionListDefinition => { 647 - let node_id = self.gen_node_id(); 648 - 649 - if self.end_newline { 650 - write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?; 651 - } else { 652 - write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?; 653 - } 654 - 655 - self.begin_node(node_id); 656 - Ok(()) 657 - } 658 - Tag::Subscript => self.write("<sub>"), 659 - Tag::Superscript => self.write("<sup>"), 660 - Tag::Emphasis => self.write("<em>"), 661 - Tag::Strong => self.write("<strong>"), 662 - Tag::Strikethrough => self.write("<s>"), 663 - Tag::Link { 664 - link_type: LinkType::Email, 665 - dest_url, 666 - title, 667 - .. 668 - } => { 669 - self.write("<a href=\"mailto:")?; 670 - escape_href(&mut self.writer, &dest_url)?; 671 - if !title.is_empty() { 672 - self.write("\" title=\"")?; 673 - escape_html(&mut self.writer, &title)?; 674 - } 675 - self.write("\">") 676 - } 677 - Tag::Link { 678 - link_type, 679 - dest_url, 680 - title, 681 - .. 682 - } => { 683 - // Collect refs for later resolution 684 - let url = dest_url.as_ref(); 685 - if matches!(link_type, LinkType::WikiLink { .. }) { 686 - let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url); 687 - self.ref_collector.add_wikilink(target, fragment, None); 688 - } else if url.starts_with("at://") { 689 - self.ref_collector.add_at_link(url); 690 - } 691 - 692 - // Determine link validity class for wikilinks 693 - let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) { 694 - if let Some(index) = &self.entry_index { 695 - if index.resolve(dest_url.as_ref()).is_some() { 696 - " link-valid" 697 - } else { 698 - " link-broken" 699 - } 700 - } else { 701 - "" 702 - } 703 - } else { 704 - "" 705 - }; 706 - 707 - self.write("<a class=\"link")?; 708 - self.write(validity_class)?; 709 - self.write("\" href=\"")?; 710 - escape_href(&mut self.writer, &dest_url)?; 711 - if !title.is_empty() { 712 - self.write("\" title=\"")?; 713 - escape_html(&mut self.writer, &title)?; 714 - } 715 - self.write("\">") 716 - } 717 - Tag::Image { 718 - link_type, 719 - dest_url, 720 - title, 721 - id, 722 - attrs, 723 - } => { 724 - // Check if this is actually an AT embed disguised as a wikilink image 725 - // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type) 726 - let url = dest_url.as_ref(); 727 - if matches!(link_type, LinkType::WikiLink { .. }) 728 - && (url.starts_with("at://") || url.starts_with("did:")) 729 - { 730 - return self.write_embed( 731 - range, 732 - EmbedType::Other, // AT embeds - disambiguated via NSID later 733 - dest_url, 734 - title, 735 - id, 736 - attrs, 737 - ); 738 - } 739 - 740 - // Image rendering: all syntax elements share one syn_id for visibility toggling 741 - // Structure: ![ alt text ](url) <img> cursor-landing 742 - let raw_text = &self.source[range.clone()]; 743 - let syn_id = self.gen_syn_id(); 744 - let opening_char_start = self.last_char_offset; 745 - 746 - // Find the alt text and closing syntax positions 747 - let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len()); 748 - let alt_text = if raw_text.starts_with("![") && paren_pos > 2 { 749 - &raw_text[2..paren_pos] 750 - } else { 751 - "" 752 - }; 753 - let closing_syntax = if paren_pos < raw_text.len() { 754 - &raw_text[paren_pos..] 755 - } else { 756 - "" 757 - }; 758 - 759 - // Calculate char positions 760 - let alt_char_len = alt_text.chars().count(); 761 - let closing_char_len = closing_syntax.chars().count(); 762 - let opening_char_end = opening_char_start + 2; // "![" 763 - let alt_char_start = opening_char_end; 764 - let alt_char_end = alt_char_start + alt_char_len; 765 - let closing_char_start = alt_char_end; 766 - let closing_char_end = closing_char_start + closing_char_len; 767 - let formatted_range = opening_char_start..closing_char_end; 768 - 769 - // 1. Emit opening ![ syntax span 770 - if raw_text.starts_with("![") { 771 - write!( 772 - &mut self.writer, 773 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![</span>", 774 - syn_id, opening_char_start, opening_char_end 775 - )?; 776 - 777 - self.syntax_spans.push(SyntaxSpanInfo { 778 - syn_id: syn_id.clone(), 779 - char_range: opening_char_start..opening_char_end, 780 - syntax_type: SyntaxType::Inline, 781 - formatted_range: Some(formatted_range.clone()), 782 - }); 783 - 784 - // Record offset mapping for ![ 785 - self.record_mapping( 786 - range.start..range.start + 2, 787 - opening_char_start..opening_char_end, 788 - ); 789 - } 790 - 791 - // 2. Emit alt text span (same syn_id, editable when visible) 792 - if !alt_text.is_empty() { 793 - write!( 794 - &mut self.writer, 795 - "<span class=\"image-alt\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 796 - syn_id, alt_char_start, alt_char_end 797 - )?; 798 - escape_html(&mut self.writer, alt_text)?; 799 - self.write("</span>")?; 800 - 801 - self.syntax_spans.push(SyntaxSpanInfo { 802 - syn_id: syn_id.clone(), 803 - char_range: alt_char_start..alt_char_end, 804 - syntax_type: SyntaxType::Inline, 805 - formatted_range: Some(formatted_range.clone()), 806 - }); 807 - 808 - // Record offset mapping for alt text 809 - self.record_mapping( 810 - range.start + 2..range.start + 2 + alt_text.len(), 811 - alt_char_start..alt_char_end, 812 - ); 813 - } 814 - 815 - // 3. Emit closing ](url) syntax span 816 - if !closing_syntax.is_empty() { 817 - write!( 818 - &mut self.writer, 819 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 820 - syn_id, closing_char_start, closing_char_end 821 - )?; 822 - escape_html(&mut self.writer, closing_syntax)?; 823 - self.write("</span>")?; 824 - 825 - self.syntax_spans.push(SyntaxSpanInfo { 826 - syn_id: syn_id.clone(), 827 - char_range: closing_char_start..closing_char_end, 828 - syntax_type: SyntaxType::Inline, 829 - formatted_range: Some(formatted_range.clone()), 830 - }); 831 - 832 - // Record offset mapping for ](url) 833 - self.record_mapping( 834 - range.start + paren_pos..range.end, 835 - closing_char_start..closing_char_end, 836 - ); 837 - } 838 - 839 - // 4. Emit <img> element (no syn_id - always visible) 840 - self.write("<img src=\"")?; 841 - let resolved_url = self 842 - .image_resolver 843 - .as_ref() 844 - .and_then(|r| r.resolve_image_url(&dest_url)); 845 - if let Some(ref cdn_url) = resolved_url { 846 - escape_href(&mut self.writer, cdn_url)?; 847 - } else { 848 - escape_href(&mut self.writer, &dest_url)?; 849 - } 850 - self.write("\" alt=\"")?; 851 - escape_html(&mut self.writer, alt_text)?; 852 - self.write("\"")?; 853 - if !title.is_empty() { 854 - self.write(" title=\"")?; 855 - escape_html(&mut self.writer, &title)?; 856 - self.write("\"")?; 857 - } 858 - if let Some(attrs) = attrs { 859 - if !attrs.classes.is_empty() { 860 - self.write(" class=\"")?; 861 - for (i, class) in attrs.classes.iter().enumerate() { 862 - if i > 0 { 863 - self.write(" ")?; 864 - } 865 - escape_html(&mut self.writer, class)?; 866 - } 867 - self.write("\"")?; 868 - } 869 - for (attr, value) in &attrs.attrs { 870 - self.write(" ")?; 871 - escape_html(&mut self.writer, attr)?; 872 - self.write("=\"")?; 873 - escape_html(&mut self.writer, value)?; 874 - self.write("\"")?; 875 - } 876 - } 877 - self.write(" />")?; 878 - 879 - // Consume the text events for alt (they're still in the iterator) 880 - // Use consume_until_end() since we already wrote alt text from source 881 - self.consume_until_end(); 882 - 883 - // Update offsets 884 - self.last_char_offset = closing_char_end; 885 - self.last_byte_offset = range.end; 886 - 887 - Ok(()) 888 - } 889 - Tag::Embed { 890 - embed_type, 891 - dest_url, 892 - title, 893 - id, 894 - attrs, 895 - } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 896 - Tag::WeaverBlock(_, attrs) => { 897 - self.in_non_writing_block = true; 898 - self.weaver_block_buffer.clear(); 899 - self.weaver_block_char_start = Some(self.last_char_offset); 900 - // Store attrs from Start tag, will merge with parsed text on End 901 - if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 902 - self.pending_block_attrs = Some(attrs.into_static()); 903 - } 904 - Ok(()) 905 - } 906 - Tag::FootnoteDefinition(name) => { 907 - // Track as paragraph-level block for incremental rendering 908 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 909 - // Suppress inner paragraph boundaries (footnote def owns its paragraph) 910 - self.in_footnote_def = true; 911 - 912 - if !self.end_newline { 913 - self.write_newline()?; 914 - } 915 - 916 - // Generate node ID for cursor tracking 917 - let node_id = self.gen_node_id(); 918 - 919 - // Emit wrapper div with NEW class (not footnote-definition which has order:9999) 920 - // This keeps footnotes in-place instead of reordering to bottom 921 - write!( 922 - &mut self.writer, 923 - "<div class=\"footnote-def-editor\" data-node-id=\"{}\">", 924 - node_id 925 - )?; 926 - 927 - // Begin node tracking BEFORE emitting prefix 928 - self.begin_node(node_id.clone()); 929 - 930 - // Map the start position (before any content) 931 - let fn_start_char = self.last_char_offset; 932 - let mapping = OffsetMapping { 933 - byte_range: range.start..range.start, 934 - char_range: fn_start_char..fn_start_char, 935 - node_id, 936 - char_offset_in_node: 0, 937 - child_index: Some(0), 938 - utf16_len: 0, 939 - }; 940 - self.offset_maps.push(mapping); 941 - 942 - // Extract ACTUAL prefix from source (not constructed string) 943 - // This ensures byte offsets match reality 944 - let raw_text = &self.source[range.clone()]; 945 - let prefix_end = raw_text 946 - .find("]:") 947 - .map(|p| { 948 - // Include ]: and any single trailing space 949 - let after_colon = p + 2; 950 - if raw_text.get(after_colon..after_colon + 1) == Some(" ") { 951 - after_colon + 1 952 - } else { 953 - after_colon 954 - } 955 - }) 956 - .unwrap_or(0); 957 - let prefix = &raw_text[..prefix_end]; 958 - let prefix_byte_len = prefix.len(); 959 - let prefix_char_len = prefix.chars().count(); 960 - 961 - let char_start = self.last_char_offset; 962 - let char_end = char_start + prefix_char_len; 963 - 964 - write!( 965 - &mut self.writer, 966 - "<span class=\"footnote-def-syntax\" data-char-start=\"{}\" data-char-end=\"{}\">", 967 - char_start, char_end 968 - )?; 969 - escape_html(&mut self.writer, prefix)?; 970 - self.write("</span>")?; 971 - 972 - // Store the definition info (no longer tracking syntax spans for hide/show) 973 - self.current_footnote_def = Some((name.to_string(), 0, char_start)); 974 - 975 - // Record offset mapping for the prefix 976 - self.record_mapping( 977 - range.start..range.start + prefix_byte_len, 978 - char_start..char_end, 979 - ); 980 - 981 - // Update tracking for the prefix 982 - self.last_char_offset = char_end; 983 - self.last_byte_offset = range.start + prefix_byte_len; 984 - 985 - Ok(()) 986 - } 987 - Tag::MetadataBlock(_) => { 988 - self.in_non_writing_block = true; 989 - Ok(()) 990 - } 991 - } 992 - } 993 - 994 - pub(crate) fn end_tag( 995 - &mut self, 996 - tag: markdown_weaver::TagEnd, 997 - range: Range<usize>, 998 - ) -> Result<(), fmt::Error> { 999 - use markdown_weaver::TagEnd; 1000 - 1001 - // Emit tag HTML first 1002 - let result = match tag { 1003 - TagEnd::HtmlBlock => { 1004 - // Capture paragraph boundary info BEFORE writing closing HTML 1005 - // Skip if inside a list or footnote def - they own their paragraph boundary 1006 - let para_boundary = if self.list_depth == 0 && !self.in_footnote_def { 1007 - self.current_paragraph_start 1008 - .take() 1009 - .map(|(byte_start, char_start)| { 1010 - ( 1011 - byte_start..self.last_byte_offset, 1012 - char_start..self.last_char_offset, 1013 - ) 1014 - }) 1015 - } else { 1016 - None 1017 - }; 1018 - 1019 - // Write closing HTML to current segment 1020 - self.end_node(); 1021 - self.write("</p>")?; 1022 - 1023 - // Now finalize paragraph (starts new segment) 1024 - if let Some((byte_range, char_range)) = para_boundary { 1025 - self.finalize_paragraph(byte_range, char_range); 1026 - } 1027 - Ok(()) 1028 - } 1029 - TagEnd::Paragraph(_) => { 1030 - // Capture paragraph boundary info BEFORE writing closing HTML 1031 - // Skip if inside a list or footnote def - they own their paragraph boundary 1032 - let para_boundary = if self.list_depth == 0 && !self.in_footnote_def { 1033 - self.current_paragraph_start 1034 - .take() 1035 - .map(|(byte_start, char_start)| { 1036 - ( 1037 - byte_start..self.last_byte_offset, 1038 - char_start..self.last_char_offset, 1039 - ) 1040 - }) 1041 - } else { 1042 - None 1043 - }; 1044 - 1045 - // Write closing HTML to current segment 1046 - self.end_node(); 1047 - self.write("</p>")?; 1048 - self.close_wrapper()?; 1049 - 1050 - // Now finalize paragraph (starts new segment) 1051 - if let Some((byte_range, char_range)) = para_boundary { 1052 - self.finalize_paragraph(byte_range, char_range); 1053 - } 1054 - Ok(()) 1055 - } 1056 - TagEnd::Heading(level) => { 1057 - // Capture paragraph boundary info BEFORE writing closing HTML 1058 - let para_boundary = 1059 - self.current_paragraph_start 1060 - .take() 1061 - .map(|(byte_start, char_start)| { 1062 - ( 1063 - byte_start..self.last_byte_offset, 1064 - char_start..self.last_char_offset, 1065 - ) 1066 - }); 1067 - 1068 - // Write closing HTML to current segment 1069 - self.end_node(); 1070 - self.write("</")?; 1071 - write!(&mut self.writer, "{}", level)?; 1072 - self.write(">")?; 1073 - // Note: Don't close wrapper here - headings typically go with following block 1074 - 1075 - // Now finalize paragraph (starts new segment) 1076 - if let Some((byte_range, char_range)) = para_boundary { 1077 - self.finalize_paragraph(byte_range, char_range); 1078 - } 1079 - Ok(()) 1080 - } 1081 - TagEnd::Table => { 1082 - if self.render_tables_as_markdown { 1083 - // Emit the raw markdown table 1084 - if let Some(start) = self.table_start_offset.take() { 1085 - let table_text = &self.source[start..range.end]; 1086 - self.in_non_writing_block = false; 1087 - 1088 - // Wrap in a pre or div for styling 1089 - self.write("<pre class=\"table-markdown\">")?; 1090 - escape_html(&mut self.writer, table_text)?; 1091 - self.write("</pre>")?; 1092 - } 1093 - Ok(()) 1094 - } else { 1095 - self.write("</tbody></table>") 1096 - } 1097 - } 1098 - TagEnd::TableHead => { 1099 - if self.render_tables_as_markdown { 1100 - Ok(()) // Skip HTML rendering 1101 - } else { 1102 - self.write("</tr></thead><tbody>")?; 1103 - self.table_state = TableState::Body; 1104 - Ok(()) 1105 - } 1106 - } 1107 - TagEnd::TableRow => { 1108 - if self.render_tables_as_markdown { 1109 - Ok(()) // Skip HTML rendering 1110 - } else { 1111 - self.write("</tr>") 1112 - } 1113 - } 1114 - TagEnd::TableCell => { 1115 - if self.render_tables_as_markdown { 1116 - Ok(()) // Skip HTML rendering 1117 - } else { 1118 - match self.table_state { 1119 - TableState::Head => self.write("</th>")?, 1120 - TableState::Body => self.write("</td>")?, 1121 - } 1122 - self.table_cell_index += 1; 1123 - Ok(()) 1124 - } 1125 - } 1126 - TagEnd::BlockQuote(_) => { 1127 - // If pending_blockquote_range is still set, the blockquote was empty 1128 - // (no paragraph inside). Emit the > as its own minimal paragraph. 1129 - let mut para_boundary = None; 1130 - if let Some(bq_range) = self.pending_blockquote_range.take() { 1131 - if bq_range.start < bq_range.end { 1132 - let raw_text = &self.source[bq_range.clone()]; 1133 - if let Some(gt_pos) = raw_text.find('>') { 1134 - let para_byte_start = bq_range.start + gt_pos; 1135 - let para_char_start = self.last_char_offset; 1136 - 1137 - // Create a minimal paragraph for the empty blockquote 1138 - let node_id = self.gen_node_id(); 1139 - write!(&mut self.writer, "<div id=\"{}\"", node_id)?; 1140 - 1141 - // Record start-of-node mapping for cursor positioning 1142 - self.offset_maps.push(OffsetMapping { 1143 - byte_range: para_byte_start..para_byte_start, 1144 - char_range: para_char_start..para_char_start, 1145 - node_id: node_id.clone(), 1146 - char_offset_in_node: gt_pos, 1147 - child_index: Some(0), 1148 - utf16_len: 0, 1149 - }); 1150 - 1151 - // Emit the > as block syntax 1152 - let syntax = &raw_text[gt_pos..gt_pos + 1]; 1153 - self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; 1154 - 1155 - self.write("</div>")?; 1156 - self.end_node(); 1157 - 1158 - // Capture paragraph boundary for later finalization 1159 - let byte_range = para_byte_start..bq_range.end; 1160 - let char_range = para_char_start..self.last_char_offset; 1161 - para_boundary = Some((byte_range, char_range)); 1162 - } 1163 - } 1164 - } 1165 - self.write("</blockquote>")?; 1166 - self.close_wrapper()?; 1167 - 1168 - // Now finalize paragraph if we had one 1169 - if let Some((byte_range, char_range)) = para_boundary { 1170 - self.finalize_paragraph(byte_range, char_range); 1171 - } 1172 - Ok(()) 1173 - } 1174 - TagEnd::CodeBlock => { 1175 - use std::sync::LazyLock; 1176 - use syntect::parsing::SyntaxSet; 1177 - static SYNTAX_SET: LazyLock<SyntaxSet> = 1178 - LazyLock::new(|| SyntaxSet::load_defaults_newlines()); 1179 - 1180 - if let Some((lang, buffer)) = self.code_buffer.take() { 1181 - // Create offset mapping for code block content if we tracked ranges 1182 - if let (Some(code_byte_range), Some(code_char_range)) = ( 1183 - self.code_buffer_byte_range.take(), 1184 - self.code_buffer_char_range.take(), 1185 - ) { 1186 - // Record mapping before writing HTML 1187 - // (current_node_id should be set by start_tag for CodeBlock) 1188 - self.record_mapping(code_byte_range, code_char_range); 1189 - } 1190 - 1191 - // Get node_id for data-node-id attribute (needed for cursor positioning) 1192 - let node_id = self.current_node_id.clone(); 1193 - 1194 - if let Some(ref lang_str) = lang { 1195 - // Use a temporary String buffer for syntect 1196 - let mut temp_output = String::new(); 1197 - match weaver_renderer::code_pretty::highlight( 1198 - &SYNTAX_SET, 1199 - Some(lang_str), 1200 - &buffer, 1201 - &mut temp_output, 1202 - ) { 1203 - Ok(_) => { 1204 - // Inject data-node-id into the <pre> tag for cursor positioning 1205 - if let Some(ref nid) = node_id { 1206 - let injected = temp_output.replacen( 1207 - "<pre>", 1208 - &format!("<pre data-node-id=\"{}\">", nid), 1209 - 1, 1210 - ); 1211 - self.write(&injected)?; 1212 - } else { 1213 - self.write(&temp_output)?; 1214 - } 1215 - } 1216 - Err(_) => { 1217 - // Fallback to plain code block 1218 - if let Some(ref nid) = node_id { 1219 - write!( 1220 - &mut self.writer, 1221 - "<pre data-node-id=\"{}\"><code class=\"language-", 1222 - nid 1223 - )?; 1224 - } else { 1225 - self.write("<pre><code class=\"language-")?; 1226 - } 1227 - escape_html(&mut self.writer, lang_str)?; 1228 - self.write("\">")?; 1229 - escape_html_body_text(&mut self.writer, &buffer)?; 1230 - self.write("</code></pre>")?; 1231 - } 1232 - } 1233 - } else { 1234 - if let Some(ref nid) = node_id { 1235 - write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?; 1236 - } else { 1237 - self.write("<pre><code>")?; 1238 - } 1239 - escape_html_body_text(&mut self.writer, &buffer)?; 1240 - self.write("</code></pre>")?; 1241 - } 1242 - 1243 - // End node tracking 1244 - self.end_node(); 1245 - } else { 1246 - self.write("</code></pre>")?; 1247 - } 1248 - 1249 - // Emit closing ``` (emit_gap_before is skipped while buffering) 1250 - // Track the opening span index and char start before we potentially clear them 1251 - let opening_span_idx = self.code_block_opening_span_idx.take(); 1252 - let code_block_start = self.code_block_char_start.take(); 1253 - 1254 - if range.start < range.end { 1255 - let raw_text = &self.source[range.clone()]; 1256 - if let Some(fence_line) = raw_text.lines().last() { 1257 - if fence_line.trim().starts_with("```") { 1258 - let fence = fence_line.trim(); 1259 - let fence_char_len = fence.chars().count(); 1260 - 1261 - let syn_id = self.gen_syn_id(); 1262 - let char_start = self.last_char_offset; 1263 - let char_end = char_start + fence_char_len; 1264 - 1265 - write!( 1266 - &mut self.writer, 1267 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1268 - syn_id, char_start, char_end 1269 - )?; 1270 - escape_html(&mut self.writer, fence)?; 1271 - self.write("</span>")?; 1272 - 1273 - self.last_char_offset += fence_char_len; 1274 - self.last_byte_offset += fence.len(); 1275 - 1276 - // Compute formatted_range for entire code block (opening fence to closing fence) 1277 - let formatted_range = 1278 - code_block_start.map(|start| start..self.last_char_offset); 1279 - 1280 - // Update opening fence span with formatted_range 1281 - if let (Some(idx), Some(fr)) = 1282 - (opening_span_idx, formatted_range.as_ref()) 1283 - { 1284 - if let Some(span) = self.syntax_spans.get_mut(idx) { 1285 - span.formatted_range = Some(fr.clone()); 1286 - } 1287 - } 1288 - 1289 - // Push closing fence span with formatted_range 1290 - self.syntax_spans.push(SyntaxSpanInfo { 1291 - syn_id, 1292 - char_range: char_start..char_end, 1293 - syntax_type: SyntaxType::Block, 1294 - formatted_range, 1295 - }); 1296 - } 1297 - } 1298 - } 1299 - 1300 - // Finalize code block paragraph 1301 - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1302 - let byte_range = byte_start..self.last_byte_offset; 1303 - let char_range = char_start..self.last_char_offset; 1304 - self.finalize_paragraph(byte_range, char_range); 1305 - } 1306 - 1307 - Ok(()) 1308 - } 1309 - TagEnd::List(true) => { 1310 - self.list_depth = self.list_depth.saturating_sub(1); 1311 - // Capture paragraph boundary BEFORE writing closing HTML 1312 - let para_boundary = 1313 - self.current_paragraph_start 1314 - .take() 1315 - .map(|(byte_start, char_start)| { 1316 - ( 1317 - byte_start..self.last_byte_offset, 1318 - char_start..self.last_char_offset, 1319 - ) 1320 - }); 1321 - 1322 - self.write("</ol>")?; 1323 - self.close_wrapper()?; 1324 - 1325 - // Finalize paragraph after closing HTML 1326 - if let Some((byte_range, char_range)) = para_boundary { 1327 - self.finalize_paragraph(byte_range, char_range); 1328 - } 1329 - Ok(()) 1330 - } 1331 - TagEnd::List(false) => { 1332 - self.list_depth = self.list_depth.saturating_sub(1); 1333 - // Capture paragraph boundary BEFORE writing closing HTML 1334 - let para_boundary = 1335 - self.current_paragraph_start 1336 - .take() 1337 - .map(|(byte_start, char_start)| { 1338 - ( 1339 - byte_start..self.last_byte_offset, 1340 - char_start..self.last_char_offset, 1341 - ) 1342 - }); 1343 - 1344 - self.write("</ul>")?; 1345 - self.close_wrapper()?; 1346 - 1347 - // Finalize paragraph after closing HTML 1348 - if let Some((byte_range, char_range)) = para_boundary { 1349 - self.finalize_paragraph(byte_range, char_range); 1350 - } 1351 - Ok(()) 1352 - } 1353 - TagEnd::Item => { 1354 - self.end_node(); 1355 - self.write("</li>") 1356 - } 1357 - TagEnd::DefinitionList => { 1358 - self.write("</dl>")?; 1359 - self.close_wrapper() 1360 - } 1361 - TagEnd::DefinitionListTitle => { 1362 - self.end_node(); 1363 - self.write("</dt>") 1364 - } 1365 - TagEnd::DefinitionListDefinition => { 1366 - self.end_node(); 1367 - self.write("</dd>") 1368 - } 1369 - TagEnd::Emphasis => { 1370 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1371 - self.write("</em>")?; 1372 - self.emit_gap_before(range.end)?; 1373 - self.finalize_paired_inline_format(); 1374 - Ok(()) 1375 - } 1376 - TagEnd::Superscript => self.write("</sup>"), 1377 - TagEnd::Subscript => self.write("</sub>"), 1378 - TagEnd::Strong => { 1379 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1380 - self.write("</strong>")?; 1381 - self.emit_gap_before(range.end)?; 1382 - self.finalize_paired_inline_format(); 1383 - Ok(()) 1384 - } 1385 - TagEnd::Strikethrough => { 1386 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1387 - self.write("</s>")?; 1388 - self.emit_gap_before(range.end)?; 1389 - self.finalize_paired_inline_format(); 1390 - Ok(()) 1391 - } 1392 - TagEnd::Link => { 1393 - self.write("</a>")?; 1394 - // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) 1395 - let raw_text = &self.source[range.clone()]; 1396 - if raw_text.ends_with("]]") { 1397 - // WikiLink: emit ]] as closing syntax 1398 - let syn_id = self.gen_syn_id(); 1399 - let char_start = self.last_char_offset; 1400 - let char_end = char_start + 2; 1401 - 1402 - write!( 1403 - &mut self.writer, 1404 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 1405 - syn_id, char_start, char_end 1406 - )?; 1407 - 1408 - self.syntax_spans.push(SyntaxSpanInfo { 1409 - syn_id, 1410 - char_range: char_start..char_end, 1411 - syntax_type: SyntaxType::Inline, 1412 - formatted_range: None, // Will be set by finalize 1413 - }); 1414 - 1415 - self.last_char_offset = char_end; 1416 - self.last_byte_offset = range.end; 1417 - } else { 1418 - self.emit_gap_before(range.end)?; 1419 - } 1420 - self.finalize_paired_inline_format(); 1421 - Ok(()) 1422 - } 1423 - TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 1424 - TagEnd::Embed => Ok(()), 1425 - TagEnd::WeaverBlock(_) => { 1426 - self.in_non_writing_block = false; 1427 - 1428 - // Emit the { content } as a hideable syntax span 1429 - if let Some(char_start) = self.weaver_block_char_start.take() { 1430 - // Build the full syntax text: { buffered_content } 1431 - let syntax_text = format!("{{{}}}", self.weaver_block_buffer); 1432 - let syntax_char_len = syntax_text.chars().count(); 1433 - let char_end = char_start + syntax_char_len; 1434 - 1435 - let syn_id = self.gen_syn_id(); 1436 - 1437 - write!( 1438 - &mut self.writer, 1439 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1440 - syn_id, char_start, char_end 1441 - )?; 1442 - escape_html(&mut self.writer, &syntax_text)?; 1443 - self.write("</span>")?; 1444 - 1445 - // Track the syntax span 1446 - self.syntax_spans.push(SyntaxSpanInfo { 1447 - syn_id, 1448 - char_range: char_start..char_end, 1449 - syntax_type: SyntaxType::Block, 1450 - formatted_range: None, 1451 - }); 1452 - 1453 - // Record offset mapping for the syntax span 1454 - self.record_mapping(range.clone(), char_start..char_end); 1455 - 1456 - // Update tracking 1457 - self.last_char_offset = char_end; 1458 - self.last_byte_offset = range.end; 1459 - } 1460 - 1461 - // Parse the buffered text for attrs and store for next block 1462 - if !self.weaver_block_buffer.is_empty() { 1463 - let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 1464 - self.weaver_block_buffer.clear(); 1465 - // Merge with any existing pending attrs or set new 1466 - if let Some(ref mut existing) = self.pending_block_attrs { 1467 - existing.classes.extend(parsed.classes); 1468 - existing.attrs.extend(parsed.attrs); 1469 - } else { 1470 - self.pending_block_attrs = Some(parsed); 1471 - } 1472 - } 1473 - 1474 - Ok(()) 1475 - } 1476 - TagEnd::FootnoteDefinition => { 1477 - // End node tracking (inner paragraphs may have already cleared it) 1478 - self.end_node(); 1479 - self.write("</div>")?; 1480 - 1481 - // Clear footnote tracking 1482 - self.current_footnote_def.take(); 1483 - self.in_footnote_def = false; 1484 - 1485 - // Finalize paragraph boundary for incremental rendering 1486 - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1487 - let byte_range = byte_start..self.last_byte_offset; 1488 - let char_range = char_start..self.last_char_offset; 1489 - self.finalize_paragraph(byte_range, char_range); 1490 - } 1491 - 1492 - Ok(()) 1493 - } 1494 - TagEnd::MetadataBlock(_) => { 1495 - self.in_non_writing_block = false; 1496 - Ok(()) 1497 - } 1498 - }; 1499 - 1500 - result?; 1501 - 1502 - // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) 1503 - // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. 1504 - // This ensures the closing syntax span appears OUTSIDE the formatted element. 1505 - // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. 1506 - 1507 - Ok(()) 1508 - } 1509 - }
···
+54 -19
crates/weaver-editor-core/src/render.rs
··· 7 //! 8 //! Implementations are provided by the consuming application (e.g., weaver-app). 9 10 /// Provides HTML content for embedded resources. 11 /// 12 /// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait 13 /// is consulted to get the pre-rendered HTML for the embed. 14 pub trait EmbedContentProvider { 15 - /// Get HTML content for an embed URL. 16 /// 17 /// Returns `Some(html)` if the embed content is available, 18 /// `None` to render a placeholder. 19 - fn get_embed_html(&self, url: &str) -> Option<&str>; 20 } 21 22 /// Unit type implementation - no embeds available. 23 impl EmbedContentProvider for () { 24 - fn get_embed_html(&self, _url: &str) -> Option<&str> { 25 None 26 } 27 } ··· 63 /// Reference implementations for common patterns. 64 65 impl<T: EmbedContentProvider> EmbedContentProvider for &T { 66 - fn get_embed_html(&self, url: &str) -> Option<&str> { 67 - (*self).get_embed_html(url) 68 } 69 } 70 ··· 81 } 82 83 impl<T: EmbedContentProvider> EmbedContentProvider for Option<T> { 84 - fn get_embed_html(&self, url: &str) -> Option<&str> { 85 - self.as_ref().and_then(|p| p.get_embed_html(url)) 86 } 87 } 88 ··· 95 impl<T: WikilinkValidator> WikilinkValidator for Option<T> { 96 fn is_valid_link(&self, target: &str) -> bool { 97 self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true) 98 } 99 } 100 101 #[cfg(test)] 102 mod tests { 103 use super::*; 104 105 struct TestEmbedProvider; 106 107 impl EmbedContentProvider for TestEmbedProvider { 108 - fn get_embed_html(&self, url: &str) -> Option<&str> { 109 - if url == "at://test/embed" { 110 - Some("<div>Test Embed</div>") 111 - } else { 112 - None 113 } 114 } 115 } 116 ··· 136 } 137 } 138 139 #[test] 140 fn test_embed_provider() { 141 let provider = TestEmbedProvider; 142 assert_eq!( 143 - provider.get_embed_html("at://test/embed"), 144 - Some("<div>Test Embed</div>") 145 ); 146 - assert_eq!(provider.get_embed_html("at://other"), None); 147 } 148 149 #[test] ··· 169 #[test] 170 fn test_unit_impls() { 171 let embed: () = (); 172 - assert_eq!(embed.get_embed_html("anything"), None); 173 174 let image: () = (); 175 assert_eq!(image.resolve_image_url("anything"), None); ··· 182 fn test_option_impls() { 183 let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider); 184 assert_eq!( 185 - some_provider.get_embed_html("at://test/embed"), 186 - Some("<div>Test Embed</div>") 187 ); 188 189 let none_provider: Option<TestEmbedProvider> = None; 190 - assert_eq!(none_provider.get_embed_html("at://test/embed"), None); 191 } 192 }
··· 7 //! 8 //! Implementations are provided by the consuming application (e.g., weaver-app). 9 10 + use markdown_weaver::Tag; 11 + 12 /// Provides HTML content for embedded resources. 13 /// 14 /// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait 15 /// is consulted to get the pre-rendered HTML for the embed. 16 + /// 17 + /// The full `Tag::Embed` is provided so implementations can access all context: 18 + /// embed_type, dest_url, title, id, and attrs. 19 pub trait EmbedContentProvider { 20 + /// Get HTML content for an embed tag. 21 /// 22 /// Returns `Some(html)` if the embed content is available, 23 /// `None` to render a placeholder. 24 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 25 } 26 27 /// Unit type implementation - no embeds available. 28 impl EmbedContentProvider for () { 29 + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 30 None 31 } 32 } ··· 68 /// Reference implementations for common patterns. 69 70 impl<T: EmbedContentProvider> EmbedContentProvider for &T { 71 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 72 + (*self).get_embed_content(tag) 73 } 74 } 75 ··· 86 } 87 88 impl<T: EmbedContentProvider> EmbedContentProvider for Option<T> { 89 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 90 + self.as_ref().and_then(|p| p.get_embed_content(tag)) 91 } 92 } 93 ··· 100 impl<T: WikilinkValidator> WikilinkValidator for Option<T> { 101 fn is_valid_link(&self, target: &str) -> bool { 102 self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true) 103 + } 104 + } 105 + 106 + /// Implementation for ResolvedContent from weaver-common. 107 + /// 108 + /// Resolves AT Protocol embeds by looking up the content in the ResolvedContent map. 109 + impl EmbedContentProvider for weaver_common::ResolvedContent { 110 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 111 + if let Tag::Embed { dest_url, .. } = tag { 112 + let url = dest_url.as_ref(); 113 + if url.starts_with("at://") { 114 + if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { 115 + return weaver_common::ResolvedContent::get_embed_content(self, &at_uri) 116 + .map(|s| s.to_string()); 117 + } 118 + } 119 + } 120 + None 121 } 122 } 123 124 #[cfg(test)] 125 mod tests { 126 use super::*; 127 + use markdown_weaver::EmbedType; 128 129 struct TestEmbedProvider; 130 131 impl EmbedContentProvider for TestEmbedProvider { 132 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 133 + if let Tag::Embed { dest_url, .. } = tag { 134 + if dest_url.as_ref() == "at://test/embed" { 135 + return Some("<div>Test Embed</div>".to_string()); 136 + } 137 } 138 + None 139 } 140 } 141 ··· 161 } 162 } 163 164 + fn make_embed_tag(url: &str) -> Tag<'_> { 165 + Tag::Embed { 166 + embed_type: EmbedType::Other, 167 + dest_url: url.into(), 168 + title: "".into(), 169 + id: "".into(), 170 + attrs: None, 171 + } 172 + } 173 + 174 #[test] 175 fn test_embed_provider() { 176 let provider = TestEmbedProvider; 177 assert_eq!( 178 + provider.get_embed_content(&make_embed_tag("at://test/embed")), 179 + Some("<div>Test Embed</div>".to_string()) 180 ); 181 + assert_eq!(provider.get_embed_content(&make_embed_tag("at://other")), None); 182 } 183 184 #[test] ··· 204 #[test] 205 fn test_unit_impls() { 206 let embed: () = (); 207 + assert_eq!(embed.get_embed_content(&make_embed_tag("anything")), None); 208 209 let image: () = (); 210 assert_eq!(image.resolve_image_url("anything"), None); ··· 217 fn test_option_impls() { 218 let some_provider: Option<TestEmbedProvider> = Some(TestEmbedProvider); 219 assert_eq!( 220 + some_provider.get_embed_content(&make_embed_tag("at://test/embed")), 221 + Some("<div>Test Embed</div>".to_string()) 222 ); 223 224 let none_provider: Option<TestEmbedProvider> = None; 225 + assert_eq!(none_provider.get_embed_content(&make_embed_tag("at://test/embed")), None); 226 } 227 }
+1 -1
crates/weaver-editor-core/src/text.rs
··· 10 /// A text buffer that supports efficient editing and offset conversion. 11 /// 12 /// All offsets are in Unicode scalar values (chars), not bytes or UTF-16. 13 - pub trait TextBuffer: Default + Clone { 14 /// Total length in bytes (UTF-8). 15 fn len_bytes(&self) -> usize; 16
··· 10 /// A text buffer that supports efficient editing and offset conversion. 11 /// 12 /// All offsets are in Unicode scalar values (chars), not bytes or UTF-16. 13 + pub trait TextBuffer { 14 /// Total length in bytes (UTF-8). 15 fn len_bytes(&self) -> usize; 16
+13 -3
crates/weaver-editor-core/src/undo.rs
··· 47 /// 48 /// This is the standard way to get undo support for local editing. 49 /// All mutations go through this wrapper, which records them for undo. 50 - #[derive(Clone)] 51 - pub struct UndoableBuffer<T: TextBuffer> { 52 buffer: T, 53 undo_stack: Vec<EditOperation>, 54 redo_stack: Vec<EditOperation>, 55 max_steps: usize, 56 } 57 58 - impl<T: TextBuffer> Default for UndoableBuffer<T> { 59 fn default() -> Self { 60 Self::new(T::default(), 100) 61 }
··· 47 /// 48 /// This is the standard way to get undo support for local editing. 49 /// All mutations go through this wrapper, which records them for undo. 50 + pub struct UndoableBuffer<T> { 51 buffer: T, 52 undo_stack: Vec<EditOperation>, 53 redo_stack: Vec<EditOperation>, 54 max_steps: usize, 55 } 56 57 + impl<T: Clone> Clone for UndoableBuffer<T> { 58 + fn clone(&self) -> Self { 59 + Self { 60 + buffer: self.buffer.clone(), 61 + undo_stack: self.undo_stack.clone(), 62 + redo_stack: self.redo_stack.clone(), 63 + max_steps: self.max_steps, 64 + } 65 + } 66 + } 67 + 68 + impl<T: TextBuffer + Default> Default for UndoableBuffer<T> { 69 fn default() -> Self { 70 Self::new(T::default(), 100) 71 }
+20 -13
crates/weaver-editor-core/src/writer/embed.rs
··· 6 7 use jacquard::IntoStatic; 8 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 9 - use markdown_weaver::{CowStr, EmbedType, Event}; 10 use markdown_weaver_escape::{StrWrite, escape_html}; 11 use smol_str::SmolStr; 12 ··· 65 blob_rkey: Rkey<'static>, 66 ident: AtIdentifier<'static>, 67 ) { 68 - self.images 69 - .insert(SmolStr::new(name), ResolvedImage::Draft { blob_rkey, ident }); 70 } 71 72 /// Add an already-uploaded draft image. ··· 160 } 161 162 // write_embed implementation 163 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 164 where 165 I: Iterator<Item = (Event<'a>, Range<usize>)>, 166 E: EmbedContentProvider, 167 R: ImageResolver, ··· 170 pub(crate) fn write_embed( 171 &mut self, 172 range: Range<usize>, 173 - _embed_type: EmbedType, 174 - dest_url: CowStr<'_>, 175 - title: CowStr<'_>, 176 - _id: CowStr<'_>, 177 - attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 178 ) -> Result<(), fmt::Error> { 179 // Embed rendering: all syntax elements share one syn_id for visibility toggling 180 // Structure: ![[ url-as-link ]] <embed-content> 181 let raw_text = &self.source[range.clone()]; ··· 279 280 // 4. Emit the actual embed content 281 // Try to get content from attributes first 282 - let content_from_attrs = if let Some(ref attrs) = attrs { 283 attrs 284 .attrs 285 .iter() ··· 290 }; 291 292 // If no content in attrs, try provider 293 - // Convert to owned to avoid borrow checker issues with self.write() 294 - // TODO: figure out a way to do this that doesn't involve cloning 295 let content: Option<String> = if content_from_attrs.is_some() { 296 content_from_attrs 297 } else if let Some(ref provider) = self.embed_provider { 298 - provider.get_embed_html(url).map(|s| s.to_string()) 299 } else { 300 None 301 };
··· 6 7 use jacquard::IntoStatic; 8 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 9 + use markdown_weaver::{CowStr, Event, Tag}; 10 use markdown_weaver_escape::{StrWrite, escape_html}; 11 use smol_str::SmolStr; 12 ··· 65 blob_rkey: Rkey<'static>, 66 ident: AtIdentifier<'static>, 67 ) { 68 + self.images.insert( 69 + SmolStr::new(name), 70 + ResolvedImage::Draft { blob_rkey, ident }, 71 + ); 72 } 73 74 /// Add an already-uploaded draft image. ··· 162 } 163 164 // write_embed implementation 165 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 166 where 167 + T: crate::TextBuffer, 168 I: Iterator<Item = (Event<'a>, Range<usize>)>, 169 E: EmbedContentProvider, 170 R: ImageResolver, ··· 173 pub(crate) fn write_embed( 174 &mut self, 175 range: Range<usize>, 176 + tag: Tag<'_>, 177 ) -> Result<(), fmt::Error> { 178 + let Tag::Embed { 179 + dest_url, 180 + title, 181 + attrs, 182 + .. 183 + } = &tag 184 + else { 185 + return Ok(()); 186 + }; 187 + 188 // Embed rendering: all syntax elements share one syn_id for visibility toggling 189 // Structure: ![[ url-as-link ]] <embed-content> 190 let raw_text = &self.source[range.clone()]; ··· 288 289 // 4. Emit the actual embed content 290 // Try to get content from attributes first 291 + let content_from_attrs = if let Some(attrs) = attrs { 292 attrs 293 .attrs 294 .iter() ··· 299 }; 300 301 // If no content in attrs, try provider 302 let content: Option<String> = if content_from_attrs.is_some() { 303 content_from_attrs 304 } else if let Some(ref provider) = self.embed_provider { 305 + provider.get_embed_content(&tag) 306 } else { 307 None 308 };
+3 -2
crates/weaver-editor-core/src/writer/events.rs
··· 14 use super::{EditorWriter, WriterResult}; 15 16 // Main run loop 17 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 18 where 19 I: Iterator<Item = (Event<'a>, Range<usize>)>, 20 E: EmbedContentProvider, 21 R: ImageResolver, ··· 84 // Handle unmapped trailing content (stripped by parser) 85 // This includes trailing spaces that markdown ignores 86 let doc_byte_len = self.source.len(); 87 - let doc_char_len = self.source_len_chars; 88 89 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 90 // Emit the trailing content as visible syntax
··· 14 use super::{EditorWriter, WriterResult}; 15 16 // Main run loop 17 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 18 where 19 + T: crate::TextBuffer, 20 I: Iterator<Item = (Event<'a>, Range<usize>)>, 21 E: EmbedContentProvider, 22 R: ImageResolver, ··· 85 // Handle unmapped trailing content (stripped by parser) 86 // This includes trailing spaces that markdown ignores 87 let doc_byte_len = self.source.len(); 88 + let doc_char_len = self.text_buffer.len_chars(); 89 90 if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 91 // Emit the trailing content as visible syntax
+18 -14
crates/weaver-editor-core/src/writer/mod.rs
··· 94 /// HTML writer that preserves markdown formatting characters. 95 /// 96 /// Generic over: 97 /// - `I`: Iterator of markdown events with byte ranges 98 /// - `E`: Embed content provider (optional) 99 /// - `R`: Image resolver (optional) 100 /// - `W`: Wikilink validator (optional) 101 - pub struct EditorWriter<'a, I, E = (), R = (), W = ()> 102 where 103 I: Iterator<Item = (Event<'a>, Range<usize>)>, 104 { 105 // === Input === 106 source: &'a str, 107 - source_len_chars: usize, 108 events: I, 109 110 // === Output === ··· 146 ref_collector: weaver_common::RefCollector, 147 } 148 149 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 150 where 151 I: Iterator<Item = (Event<'a>, Range<usize>)>, 152 { 153 /// Create a new EditorWriter. 154 /// 155 - /// `source` is the markdown source text. 156 - /// `source_len_chars` is the length in Unicode chars (for bounds checking). 157 /// `events` is the markdown parser event iterator. 158 - pub fn new(source: &'a str, source_len_chars: usize, events: I) -> Self { 159 Self { 160 source, 161 - source_len_chars, 162 events, 163 writer: SegmentedWriter::new(), 164 last_byte_offset: 0, ··· 232 pub fn with_embed_provider<E2: EmbedContentProvider>( 233 self, 234 provider: E2, 235 - ) -> EditorWriter<'a, I, E2, R, W> { 236 EditorWriter { 237 source: self.source, 238 - source_len_chars: self.source_len_chars, 239 events: self.events, 240 writer: self.writer, 241 last_byte_offset: self.last_byte_offset, ··· 268 pub fn with_image_resolver<R2: ImageResolver>( 269 self, 270 resolver: R2, 271 - ) -> EditorWriter<'a, I, E, R2, W> { 272 EditorWriter { 273 source: self.source, 274 - source_len_chars: self.source_len_chars, 275 events: self.events, 276 writer: self.writer, 277 last_byte_offset: self.last_byte_offset, ··· 304 pub fn with_wikilink_validator<W2: WikilinkValidator>( 305 self, 306 validator: W2, 307 - ) -> EditorWriter<'a, I, E, R, W2> { 308 EditorWriter { 309 source: self.source, 310 - source_len_chars: self.source_len_chars, 311 events: self.events, 312 writer: self.writer, 313 last_byte_offset: self.last_byte_offset, ··· 344 } 345 346 // Core helper methods 347 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 348 where 349 I: Iterator<Item = (Event<'a>, Range<usize>)>, 350 { 351 /// Write a string to the output.
··· 94 /// HTML writer that preserves markdown formatting characters. 95 /// 96 /// Generic over: 97 + /// - `T`: Text buffer for efficient offset conversions 98 /// - `I`: Iterator of markdown events with byte ranges 99 /// - `E`: Embed content provider (optional) 100 /// - `R`: Image resolver (optional) 101 /// - `W`: Wikilink validator (optional) 102 + pub struct EditorWriter<'a, T, I, E = (), R = (), W = ()> 103 where 104 + T: crate::TextBuffer, 105 I: Iterator<Item = (Event<'a>, Range<usize>)>, 106 { 107 // === Input === 108 source: &'a str, 109 + text_buffer: &'a T, 110 events: I, 111 112 // === Output === ··· 148 ref_collector: weaver_common::RefCollector, 149 } 150 151 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 152 where 153 + T: crate::TextBuffer, 154 I: Iterator<Item = (Event<'a>, Range<usize>)>, 155 { 156 /// Create a new EditorWriter. 157 /// 158 + /// `source` is the markdown source text (should match text_buffer content). 159 + /// `text_buffer` provides efficient offset conversions. 160 /// `events` is the markdown parser event iterator. 161 + pub fn new(source: &'a str, text_buffer: &'a T, events: I) -> Self { 162 Self { 163 source, 164 + text_buffer, 165 events, 166 writer: SegmentedWriter::new(), 167 last_byte_offset: 0, ··· 235 pub fn with_embed_provider<E2: EmbedContentProvider>( 236 self, 237 provider: E2, 238 + ) -> EditorWriter<'a, T, I, E2, R, W> { 239 EditorWriter { 240 source: self.source, 241 + text_buffer: self.text_buffer, 242 events: self.events, 243 writer: self.writer, 244 last_byte_offset: self.last_byte_offset, ··· 271 pub fn with_image_resolver<R2: ImageResolver>( 272 self, 273 resolver: R2, 274 + ) -> EditorWriter<'a, T, I, E, R2, W> { 275 EditorWriter { 276 source: self.source, 277 + text_buffer: self.text_buffer, 278 events: self.events, 279 writer: self.writer, 280 last_byte_offset: self.last_byte_offset, ··· 307 pub fn with_wikilink_validator<W2: WikilinkValidator>( 308 self, 309 validator: W2, 310 + ) -> EditorWriter<'a, T, I, E, R, W2> { 311 EditorWriter { 312 source: self.source, 313 + text_buffer: self.text_buffer, 314 events: self.events, 315 writer: self.writer, 316 last_byte_offset: self.last_byte_offset, ··· 347 } 348 349 // Core helper methods 350 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 351 where 352 + T: crate::TextBuffer, 353 I: Iterator<Item = (Event<'a>, Range<usize>)>, 354 { 355 /// Write a string to the output.
+2 -1
crates/weaver-editor-core/src/writer/syntax.rs
··· 11 12 use super::EditorWriter; 13 14 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 15 where 16 I: Iterator<Item = (Event<'a>, Range<usize>)>, 17 E: EmbedContentProvider, 18 R: ImageResolver,
··· 11 12 use super::EditorWriter; 13 14 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 15 where 16 + T: crate::TextBuffer, 17 I: Iterator<Item = (Event<'a>, Range<usize>)>, 18 E: EmbedContentProvider, 19 R: ImageResolver,
+8 -12
crates/weaver-editor-core/src/writer/tags.rs
··· 13 14 use super::{EditorWriter, TableState}; 15 16 - impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> 17 where 18 I: Iterator<Item = (Event<'a>, Range<usize>)>, 19 E: EmbedContentProvider, 20 R: ImageResolver, ··· 747 if matches!(link_type, LinkType::WikiLink { .. }) 748 && (url.starts_with("at://") || url.starts_with("did:")) 749 { 750 - return self.write_embed( 751 - range, 752 - EmbedType::Other, // AT embeds - disambiguated via NSID later 753 dest_url, 754 title, 755 id, 756 attrs, 757 - ); 758 } 759 760 // Image rendering: all syntax elements share one syn_id for visibility toggling ··· 906 907 Ok(()) 908 } 909 - Tag::Embed { 910 - embed_type, 911 - dest_url, 912 - title, 913 - id, 914 - attrs, 915 - } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 916 Tag::WeaverBlock(_, attrs) => { 917 self.in_non_writing_block = true; 918 self.weaver_block.buffer.clear();
··· 13 14 use super::{EditorWriter, TableState}; 15 16 + impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> 17 where 18 + T: crate::TextBuffer, 19 I: Iterator<Item = (Event<'a>, Range<usize>)>, 20 E: EmbedContentProvider, 21 R: ImageResolver, ··· 748 if matches!(link_type, LinkType::WikiLink { .. }) 749 && (url.starts_with("at://") || url.starts_with("did:")) 750 { 751 + // Construct an Embed tag from the Image fields 752 + let embed_tag = Tag::Embed { 753 + embed_type: EmbedType::Other, 754 dest_url, 755 title, 756 id, 757 attrs, 758 + }; 759 + return self.write_embed(range, embed_tag); 760 } 761 762 // Image rendering: all syntax elements share one syn_id for visibility toggling ··· 908 909 Ok(()) 910 } 911 + tag @ Tag::Embed { .. } => self.write_embed(range, tag), 912 Tag::WeaverBlock(_, attrs) => { 913 self.in_non_writing_block = true; 914 self.weaver_block.buffer.clear();