From 0d6ad97453414b8890f8ae74a6fbe8c08cdfae2d Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 26 Nov 2025 13:38:32 -0500 Subject: [PATCH] bunch of random little bugs squished --- .../src/components/editor/formatting.rs | 48 ++- .../weaver-app/src/components/editor/mod.rs | 137 ++++++-- ...components__editor__tests__blockquote.snap | 18 +- ...ts__editor__tests__gap_between_blocks.snap | 2 +- ...components__editor__tests__hard_break.snap | 2 +- ...onents__editor__tests__heading_levels.snap | 8 +- ...__editor__tests__multiple_blank_lines.snap | 2 +- ...omponents__editor__tests__nested_list.snap | 2 +- ...mponents__editor__tests__ordered_list.snap | 2 +- ...ents__editor__tests__three_paragraphs.snap | 4 +- ...ditor__tests__trailing_double_newline.snap | 2 +- ...ditor__tests__trailing_single_newline.snap | 2 +- ...onents__editor__tests__two_paragraphs.snap | 2 +- ...onents__editor__tests__unordered_list.snap | 2 +- .../weaver-app/src/components/editor/tests.rs | 316 ++++++++++++++++++ .../src/components/editor/writer.rs | 197 +++++++---- crates/weaver-app/src/views/editor.rs | 26 +- 17 files changed, 654 insertions(+), 118 deletions(-) diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs index 52fb005..cfa7aec 100644 --- a/crates/weaver-app/src/components/editor/formatting.rs +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -1,5 +1,7 @@ //! Formatting actions and utilities for applying markdown formatting. +use crate::components::editor::{ListContext, detect_list_context, find_line_end}; + use super::document::EditorDocument; /// Formatting actions available in the editor. @@ -114,16 +116,46 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { doc.selection = None; } FormatAction::BulletList => { - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); - let _ = doc.insert_tracked(line_start, "- "); - doc.cursor.offset += 2; - doc.selection = None; + if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { + let continuation = match ctx { + ListContext::Unordered { indent, marker } => { + format!("\n{}{} ", indent, marker) + } + ListContext::Ordered { .. } => { + format!("\n\n - ") + } + }; + let len = continuation.chars().count(); + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); + doc.cursor.offset += len; + doc.selection = None; + } else { + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let _ = doc.insert_tracked(line_start, " - "); + doc.cursor.offset += 3; + doc.selection = None; + } } FormatAction::NumberedList => { - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); - let _ = doc.insert_tracked(line_start, "1. "); - doc.cursor.offset += 3; - doc.selection = None; + if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { + let continuation = match ctx { + ListContext::Unordered { .. } => { + format!("\n\n1. ") + } + ListContext::Ordered { indent, number } => { + format!("\n{}{}. ", indent, number + 1) + } + }; + let len = continuation.chars().count(); + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); + doc.cursor.offset += len; + doc.selection = None; + } else { + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let _ = doc.insert_tracked(line_start, "1. "); + doc.cursor.offset += 3; + doc.selection = None; + } } FormatAction::Quote => { let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 26a9b97..c39895a 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -100,12 +100,12 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Update DOM when paragraphs change (incremental rendering) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { - tracing::info!("DOM update effect triggered"); + tracing::debug!("DOM update effect triggered"); // Read document once to avoid multiple borrows let doc = document(); - tracing::info!( + tracing::debug!( composition_active = doc.composition.is_some(), cursor = doc.cursor.offset, "DOM update: checking state" @@ -113,10 +113,16 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Skip DOM updates during IME composition - browser controls the preview if doc.composition.is_some() { - tracing::info!("skipping DOM update during composition"); + tracing::debug!("skipping DOM update during composition"); return; } + tracing::debug!( + cursor = doc.cursor.offset, + len = doc.len_chars(), + "DOM update proceeding (not in composition)" + ); + let cursor_offset = doc.cursor.offset; let selection = doc.selection; drop(doc); // Release borrow before other operations @@ -234,12 +240,12 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // During IME composition, let browser handle everything // Exception: Escape cancels composition if document.peek().composition.is_some() { - tracing::info!( + tracing::debug!( key = ?evt.key(), "keydown during composition - delegating to browser" ); if evt.key() == Key::Escape { - tracing::info!("Escape pressed - cancelling composition"); + tracing::debug!("Escape pressed - cancelling composition"); document.with_mut(|doc| { doc.composition = None; }); @@ -257,17 +263,21 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { onkeyup: move |evt| { use dioxus::prelude::keyboard_types::Key; - // Only sync cursor from DOM after navigation keys - // Content-modifying keys update cursor directly in handle_keydown - let dominated = matches!( + + // Navigation keys (with or without Shift for selection) + let navigation = matches!( evt.key(), Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End | Key::PageUp | Key::PageDown ); - if dominated { + + // Cmd/Ctrl+A for select all + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) + && matches!(evt.key(), Key::Character(ref c) if c == "a"); + + if navigation || select_all { let paras = cached_paragraphs(); sync_cursor_from_dom(&mut document, editor_id, ¶s); - // Update syntax visibility after cursor sync let doc = document(); let spans = syntax_spans(); update_syntax_visibility( @@ -279,12 +289,52 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } }, + onselect: move |_evt| { + tracing::debug!("onselect fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onselectstart: move |_evt| { + tracing::debug!("onselectstart fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onselectionchange: move |_evt| { + tracing::debug!("onselectionchange fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + onclick: move |_evt| { - // After mouse click or drag selection, sync cursor from DOM - // (click fires after mouseup, so this handles both cases) + tracing::debug!("onclick fired"); let paras = cached_paragraphs(); sync_cursor_from_dom(&mut document, editor_id, ¶s); - // Update syntax visibility after cursor sync let doc = document(); let spans = syntax_spans(); update_syntax_visibility( @@ -311,7 +361,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Cancel any in-progress IME composition on focus loss let had_composition = document.peek().composition.is_some(); if had_composition { - tracing::info!("onblur: clearing active composition"); + tracing::debug!("onblur: clearing active composition"); } document.with_mut(|doc| { doc.composition = None; @@ -320,7 +370,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { oncompositionstart: move |evt: CompositionEvent| { let data = evt.data().data(); - tracing::info!( + tracing::debug!( data = %data, "compositionstart" ); @@ -329,7 +379,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { if let Some(sel) = doc.selection.take() { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - tracing::info!( + tracing::debug!( start, end, "compositionstart: deleting selection" @@ -338,7 +388,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { doc.cursor.offset = start; } - tracing::info!( + tracing::debug!( cursor = doc.cursor.offset, "compositionstart: setting composition state" ); @@ -351,7 +401,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { oncompositionupdate: move |evt: CompositionEvent| { let data = evt.data().data(); - tracing::info!( + tracing::debug!( data = %data, "compositionupdate" ); @@ -359,20 +409,20 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { if let Some(ref mut comp) = doc.composition { comp.text = data; } else { - tracing::info!("compositionupdate without active composition state"); + tracing::debug!("compositionupdate without active composition state"); } }); }, oncompositionend: move |evt: CompositionEvent| { let final_text = evt.data().data(); - tracing::info!( + tracing::debug!( data = %final_text, "compositionend" ); document.with_mut(|doc| { if let Some(comp) = doc.composition.take() { - tracing::info!( + tracing::debug!( start_offset = comp.start_offset, final_text = %final_text, chars = final_text.chars().count(), @@ -380,18 +430,34 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { ); if !final_text.is_empty() { - let _ = doc.insert_tracked(comp.start_offset, &final_text); - doc.cursor.offset = - comp.start_offset + final_text.chars().count(); + let mut delete_start = comp.start_offset; + while delete_start > 0 { + match get_char_at(doc.loro_text(), delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + _ => break, + } + } + + let zw_count = doc.cursor.offset - delete_start; + if zw_count > 0 { + // Splice: delete zero-width chars and insert new char in one op + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); + doc.cursor.offset = delete_start + final_text.chars().count(); + } else if doc.cursor.offset == doc.len_chars() { + // Fast path: append at end + let _ = doc.push_tracked(&final_text); + doc.cursor.offset = comp.start_offset + final_text.chars().count(); + } else { + let _ = doc.insert_tracked(doc.cursor.offset, &final_text); + doc.cursor.offset = comp.start_offset + final_text.chars().count(); + } } } else { - tracing::info!("compositionend without active composition state"); + tracing::debug!("compositionend without active composition state"); } }); }, } - - } EditorToolbar { @@ -534,6 +600,16 @@ fn dom_position_to_rope_offset( let node_id = loop { if let Some(element) = current_node.dyn_ref::() { if element == editor_element { + // Selection is on the editor container itself (e.g., Cmd+A select all) + // Return boundary position based on offset: + // offset 0 = start of editor, offset == child count = end of editor + let child_count = editor_element.child_element_count() as usize; + if offset_in_text_node == 0 { + return Some(0); // Start of document + } else if offset_in_text_node >= child_count { + // End of document - find last paragraph's end + return paragraphs.last().map(|p| p.char_range.end); + } break None; } @@ -1029,7 +1105,7 @@ fn handle_keydown(evt: Event, document: &mut Signal 0 { let ch = get_char_at(doc.loro_text(), delete_start - 1); match ch { - Some(' ') | Some('\t') | Some('\u{200C}') | Some('\u{200B}') => { + Some('\u{200C}') | Some('\u{200B}') => { delete_start -= 1; } Some('\n') => break, // stop at another newline @@ -1307,8 +1383,11 @@ fn update_paragraph_dom( if let Some(old_para) = old_paragraphs.get(idx) { // Paragraph exists - check if changed if new_para.source_hash != old_para.source_hash { - // Changed - update innerHTML + // Changed - clear and update innerHTML + // We clear first to ensure any browser-added content (from IME composition, + // contenteditable quirks, etc.) is fully removed before setting new content if let Some(elem) = document.get_element_by_id(¶_id) { + elem.set_text_content(None); // Clear completely elem.set_inner_html(&new_para.html); } diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap index f657a48..dae2340 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "
\n

