bunch of random little bugs squished

Orual 0d6ad974 542142f9

+654 -118
+40 -8
crates/weaver-app/src/components/editor/formatting.rs
··· 1 1 //! Formatting actions and utilities for applying markdown formatting. 2 2 3 + use crate::components::editor::{ListContext, detect_list_context, find_line_end}; 4 + 3 5 use super::document::EditorDocument; 4 6 5 7 /// Formatting actions available in the editor. ··· 114 116 doc.selection = None; 115 117 } 116 118 FormatAction::BulletList => { 117 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 118 - let _ = doc.insert_tracked(line_start, "- "); 119 - doc.cursor.offset += 2; 120 - doc.selection = None; 119 + if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 120 + let continuation = match ctx { 121 + ListContext::Unordered { indent, marker } => { 122 + format!("\n{}{} ", indent, marker) 123 + } 124 + ListContext::Ordered { .. } => { 125 + format!("\n\n - ") 126 + } 127 + }; 128 + let len = continuation.chars().count(); 129 + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 130 + doc.cursor.offset += len; 131 + doc.selection = None; 132 + } else { 133 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 134 + let _ = doc.insert_tracked(line_start, " - "); 135 + doc.cursor.offset += 3; 136 + doc.selection = None; 137 + } 121 138 } 122 139 FormatAction::NumberedList => { 123 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 124 - let _ = doc.insert_tracked(line_start, "1. "); 125 - doc.cursor.offset += 3; 126 - doc.selection = None; 140 + if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 141 + let continuation = match ctx { 142 + ListContext::Unordered { .. } => { 143 + format!("\n\n1. ") 144 + } 145 + ListContext::Ordered { indent, number } => { 146 + format!("\n{}{}. ", indent, number + 1) 147 + } 148 + }; 149 + let len = continuation.chars().count(); 150 + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 151 + doc.cursor.offset += len; 152 + doc.selection = None; 153 + } else { 154 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 155 + let _ = doc.insert_tracked(line_start, "1. "); 156 + doc.cursor.offset += 3; 157 + doc.selection = None; 158 + } 127 159 } 128 160 FormatAction::Quote => { 129 161 let line_start = find_line_start(doc.loro_text(), doc.cursor.offset);
+108 -29
crates/weaver-app/src/components/editor/mod.rs
··· 100 100 // Update DOM when paragraphs change (incremental rendering) 101 101 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 102 102 use_effect(move || { 103 - tracing::info!("DOM update effect triggered"); 103 + tracing::debug!("DOM update effect triggered"); 104 104 105 105 // Read document once to avoid multiple borrows 106 106 let doc = document(); 107 107 108 - tracing::info!( 108 + tracing::debug!( 109 109 composition_active = doc.composition.is_some(), 110 110 cursor = doc.cursor.offset, 111 111 "DOM update: checking state" ··· 113 113 114 114 // Skip DOM updates during IME composition - browser controls the preview 115 115 if doc.composition.is_some() { 116 - tracing::info!("skipping DOM update during composition"); 116 + tracing::debug!("skipping DOM update during composition"); 117 117 return; 118 118 } 119 + 120 + tracing::debug!( 121 + cursor = doc.cursor.offset, 122 + len = doc.len_chars(), 123 + "DOM update proceeding (not in composition)" 124 + ); 119 125 120 126 let cursor_offset = doc.cursor.offset; 121 127 let selection = doc.selection; ··· 234 240 // During IME composition, let browser handle everything 235 241 // Exception: Escape cancels composition 236 242 if document.peek().composition.is_some() { 237 - tracing::info!( 243 + tracing::debug!( 238 244 key = ?evt.key(), 239 245 "keydown during composition - delegating to browser" 240 246 ); 241 247 if evt.key() == Key::Escape { 242 - tracing::info!("Escape pressed - cancelling composition"); 248 + tracing::debug!("Escape pressed - cancelling composition"); 243 249 document.with_mut(|doc| { 244 250 doc.composition = None; 245 251 }); ··· 257 263 258 264 onkeyup: move |evt| { 259 265 use dioxus::prelude::keyboard_types::Key; 260 - // Only sync cursor from DOM after navigation keys 261 - // Content-modifying keys update cursor directly in handle_keydown 262 - let dominated = matches!( 266 + 267 + // Navigation keys (with or without Shift for selection) 268 + let navigation = matches!( 263 269 evt.key(), 264 270 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 265 271 Key::Home | Key::End | Key::PageUp | Key::PageDown 266 272 ); 267 - if dominated { 273 + 274 + // Cmd/Ctrl+A for select all 275 + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 276 + && matches!(evt.key(), Key::Character(ref c) if c == "a"); 277 + 278 + if navigation || select_all { 268 279 let paras = cached_paragraphs(); 269 280 sync_cursor_from_dom(&mut document, editor_id, &paras); 270 - // Update syntax visibility after cursor sync 271 281 let doc = document(); 272 282 let spans = syntax_spans(); 273 283 update_syntax_visibility( ··· 279 289 } 280 290 }, 281 291 292 + onselect: move |_evt| { 293 + tracing::debug!("onselect fired"); 294 + let paras = cached_paragraphs(); 295 + sync_cursor_from_dom(&mut document, editor_id, &paras); 296 + let doc = document(); 297 + let spans = syntax_spans(); 298 + update_syntax_visibility( 299 + doc.cursor.offset, 300 + doc.selection.as_ref(), 301 + &spans, 302 + &paras, 303 + ); 304 + }, 305 + 306 + onselectstart: move |_evt| { 307 + tracing::debug!("onselectstart fired"); 308 + let paras = cached_paragraphs(); 309 + sync_cursor_from_dom(&mut document, editor_id, &paras); 310 + let doc = document(); 311 + let spans = syntax_spans(); 312 + update_syntax_visibility( 313 + doc.cursor.offset, 314 + doc.selection.as_ref(), 315 + &spans, 316 + &paras, 317 + ); 318 + }, 319 + 320 + onselectionchange: move |_evt| { 321 + tracing::debug!("onselectionchange fired"); 322 + let paras = cached_paragraphs(); 323 + sync_cursor_from_dom(&mut document, editor_id, &paras); 324 + let doc = document(); 325 + let spans = syntax_spans(); 326 + update_syntax_visibility( 327 + doc.cursor.offset, 328 + doc.selection.as_ref(), 329 + &spans, 330 + &paras, 331 + ); 332 + }, 333 + 282 334 onclick: move |_evt| { 283 - // After mouse click or drag selection, sync cursor from DOM 284 - // (click fires after mouseup, so this handles both cases) 335 + tracing::debug!("onclick fired"); 285 336 let paras = cached_paragraphs(); 286 337 sync_cursor_from_dom(&mut document, editor_id, &paras); 287 - // Update syntax visibility after cursor sync 288 338 let doc = document(); 289 339 let spans = syntax_spans(); 290 340 update_syntax_visibility( ··· 311 361 // Cancel any in-progress IME composition on focus loss 312 362 let had_composition = document.peek().composition.is_some(); 313 363 if had_composition { 314 - tracing::info!("onblur: clearing active composition"); 364 + tracing::debug!("onblur: clearing active composition"); 315 365 } 316 366 document.with_mut(|doc| { 317 367 doc.composition = None; ··· 320 370 321 371 oncompositionstart: move |evt: CompositionEvent| { 322 372 let data = evt.data().data(); 323 - tracing::info!( 373 + tracing::debug!( 324 374 data = %data, 325 375 "compositionstart" 326 376 ); ··· 329 379 if let Some(sel) = doc.selection.take() { 330 380 let (start, end) = 331 381 (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 332 - tracing::info!( 382 + tracing::debug!( 333 383 start, 334 384 end, 335 385 "compositionstart: deleting selection" ··· 338 388 doc.cursor.offset = start; 339 389 } 340 390 341 - tracing::info!( 391 + tracing::debug!( 342 392 cursor = doc.cursor.offset, 343 393 "compositionstart: setting composition state" 344 394 ); ··· 351 401 352 402 oncompositionupdate: move |evt: CompositionEvent| { 353 403 let data = evt.data().data(); 354 - tracing::info!( 404 + tracing::debug!( 355 405 data = %data, 356 406 "compositionupdate" 357 407 ); ··· 359 409 if let Some(ref mut comp) = doc.composition { 360 410 comp.text = data; 361 411 } else { 362 - tracing::info!("compositionupdate without active composition state"); 412 + tracing::debug!("compositionupdate without active composition state"); 363 413 } 364 414 }); 365 415 }, 366 416 367 417 oncompositionend: move |evt: CompositionEvent| { 368 418 let final_text = evt.data().data(); 369 - tracing::info!( 419 + tracing::debug!( 370 420 data = %final_text, 371 421 "compositionend" 372 422 ); 373 423 document.with_mut(|doc| { 374 424 if let Some(comp) = doc.composition.take() { 375 - tracing::info!( 425 + tracing::debug!( 376 426 start_offset = comp.start_offset, 377 427 final_text = %final_text, 378 428 chars = final_text.chars().count(), ··· 380 430 ); 381 431 382 432 if !final_text.is_empty() { 383 - let _ = doc.insert_tracked(comp.start_offset, &final_text); 384 - doc.cursor.offset = 385 - comp.start_offset + final_text.chars().count(); 433 + let mut delete_start = comp.start_offset; 434 + while delete_start > 0 { 435 + match get_char_at(doc.loro_text(), delete_start - 1) { 436 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 437 + _ => break, 438 + } 439 + } 440 + 441 + let zw_count = doc.cursor.offset - delete_start; 442 + if zw_count > 0 { 443 + // Splice: delete zero-width chars and insert new char in one op 444 + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 445 + doc.cursor.offset = delete_start + final_text.chars().count(); 446 + } else if doc.cursor.offset == doc.len_chars() { 447 + // Fast path: append at end 448 + let _ = doc.push_tracked(&final_text); 449 + doc.cursor.offset = comp.start_offset + final_text.chars().count(); 450 + } else { 451 + let _ = doc.insert_tracked(doc.cursor.offset, &final_text); 452 + doc.cursor.offset = comp.start_offset + final_text.chars().count(); 453 + } 386 454 } 387 455 } else { 388 - tracing::info!("compositionend without active composition state"); 456 + tracing::debug!("compositionend without active composition state"); 389 457 } 390 458 }); 391 459 }, 392 460 } 393 - 394 - 395 461 } 396 462 397 463 EditorToolbar { ··· 534 600 let node_id = loop { 535 601 if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 536 602 if element == editor_element { 603 + // Selection is on the editor container itself (e.g., Cmd+A select all) 604 + // Return boundary position based on offset: 605 + // offset 0 = start of editor, offset == child count = end of editor 606 + let child_count = editor_element.child_element_count() as usize; 607 + if offset_in_text_node == 0 { 608 + return Some(0); // Start of document 609 + } else if offset_in_text_node >= child_count { 610 + // End of document - find last paragraph's end 611 + return paragraphs.last().map(|p| p.char_range.end); 612 + } 537 613 break None; 538 614 } 539 615 ··· 1029 1105 while delete_start > 0 { 1030 1106 let ch = get_char_at(doc.loro_text(), delete_start - 1); 1031 1107 match ch { 1032 - Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => { 1108 + Some('\u{200C}') | Some('\u{200B}') => { 1033 1109 delete_start -= 1; 1034 1110 } 1035 1111 Some('\n') => break, // stop at another newline ··· 1307 1383 if let Some(old_para) = old_paragraphs.get(idx) { 1308 1384 // Paragraph exists - check if changed 1309 1385 if new_para.source_hash != old_para.source_hash { 1310 - // Changed - update innerHTML 1386 + // Changed - clear and update innerHTML 1387 + // We clear first to ensure any browser-added content (from IME composition, 1388 + // contenteditable quirks, etc.) is fully removed before setting new content 1311 1389 if let Some(elem) = document.get_element_by_id(&para_id) { 1390 + elem.set_text_content(None); // Clear completely 1312 1391 elem.set_inner_html(&new_para.html); 1313 1392 } 1314 1393
+14 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 18 11 - html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">&gt; </span>This is a quote<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"17\" data-char-end=\"18\">\n</span></p>\n</blockquote>\n" 11 + html: "<blockquote>\n<p id=\"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</blockquote>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 43 ··· 22 22 utf16_len: 0 23 23 - byte_range: 24 24 - 41 25 - - 43 25 + - 42 26 26 char_range: 27 27 - 0 28 - - 2 28 + - 1 29 29 node_id: n0 30 30 char_offset_in_node: 0 31 31 child_index: ~ 32 - utf16_len: 2 32 + utf16_len: 1 33 + - byte_range: 34 + - 42 35 + - 43 36 + char_range: 37 + - 1 38 + - 2 39 + node_id: n0 40 + char_offset_in_node: 1 41 + child_index: ~ 42 + utf16_len: 1 33 43 - byte_range: 34 44 - 43 35 45 - 58
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 10 11 - html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"10\">\n</span></h1>\n" 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>Heading\n</h1>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 19 11 - html: "<p id=\"n0\">Line one<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"8\" data-char-end=\"10\"> </span><br />​Line two</p>\n" 11 + html: "<p id=\"n0\">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 12 offset_map: 13 13 - byte_range: 14 14 - 0
+4 -4
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 5 11 - html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>H1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"4\" data-char-end=\"5\">\n</span></h1>\n" 11 + html: "<h1 data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\"># </span>H1\n</h1>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 57 57 char_range: 58 58 - 6 59 59 - 12 60 - html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"0\" data-char-end=\"3\">## </span>H2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"5\" data-char-end=\"6\">\n</span></h2>\n" 60 + html: "<h2 data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"0\" data-char-end=\"3\">## </span>H2\n</h2>\n" 61 61 offset_map: 62 62 - byte_range: 63 63 - 6 ··· 106 106 char_range: 107 107 - 13 108 108 - 20 109 - html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"0\" data-char-end=\"4\">### </span>H3<span class=\"md-syntax-inline\" data-syn-id=\"s5\" data-char-start=\"6\" data-char-end=\"7\">\n</span></h3>\n" 109 + html: "<h3 data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"0\" data-char-end=\"4\">### </span>H3\n</h3>\n" 110 110 offset_map: 111 111 - byte_range: 112 112 - 13 ··· 155 155 char_range: 156 156 - 21 157 157 - 28 158 - html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\" data-syn-id=\"s6\" data-char-start=\"0\" data-char-end=\"5\">#### </span>H4</h4>\n" 158 + html: "<h4 data-node-id=\"n3\"><span class=\"md-syntax-block\" data-syn-id=\"s3\" data-char-start=\"0\" data-char-end=\"5\">#### </span>H4</h4>\n" 159 159 offset_map: 160 160 - byte_range: 161 161 - 21
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">First<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 11 + html: "<p id=\"n0\">First\n</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap
··· 27 27 char_range: 28 28 - 11 29 29 - 33 30 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Child 1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\">\n </span>\n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"12\" data-char-end=\"14\">- </span>Child 2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"21\" data-char-end=\"22\">\n</span></li>\n</ul>\n</li>\n</ul>\n" 30 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Child 1\n \n<ul>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"12\" data-char-end=\"14\">- </span>Child 2\n</li>\n</ul>\n</li>\n</ul>\n" 31 31 offset_map: 32 32 - byte_range: 33 33 - 11
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 27 11 - html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\">1. </span>First<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"9\" data-char-end=\"12\">2. </span>Second<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"18\" data-char-end=\"19\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"19\" data-char-end=\"22\">3. </span>Third</li>\n</ol>\n" 11 + html: "<ol>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"3\">1. </span>First\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"12\">2. </span>Second\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"19\" data-char-end=\"22\">3. </span>Third</li>\n</ol>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+2 -2
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 5 11 - html: "<p id=\"n0\">One.<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"4\" data-char-end=\"5\">\n</span></p>\n" 11 + html: "<p id=\"n0\">One.\n</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0 ··· 47 47 char_range: 48 48 - 6 49 49 - 11 50 - html: "<p id=\"n1\">Two.<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"4\" data-char-end=\"5\">\n</span></p>\n" 50 + html: "<p id=\"n1\">Two.\n</p>\n" 51 51 offset_map: 52 52 - byte_range: 53 53 - 6
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 11 + html: "<p id=\"n0\">Hello\n</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 6 11 - html: "<p id=\"n0\">Hello<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"5\" data-char-end=\"6\">\n</span></p>\n" 11 + html: "<p id=\"n0\">Hello\n</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 17 11 - html: "<p id=\"n0\">First paragraph.<span class=\"md-syntax-inline\" data-syn-id=\"s0\" data-char-start=\"16\" data-char-end=\"17\">\n</span></p>\n" 11 + html: "<p id=\"n0\">First paragraph.\n</p>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+1 -1
crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap
··· 8 8 char_range: 9 9 - 0 10 10 - 26 11 - html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Item 1<span class=\"md-syntax-inline\" data-syn-id=\"s1\" data-char-start=\"8\" data-char-end=\"9\">\n</span></li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"9\" data-char-end=\"11\">- </span>Item 2<span class=\"md-syntax-inline\" data-syn-id=\"s3\" data-char-start=\"17\" data-char-end=\"18\">\n</span></li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s4\" data-char-start=\"18\" data-char-end=\"20\">- </span>Item 3</li>\n</ul>\n" 11 + html: "<ul>\n<li data-node-id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"2\">- </span>Item 1\n</li>\n<li data-node-id=\"n1\"><span class=\"md-syntax-block\" data-syn-id=\"s1\" data-char-start=\"9\" data-char-end=\"11\">- </span>Item 2\n</li>\n<li data-node-id=\"n2\"><span class=\"md-syntax-block\" data-syn-id=\"s2\" data-char-start=\"18\" data-char-end=\"20\">- </span>Item 3</li>\n</ul>\n" 12 12 offset_map: 13 13 - byte_range: 14 14 - 0
+316
crates/weaver-app/src/components/editor/tests.rs
··· 426 426 } 427 427 428 428 // ============================================================================= 429 + // Syntax Span Edge Case Tests 430 + // ============================================================================= 431 + 432 + #[test] 433 + fn test_invalid_heading_no_space() { 434 + // "#text" without space is NOT a valid heading - should be plain text 435 + // The '#' should NOT be wrapped in a syntax span 436 + let result = render_test("#text"); 437 + 438 + // Should be a single paragraph with plain text 439 + assert_eq!(result.len(), 1, "Should have 1 paragraph"); 440 + 441 + // HTML should NOT contain md-syntax-block for the # 442 + assert!( 443 + !result[0].html.contains("md-syntax-block"), 444 + "Invalid heading '#text' should NOT have block syntax span. HTML: {}", 445 + result[0].html 446 + ); 447 + 448 + // The # should be visible as regular text content 449 + assert!( 450 + result[0].html.contains("#text") || result[0].html.contains("&num;text"), 451 + "The '#text' should appear as regular text. HTML: {}", 452 + result[0].html 453 + ); 454 + } 455 + 456 + #[test] 457 + fn test_valid_heading_with_space() { 458 + // "# text" WITH space IS a valid heading 459 + let result = render_test("# Heading"); 460 + 461 + // Should have heading syntax span 462 + assert!( 463 + result[0].html.contains("md-syntax-block"), 464 + "Valid heading should have block syntax span. HTML: {}", 465 + result[0].html 466 + ); 467 + 468 + // Should have <h1> tag 469 + assert!( 470 + result[0].html.contains("<h1"), 471 + "Valid heading should render as h1. HTML: {}", 472 + result[0].html 473 + ); 474 + } 475 + 476 + #[test] 477 + fn test_hash_in_middle_of_text() { 478 + // "#" in middle of text should not be treated as heading syntax 479 + let result = render_test("Some #hashtag here"); 480 + 481 + assert!( 482 + !result[0].html.contains("md-syntax-block"), 483 + "# in middle of text should NOT be block syntax. HTML: {}", 484 + result[0].html 485 + ); 486 + } 487 + 488 + #[test] 489 + fn test_unclosed_bold() { 490 + // "**text" without closing ** should be plain text, not bold 491 + let result = render_test("**unclosed bold"); 492 + 493 + // Should NOT have <strong> tag 494 + assert!( 495 + !result[0].html.contains("<strong>"), 496 + "Unclosed ** should NOT render as bold. HTML: {}", 497 + result[0].html 498 + ); 499 + } 500 + 501 + #[test] 502 + fn test_unclosed_italic() { 503 + // "*text" without closing * should be plain text, not italic 504 + let result = render_test("*unclosed italic"); 505 + 506 + // Should NOT have <em> tag 507 + assert!( 508 + !result[0].html.contains("<em>"), 509 + "Unclosed * should NOT render as italic. HTML: {}", 510 + result[0].html 511 + ); 512 + } 513 + 514 + #[test] 515 + fn test_asterisk_not_emphasis() { 516 + // Single * surrounded by spaces is not emphasis 517 + let result = render_test("5 * 3 = 15"); 518 + 519 + // Should NOT have <em> tag 520 + assert!( 521 + !result[0].html.contains("<em>"), 522 + "Math expression with * should NOT be italic. HTML: {}", 523 + result[0].html 524 + ); 525 + } 526 + 527 + #[test] 528 + fn test_list_marker_needs_space() { 529 + // "-text" without space is NOT a list item 530 + let result = render_test("-not-a-list"); 531 + 532 + // Should NOT have <li> or <ul> tags 533 + assert!( 534 + !result[0].html.contains("<li>") && !result[0].html.contains("<ul>"), 535 + "'-text' without space should NOT be a list. HTML: {}", 536 + result[0].html 537 + ); 538 + } 539 + 540 + #[test] 541 + fn test_valid_list_with_space() { 542 + // "- text" WITH space IS a valid list item 543 + let result = render_test("- List item"); 544 + 545 + // Should have list markup 546 + assert!( 547 + result[0].html.contains("<li>") || result[0].html.contains("<ul>"), 548 + "Valid list should have list markup. HTML: {}", 549 + result[0].html 550 + ); 551 + 552 + // Should have block syntax span for the marker 553 + assert!( 554 + result[0].html.contains("md-syntax-block"), 555 + "List marker should have block syntax span. HTML: {}", 556 + result[0].html 557 + ); 558 + } 559 + 560 + #[test] 561 + fn test_number_dot_needs_space() { 562 + // "1.text" without space is NOT an ordered list 563 + let result = render_test("1.not-a-list"); 564 + 565 + // Should NOT have <ol> tag 566 + assert!( 567 + !result[0].html.contains("<ol>"), 568 + "'1.text' without space should NOT be ordered list. HTML: {}", 569 + result[0].html 570 + ); 571 + } 572 + 573 + #[test] 574 + fn test_hash_with_zero_width_char() { 575 + // "#\u{200B}text" - zero-width space after # should NOT make it a valid heading 576 + let result = render_test("#\u{200B}text"); 577 + 578 + // Debug: print what we got 579 + eprintln!("HTML for '#\\u{{200B}}text': {}", result[0].html); 580 + 581 + // Should NOT be a heading - zero-width space is not a real space 582 + assert!( 583 + !result[0].html.contains("<h1"), 584 + "# followed by zero-width space should NOT be h1. HTML: {}", 585 + result[0].html 586 + ); 587 + } 588 + 589 + #[test] 590 + fn test_hash_with_zwj() { 591 + // Test with zero-width joiner 592 + let result = render_test("#\u{200C}text"); 593 + 594 + eprintln!("HTML for '#\\u{{200C}}text': {}", result[0].html); 595 + 596 + assert!( 597 + !result[0].html.contains("<h1"), 598 + "# followed by ZWNJ should NOT be h1. HTML: {}", 599 + result[0].html 600 + ); 601 + } 602 + 603 + #[test] 604 + fn test_hash_space_then_zero_width() { 605 + // "# \u{200B}" - valid heading marker, but content is just zero-width 606 + let result = render_test("# \u{200B}"); 607 + 608 + eprintln!("HTML for '# \\u{{200B}}': {}", result[0].html); 609 + eprintln!("Syntax spans: {:?}", result[0].offset_map); 610 + 611 + // This IS a valid heading (has space after #), even if content is "invisible" 612 + // The question is: should we hide the # syntax in this case? 613 + } 614 + 615 + #[test] 616 + fn test_hash_alone() { 617 + // Just "#" at EOL IS a valid empty heading (standard CommonMark behavior) 618 + let result = render_test("#"); 619 + eprintln!("HTML for '#': {}", result[0].html); 620 + 621 + // This IS a heading - empty headings are valid 622 + assert!( 623 + result[0].html.contains("<h1"), 624 + "'#' alone IS a valid empty h1. HTML: {}", 625 + result[0].html 626 + ); 627 + } 628 + 629 + #[test] 630 + fn test_heading_to_non_heading_transition() { 631 + // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 632 + // This tests that the syntax spans are correctly updated on content change. 633 + use loro::LoroDoc; 634 + use super::render::render_paragraphs_incremental; 635 + 636 + let doc = LoroDoc::new(); 637 + let text = doc.get_text("content"); 638 + 639 + // Initial state: "#" is a valid empty heading 640 + text.insert(0, "#").unwrap(); 641 + let (paras1, cache1) = render_paragraphs_incremental(&text, None, None); 642 + 643 + eprintln!("State 1 ('#'): {}", paras1[0].html); 644 + assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); 645 + assert!( 646 + paras1[0].html.contains("md-syntax-block"), 647 + "# should have syntax span" 648 + ); 649 + 650 + // Transition: add "t" to make "#t" - no longer a heading 651 + text.insert(1, "t").unwrap(); 652 + let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None); 653 + 654 + eprintln!("State 2 ('#t'): {}", paras2[0].html); 655 + assert!( 656 + !paras2[0].html.contains("<h1"), 657 + "#t should NOT be heading. HTML: {}", 658 + paras2[0].html 659 + ); 660 + assert!( 661 + !paras2[0].html.contains("md-syntax-block"), 662 + "#t should NOT have block syntax span. HTML: {}", 663 + paras2[0].html 664 + ); 665 + } 666 + 667 + #[test] 668 + fn test_hash_space_alone() { 669 + // "# " (hash + space, no content) - IS this a heading? 670 + let result = render_test("# "); 671 + eprintln!("HTML for '# ': {}", result[0].html); 672 + 673 + // Document actual behavior - this determines if empty headings are valid 674 + } 675 + 676 + #[test] 677 + fn test_empty_blockquote() { 678 + // Just ">" alone - empty blockquote 679 + // BUG: Currently produces 0 paragraphs, making the > invisible! 680 + let result = render_test(">"); 681 + eprintln!("Paragraphs for '>': {:?}", result.len()); 682 + for (i, p) in result.iter().enumerate() { 683 + eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range); 684 + } 685 + 686 + // Empty blockquote should still produce at least one paragraph 687 + // containing the > syntax so it can be rendered and edited 688 + assert!( 689 + !result.is_empty(), 690 + "Empty blockquote should produce at least one paragraph, got 0" 691 + ); 692 + } 693 + 694 + #[test] 695 + fn test_blockquote_needs_space_or_newline() { 696 + // ">text" directly attached might not be a blockquote depending on parser 697 + // This test documents expected behavior 698 + let result = render_test(">quote"); 699 + 700 + // Whether this is a blockquote depends on the parser - document actual behavior 701 + insta::assert_yaml_snapshot!(result, @r#" 702 + - byte_range: 703 + - 6 704 + - 6 705 + char_range: 706 + - 0 707 + - 6 708 + html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">&gt;</span>quote</p>\n</blockquote>\n" 709 + offset_map: 710 + - byte_range: 711 + - 7 712 + - 7 713 + char_range: 714 + - 0 715 + - 0 716 + node_id: n0 717 + char_offset_in_node: 0 718 + child_index: 0 719 + utf16_len: 0 720 + - byte_range: 721 + - 6 722 + - 7 723 + char_range: 724 + - 0 725 + - 1 726 + node_id: n0 727 + char_offset_in_node: 0 728 + child_index: ~ 729 + utf16_len: 1 730 + - byte_range: 731 + - 7 732 + - 12 733 + char_range: 734 + - 1 735 + - 6 736 + node_id: n0 737 + char_offset_in_node: 1 738 + child_index: ~ 739 + utf16_len: 5 740 + source_hash: 6279293067953035109 741 + "#); 742 + } 743 + 744 + // ============================================================================= 429 745 // Char Range Coverage Tests 430 746 // ============================================================================= 431 747
+136 -61
crates/weaver-app/src/components/editor/writer.rs
··· 359 359 { 360 360 opening_span.formatted_range = Some(formatted_range.clone()); 361 361 } else { 362 - tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id); 362 + tracing::warn!( 363 + "[FINALIZE_PAIRED] Could not find opening span {}", 364 + opening_syn_id 365 + ); 363 366 } 364 367 365 368 // Update the closing span's formatted_range (the most recent one) ··· 397 400 return Ok(()); 398 401 } 399 402 400 - let syntax_type = classify_syntax(syntax); 401 - let class = match syntax_type { 402 - SyntaxType::Inline => "md-syntax-inline", 403 - SyntaxType::Block => "md-syntax-block", 404 - }; 403 + // Whitespace-only content (trailing spaces, newlines) should be emitted 404 + // as plain text, not wrapped in a hideable syntax span 405 + let is_whitespace_only = syntax.trim().is_empty(); 406 + 407 + if is_whitespace_only { 408 + // Emit as plain text with tracking span (not hideable) 409 + let created_node = if self.current_node_id.is_none() { 410 + let node_id = self.gen_node_id(); 411 + write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 412 + self.begin_node(node_id); 413 + true 414 + } else { 415 + false 416 + }; 417 + 418 + escape_html(&mut self.writer, syntax)?; 405 419 406 - // Generate unique ID for this syntax span 407 - let syn_id = self.gen_syn_id(); 420 + if created_node { 421 + self.write("</span>")?; 422 + self.end_node(); 423 + } 408 424 409 - // If we're outside any node, create a wrapper span for tracking 410 - let created_node = if self.current_node_id.is_none() { 411 - let node_id = self.gen_node_id(); 412 - write!( 413 - &mut self.writer, 414 - "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 415 - node_id, class, syn_id, char_start, char_end 416 - )?; 417 - self.begin_node(node_id); 418 - true 425 + // Record offset mapping but no syntax span info 426 + self.record_mapping(range.clone(), char_start..char_end); 427 + self.last_char_offset = char_end; 428 + self.last_byte_offset = range.end; 419 429 } else { 420 - write!( 421 - &mut self.writer, 422 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 423 - class, syn_id, char_start, char_end 424 - )?; 425 - false 426 - }; 430 + // Real syntax - wrap in hideable span 431 + let syntax_type = classify_syntax(syntax); 432 + let class = match syntax_type { 433 + SyntaxType::Inline => "md-syntax-inline", 434 + SyntaxType::Block => "md-syntax-block", 435 + }; 436 + 437 + // Generate unique ID for this syntax span 438 + let syn_id = self.gen_syn_id(); 439 + 440 + // If we're outside any node, create a wrapper span for tracking 441 + let created_node = if self.current_node_id.is_none() { 442 + let node_id = self.gen_node_id(); 443 + write!( 444 + &mut self.writer, 445 + "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 446 + node_id, class, syn_id, char_start, char_end 447 + )?; 448 + self.begin_node(node_id); 449 + true 450 + } else { 451 + write!( 452 + &mut self.writer, 453 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 454 + class, syn_id, char_start, char_end 455 + )?; 456 + false 457 + }; 427 458 428 - escape_html(&mut self.writer, syntax)?; 429 - self.write("</span>")?; 459 + escape_html(&mut self.writer, syntax)?; 460 + self.write("</span>")?; 430 461 431 - // Record syntax span info for visibility toggling 432 - self.syntax_spans.push(SyntaxSpanInfo { 433 - syn_id, 434 - char_range: char_start..char_end, 435 - syntax_type, 436 - formatted_range: None, 437 - }); 462 + // Record syntax span info for visibility toggling 463 + self.syntax_spans.push(SyntaxSpanInfo { 464 + syn_id, 465 + char_range: char_start..char_end, 466 + syntax_type, 467 + formatted_range: None, 468 + }); 438 469 439 - // Record offset mapping for this syntax 440 - self.record_mapping(range.clone(), char_start..char_end); 441 - self.last_char_offset = char_end; 442 - self.last_byte_offset = range.end; // Mark bytes as processed 470 + // Record offset mapping for this syntax 471 + self.record_mapping(range.clone(), char_start..char_end); 472 + self.last_char_offset = char_end; 473 + self.last_byte_offset = range.end; 443 474 444 - // Close wrapper if we created one 445 - if created_node { 446 - self.write("</span>")?; 447 - self.end_node(); 475 + // Close wrapper if we created one 476 + if created_node { 477 + self.write("</span>")?; 478 + self.end_node(); 479 + } 448 480 } 449 481 } 450 482 } ··· 585 617 586 618 // Only add checkpoint if we've advanced 587 619 if char_range.end > last_checkpoint.0 { 588 - self.utf16_checkpoints.push((char_range.end, new_utf16_offset)); 620 + self.utf16_checkpoints 621 + .push((char_range.end, new_utf16_offset)); 589 622 } 590 623 591 624 let mapping = OffsetMapping { ··· 675 708 676 709 write!( 677 710 &mut self.writer, 678 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 711 + "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 679 712 syn_id, char_start, char_end 680 713 )?; 681 714 escape_html(&mut self.writer, trailing)?; 682 715 self.write("</span>")?; 683 716 684 717 // Record syntax span info 685 - self.syntax_spans.push(SyntaxSpanInfo { 686 - syn_id, 687 - char_range: char_start..char_end, 688 - syntax_type: SyntaxType::Inline, 689 - formatted_range: None, 690 - }); 718 + // self.syntax_spans.push(SyntaxSpanInfo { 719 + // syn_id, 720 + // char_range: char_start..char_end, 721 + // syntax_type: SyntaxType::Inline, 722 + // formatted_range: None, 723 + // }); 691 724 692 725 // Record mapping if we have a node 693 726 if let Some(ref node_id) = self.current_node_id { ··· 984 1017 let syn_id = self.gen_syn_id(); 985 1018 write!( 986 1019 &mut self.writer, 987 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1020 + "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 988 1021 syn_id, char_start, char_end 989 1022 )?; 990 1023 escape_html(&mut self.writer, spaces)?; 991 1024 self.write("</span>")?; 992 1025 993 1026 // Record syntax span info 994 - self.syntax_spans.push(SyntaxSpanInfo { 995 - syn_id, 996 - char_range: char_start..char_end, 997 - syntax_type: SyntaxType::Inline, 998 - formatted_range: None, 999 - }); 1027 + // self.syntax_spans.push(SyntaxSpanInfo { 1028 + // syn_id, 1029 + // char_range: char_start..char_end, 1030 + // syntax_type: SyntaxType::Inline, 1031 + // formatted_range: None, 1032 + // }); 1000 1033 1001 1034 // Count this span as a child 1002 1035 self.current_node_child_count += 1; ··· 1013 1046 self.current_node_child_count += 1; 1014 1047 1015 1048 // After <br>, emit plain zero-width space for cursor positioning 1016 - self.write("\u{200B}")?; 1049 + self.write(" ")?; 1050 + //self.write("\u{200B}")?; 1017 1051 1018 1052 // Count the zero-width space text node as a child 1019 1053 self.current_node_child_count += 1; ··· 1228 1262 // Record paragraph start for boundary tracking 1229 1263 // BUT skip if inside a list - list owns the paragraph boundary 1230 1264 if self.list_depth == 0 { 1231 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 1265 + self.current_paragraph_start = 1266 + Some((self.last_byte_offset, self.last_char_offset)); 1232 1267 } 1233 1268 1234 1269 let node_id = self.gen_node_id(); ··· 1268 1303 } 1269 1304 } else { 1270 1305 // Just > and maybe a space 1271 - (gt_pos + 2).min(raw_text.len()) 1306 + (gt_pos + 1).min(raw_text.len()) 1272 1307 }; 1273 1308 1274 1309 let syntax = &raw_text[gt_pos..syntax_end]; ··· 1890 1925 Ok(()) 1891 1926 } 1892 1927 } 1893 - TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 1928 + TagEnd::BlockQuote(_) => { 1929 + // If pending_blockquote_range is still set, the blockquote was empty 1930 + // (no paragraph inside). Emit the > as its own minimal paragraph. 1931 + if let Some(bq_range) = self.pending_blockquote_range.take() { 1932 + if bq_range.start < bq_range.end { 1933 + let raw_text = &self.source[bq_range.clone()]; 1934 + if let Some(gt_pos) = raw_text.find('>') { 1935 + let para_byte_start = bq_range.start + gt_pos; 1936 + let para_char_start = self.last_char_offset; 1937 + 1938 + // Create a minimal paragraph for the empty blockquote 1939 + // let node_id = self.gen_node_id(); 1940 + // write!(&mut self.writer, "<p id=\"{}\"", node_id)?; 1941 + // self.begin_node(node_id.clone()); 1942 + 1943 + // // Record start-of-node mapping for cursor positioning 1944 + // self.offset_maps.push(OffsetMapping { 1945 + // byte_range: para_byte_start..para_byte_start, 1946 + // char_range: para_char_start..para_char_start, 1947 + // node_id: node_id.clone(), 1948 + // char_offset_in_node: 0, 1949 + // child_index: Some(0), 1950 + // utf16_len: 0, 1951 + // }); 1952 + 1953 + // Emit the > as block syntax 1954 + let syntax = &raw_text[gt_pos..gt_pos + 1]; 1955 + self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; 1956 + 1957 + // self.write("</p>\n")?; 1958 + // self.end_node(); 1959 + 1960 + // Record paragraph boundary for incremental rendering 1961 + let byte_range = para_byte_start..bq_range.end; 1962 + let char_range = para_char_start..self.last_char_offset; 1963 + self.paragraph_ranges.push((byte_range, char_range)); 1964 + } 1965 + } 1966 + } 1967 + self.write("</blockquote>\n") 1968 + } 1894 1969 TagEnd::CodeBlock => { 1895 1970 use std::sync::LazyLock; 1896 1971 use syntect::parsing::SyntaxSet;
+25 -1
crates/weaver-app/src/views/editor.rs
··· 1 1 //! Editor view - wraps the MarkdownEditor component for the /editor route. 2 2 3 - use dioxus::prelude::*; 4 3 use crate::components::editor::MarkdownEditor; 4 + use dioxus::prelude::*; 5 5 6 6 /// Editor page view. 7 7 /// ··· 10 10 #[component] 11 11 pub fn Editor() -> Element { 12 12 rsx! { 13 + EditorCss {} 13 14 div { class: "editor-page", 14 15 MarkdownEditor { initial_content: None } 15 16 } 16 17 } 17 18 } 19 + 20 + #[component] 21 + pub fn EditorCss() -> Element { 22 + use weaver_renderer::css::{generate_base_css, generate_syntax_css}; 23 + use weaver_renderer::theme::default_resolved_theme; 24 + 25 + let css_content = use_resource(move || async move { 26 + let resolved_theme = default_resolved_theme(); 27 + let mut css = generate_base_css(&resolved_theme); 28 + css.push_str( 29 + &generate_syntax_css(&resolved_theme) 30 + .await 31 + .unwrap_or_default(), 32 + ); 33 + 34 + Some(css) 35 + }); 36 + 37 + match css_content() { 38 + Some(Some(css)) => rsx! { document::Style { {css} } }, 39 + _ => rsx! {}, 40 + } 41 + }