> This is a quote\n

\n
\n" + html: "
\n

> This is a quote\n

\n
\n" offset_map: - byte_range: - 43 @@ -22,14 +22,24 @@ expression: result utf16_len: 0 - byte_range: - 41 - - 43 + - 42 char_range: - 0 - - 2 + - 1 node_id: n0 char_offset_in_node: 0 child_index: ~ - utf16_len: 2 + utf16_len: 1 + - byte_range: + - 42 + - 43 + char_range: + - 1 + - 2 + node_id: n0 + char_offset_in_node: 1 + child_index: ~ + utf16_len: 1 - byte_range: - 43 - 58 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap index bb7fb5d..cc09cec 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 10 - html: "

# Heading\n

\n" + html: "

# Heading\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap index 5ab6180..7853751 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__hard_break.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 19 - html: "

Line one
​Line two

\n" + html: "

Line one
Line two

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap index a21aca8..bcc0713 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 5 - html: "

# H1\n

\n" + html: "

# H1\n

\n" offset_map: - byte_range: - 0 @@ -57,7 +57,7 @@ expression: result char_range: - 6 - 12 - html: "

## H2\n

\n" + html: "

## H2\n

\n" offset_map: - byte_range: - 6 @@ -106,7 +106,7 @@ expression: result char_range: - 13 - 20 - html: "

### H3\n

\n" + html: "

### H3\n

\n" offset_map: - byte_range: - 13 @@ -155,7 +155,7 @@ expression: result char_range: - 21 - 28 - html: "

#### H4

\n" + html: "

#### H4

\n" offset_map: - byte_range: - 21 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap index 0748f98..22f97a4 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

First\n

\n" + html: "

First\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap index d1b205a..184614a 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap @@ -27,7 +27,7 @@ expression: result char_range: - 11 - 33 - html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" + html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" offset_map: - byte_range: - 11 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap index f40a915..7b301c1 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" + html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap index 4e177ed..bf13ea0 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 5 - html: "

One.\n

\n" + html: "

One.\n

\n" offset_map: - byte_range: - 0 @@ -47,7 +47,7 @@ expression: result char_range: - 6 - 11 - html: "

Two.\n

\n" + html: "

Two.\n

\n" offset_map: - byte_range: - 6 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap index be54f3b..5638f1f 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_double_newline.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

Hello\n

\n" + html: "

Hello\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap index e306b98..02165ae 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__trailing_single_newline.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 6 - html: "

Hello\n

\n" + html: "

Hello\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap index c1bd773..e36817b 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 17 - html: "

First paragraph.\n

\n" + html: "

First paragraph.\n

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap index 437c33c..d0eae33 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 26 - html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" + html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 0ee81c6..2b5e7ac 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -425,6 +425,322 @@ fn regression_bug11_gap_paragraphs_for_whitespace() { assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent"); } +// ============================================================================= +// Syntax Span Edge Case Tests +// ============================================================================= + +#[test] +fn test_invalid_heading_no_space() { + // "#text" without space is NOT a valid heading - should be plain text + // The '#' should NOT be wrapped in a syntax span + let result = render_test("#text"); + + // Should be a single paragraph with plain text + assert_eq!(result.len(), 1, "Should have 1 paragraph"); + + // HTML should NOT contain md-syntax-block for the # + assert!( + !result[0].html.contains("md-syntax-block"), + "Invalid heading '#text' should NOT have block syntax span. HTML: {}", + result[0].html + ); + + // The # should be visible as regular text content + assert!( + result[0].html.contains("#text") || result[0].html.contains("#text"), + "The '#text' should appear as regular text. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_valid_heading_with_space() { + // "# text" WITH space IS a valid heading + let result = render_test("# Heading"); + + // Should have heading syntax span + assert!( + result[0].html.contains("md-syntax-block"), + "Valid heading should have block syntax span. HTML: {}", + result[0].html + ); + + // Should have

tag + assert!( + result[0].html.contains(" tag + assert!( + !result[0].html.contains(""), + "Unclosed ** should NOT render as bold. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_unclosed_italic() { + // "*text" without closing * should be plain text, not italic + let result = render_test("*unclosed italic"); + + // Should NOT have tag + assert!( + !result[0].html.contains(""), + "Unclosed * should NOT render as italic. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_asterisk_not_emphasis() { + // Single * surrounded by spaces is not emphasis + let result = render_test("5 * 3 = 15"); + + // Should NOT have tag + assert!( + !result[0].html.contains(""), + "Math expression with * should NOT be italic. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_list_marker_needs_space() { + // "-text" without space is NOT a list item + let result = render_test("-not-a-list"); + + // Should NOT have
  • or
      tags + assert!( + !result[0].html.contains("
    • ") && !result[0].html.contains("
        "), + "'-text' without space should NOT be a list. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_valid_list_with_space() { + // "- text" WITH space IS a valid list item + let result = render_test("- List item"); + + // Should have list markup + assert!( + result[0].html.contains("
      • ") || result[0].html.contains("
          "), + "Valid list should have list markup. HTML: {}", + result[0].html + ); + + // Should have block syntax span for the marker + assert!( + result[0].html.contains("md-syntax-block"), + "List marker should have block syntax span. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_number_dot_needs_space() { + // "1.text" without space is NOT an ordered list + let result = render_test("1.not-a-list"); + + // Should NOT have
            tag + assert!( + !result[0].html.contains("
              "), + "'1.text' without space should NOT be ordered list. HTML: {}", + result[0].html + ); +} + +#[test] +fn test_hash_with_zero_width_char() { + // "#\u{200B}text" - zero-width space after # should NOT make it a valid heading + let result = render_test("#\u{200B}text"); + + // Debug: print what we got + eprintln!("HTML for '#\\u{{200B}}text': {}", result[0].html); + + // Should NOT be a heading - zero-width space is not a real space + assert!( + !result[0].html.contains("" alone - empty blockquote + // BUG: Currently produces 0 paragraphs, making the > invisible! + let result = render_test(">"); + eprintln!("Paragraphs for '>': {:?}", result.len()); + for (i, p) in result.iter().enumerate() { + eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range); + } + + // Empty blockquote should still produce at least one paragraph + // containing the > syntax so it can be rendered and edited + assert!( + !result.is_empty(), + "Empty blockquote should produce at least one paragraph, got 0" + ); +} + +#[test] +fn test_blockquote_needs_space_or_newline() { + // ">text" directly attached might not be a blockquote depending on parser + // This test documents expected behavior + let result = render_test(">quote"); + + // Whether this is a blockquote depends on the parser - document actual behavior + insta::assert_yaml_snapshot!(result, @r#" + - byte_range: + - 6 + - 6 + char_range: + - 0 + - 6 + html: "
              \n

              >quote

              \n
              \n" + offset_map: + - byte_range: + - 7 + - 7 + char_range: + - 0 + - 0 + node_id: n0 + char_offset_in_node: 0 + child_index: 0 + utf16_len: 0 + - byte_range: + - 6 + - 7 + char_range: + - 0 + - 1 + node_id: n0 + char_offset_in_node: 0 + child_index: ~ + utf16_len: 1 + - byte_range: + - 7 + - 12 + char_range: + - 1 + - 6 + node_id: n0 + char_offset_in_node: 1 + child_index: ~ + utf16_len: 5 + source_hash: 6279293067953035109 + "#); +} + // ============================================================================= // Char Range Coverage Tests // ============================================================================= diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 5056101..027be85 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -359,7 +359,10 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon { opening_span.formatted_range = Some(formatted_range.clone()); } else { - tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id); + tracing::warn!( + "[FINALIZE_PAIRED] Could not find opening span {}", + opening_syn_id + ); } // Update the closing span's formatted_range (the most recent one) @@ -397,54 +400,83 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon return Ok(()); } - let syntax_type = classify_syntax(syntax); - let class = match syntax_type { - SyntaxType::Inline => "md-syntax-inline", - SyntaxType::Block => "md-syntax-block", - }; + // Whitespace-only content (trailing spaces, newlines) should be emitted + // as plain text, not wrapped in a hideable syntax span + let is_whitespace_only = syntax.trim().is_empty(); - // Generate unique ID for this syntax span - let syn_id = self.gen_syn_id(); + if is_whitespace_only { + // Emit as plain text with tracking span (not hideable) + let created_node = if self.current_node_id.is_none() { + let node_id = self.gen_node_id(); + write!(&mut self.writer, "", node_id)?; + self.begin_node(node_id); + true + } else { + false + }; - // If we're outside any node, create a wrapper span for tracking - let created_node = if self.current_node_id.is_none() { - let node_id = self.gen_node_id(); - write!( - &mut self.writer, - "", - node_id, class, syn_id, char_start, char_end - )?; - self.begin_node(node_id); - true - } else { - write!( - &mut self.writer, - "", - class, syn_id, char_start, char_end - )?; - false - }; + escape_html(&mut self.writer, syntax)?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; + if created_node { + self.write("")?; + self.end_node(); + } - // Record syntax span info for visibility toggling - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type, - formatted_range: None, - }); + // Record offset mapping but no syntax span info + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } else { + // Real syntax - wrap in hideable span + let syntax_type = classify_syntax(syntax); + let class = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", + }; + + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); - // Record offset mapping for this syntax - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; - self.last_byte_offset = range.end; // Mark bytes as processed + // If we're outside any node, create a wrapper span for tracking + let created_node = if self.current_node_id.is_none() { + let node_id = self.gen_node_id(); + write!( + &mut self.writer, + "", + node_id, class, syn_id, char_start, char_end + )?; + self.begin_node(node_id); + true + } else { + write!( + &mut self.writer, + "", + class, syn_id, char_start, char_end + )?; + false + }; - // Close wrapper if we created one - if created_node { + escape_html(&mut self.writer, syntax)?; self.write("")?; - self.end_node(); + + // Record syntax span info for visibility toggling + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type, + formatted_range: None, + }); + + // Record offset mapping for this syntax + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + + // Close wrapper if we created one + if created_node { + self.write("")?; + self.end_node(); + } } } } @@ -585,7 +617,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Only add checkpoint if we've advanced if char_range.end > last_checkpoint.0 { - self.utf16_checkpoints.push((char_range.end, new_utf16_offset)); + self.utf16_checkpoints + .push((char_range.end, new_utf16_offset)); } let mapping = OffsetMapping { @@ -675,19 +708,19 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon write!( &mut self.writer, - "", + "", syn_id, char_start, char_end )?; escape_html(&mut self.writer, trailing)?; self.write("")?; // Record syntax span info - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, - }); + // self.syntax_spans.push(SyntaxSpanInfo { + // syn_id, + // char_range: char_start..char_end, + // syntax_type: SyntaxType::Inline, + // formatted_range: None, + // }); // Record mapping if we have a node if let Some(ref node_id) = self.current_node_id { @@ -984,19 +1017,19 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syn_id = self.gen_syn_id(); write!( &mut self.writer, - "", + "", syn_id, char_start, char_end )?; escape_html(&mut self.writer, spaces)?; self.write("")?; // Record syntax span info - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, - }); + // self.syntax_spans.push(SyntaxSpanInfo { + // syn_id, + // char_range: char_start..char_end, + // syntax_type: SyntaxType::Inline, + // formatted_range: None, + // }); // Count this span as a child self.current_node_child_count += 1; @@ -1013,7 +1046,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon self.current_node_child_count += 1; // After
              , emit plain zero-width space for cursor positioning - self.write("\u{200B}")?; + self.write(" ")?; + //self.write("\u{200B}")?; // Count the zero-width space text node as a child self.current_node_child_count += 1; @@ -1228,7 +1262,8 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Record paragraph start for boundary tracking // BUT skip if inside a list - list owns the paragraph boundary if self.list_depth == 0 { - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.current_paragraph_start = + Some((self.last_byte_offset, self.last_char_offset)); } let node_id = self.gen_node_id(); @@ -1268,7 +1303,7 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } } else { // Just > and maybe a space - (gt_pos + 2).min(raw_text.len()) + (gt_pos + 1).min(raw_text.len()) }; let syntax = &raw_text[gt_pos..syntax_end]; @@ -1890,7 +1925,47 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon Ok(()) } } - TagEnd::BlockQuote(_) => self.write("\n"), + TagEnd::BlockQuote(_) => { + // If pending_blockquote_range is still set, the blockquote was empty + // (no paragraph inside). Emit the > as its own minimal paragraph. + if let Some(bq_range) = self.pending_blockquote_range.take() { + if bq_range.start < bq_range.end { + let raw_text = &self.source[bq_range.clone()]; + if let Some(gt_pos) = raw_text.find('>') { + let para_byte_start = bq_range.start + gt_pos; + let para_char_start = self.last_char_offset; + + // Create a minimal paragraph for the empty blockquote + // let node_id = self.gen_node_id(); + // write!(&mut self.writer, "

              as block syntax + let syntax = &raw_text[gt_pos..gt_pos + 1]; + self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; + + // self.write("

              \n")?; + // self.end_node(); + + // Record paragraph boundary for incremental rendering + let byte_range = para_byte_start..bq_range.end; + let char_range = para_char_start..self.last_char_offset; + self.paragraph_ranges.push((byte_range, char_range)); + } + } + } + self.write("\n") + } TagEnd::CodeBlock => { use std::sync::LazyLock; use syntect::parsing::SyntaxSet; diff --git a/crates/weaver-app/src/views/editor.rs b/crates/weaver-app/src/views/editor.rs index cf437a4..0d78480 100644 --- a/crates/weaver-app/src/views/editor.rs +++ b/crates/weaver-app/src/views/editor.rs @@ -1,7 +1,7 @@ //! Editor view - wraps the MarkdownEditor component for the /editor route. -use dioxus::prelude::*; use crate::components::editor::MarkdownEditor; +use dioxus::prelude::*; /// Editor page view. /// @@ -10,8 +10,32 @@ use crate::components::editor::MarkdownEditor; #[component] pub fn Editor() -> Element { rsx! { + EditorCss {} div { class: "editor-page", MarkdownEditor { initial_content: None } } } } + +#[component] +pub fn EditorCss() -> Element { + use weaver_renderer::css::{generate_base_css, generate_syntax_css}; + use weaver_renderer::theme::default_resolved_theme; + + let css_content = use_resource(move || async move { + let resolved_theme = default_resolved_theme(); + let mut css = generate_base_css(&resolved_theme); + css.push_str( + &generate_syntax_css(&resolved_theme) + .await + .unwrap_or_default(), + ); + + Some(css) + }); + + match css_content() { + Some(Some(css)) => rsx! { document::Style { {css} } }, + _ => rsx! {}, + } +} -- knotmirror 0.0.1