diff --git a/Cargo.lock b/Cargo.lock index fa644f4..8c5b576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12049,6 +12049,7 @@ dependencies = [ "wasm-bindgen-futures", "weaver-api", "weaver-common", + "weaver-editor-core", "weaver-renderer", "web-sys", "web-time", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 9d6dfe7..d242b8e 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -48,6 +48,7 @@ dashmap = "6.1.0" dioxus = { version = "0.7.1", features = ["router"] } weaver-common = { path = "../weaver-common" } +weaver-editor-core = { path = "../weaver-editor-core" } jacquard = { workspace = true}#, features = ["streaming"] } jacquard-lexicon = { workspace = true } jacquard-identity = { workspace = true } diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs index 3758e99..072502d 100644 --- a/crates/weaver-app/src/components/editor/actions.rs +++ b/crates/weaver-app/src/components/editor/actions.rs @@ -753,7 +753,7 @@ pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool { use super::input::{ detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, }; - use super::offset_map::SnapDirection; + use weaver_editor_core::SnapDirection; match action { EditorAction::Insert { text, range } => { diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index d52510c..15f6ef3 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -15,7 +15,6 @@ use super::dom_sync::update_paragraph_dom; use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; use super::formatting; use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; -use super::offset_map::SnapDirection; use super::paragraph::ParagraphRender; use super::platform; #[allow(unused_imports)] @@ -45,6 +44,7 @@ use jacquard::types::blob::BlobRef; use jacquard::types::ident::AtIdentifier; use weaver_api::sh_weaver::embed::images::Image; use weaver_common::WeaverExt; +use weaver_editor_core::SnapDirection; /// Result of loading document state. enum LoadResult { @@ -1868,7 +1868,7 @@ fn RemoteCursorIndicator( position: usize, selection: Option<(usize, usize)>, color: u32, - offset_map: Vec, + offset_map: Vec, ) -> Element { use super::cursor::{get_cursor_rect_relative, get_selection_rects_relative}; diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 3ea6207..0869ea5 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -7,9 +7,9 @@ //! 3. Walking text nodes to find the UTF-16 offset within the element //! 4. Setting cursor with web_sys Selection API -use super::offset_map::OffsetMapping; +use weaver_editor_core::OffsetMapping; #[cfg(all(target_family = "wasm", target_os = "unknown"))] -use super::offset_map::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; +use weaver_editor_core::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use wasm_bindgen::JsCast; diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index e98ce76..ce7432d 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -144,7 +144,7 @@ pub struct EditorDocument { /// Pending snap direction for cursor restoration after edits. /// Set by input handlers, consumed by cursor restoration. - pub pending_snap: Signal>, + pub pending_snap: Signal>, /// Collected refs (wikilinks, AT embeds) from the most recent render. /// Updated by the render pipeline, read by publish for populating records. diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index d1bc22c..2213d1e 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -8,7 +8,7 @@ use super::cursor::restore_cursor_position; #[allow(unused_imports)] use super::document::{EditorDocument, Selection}; #[allow(unused_imports)] -use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; +use weaver_editor_core::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; use super::paragraph::ParagraphRender; #[allow(unused_imports)] use dioxus::prelude::*; diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs index 2f3bc60..5cd9484 100644 --- a/crates/weaver-app/src/components/editor/input.rs +++ b/crates/weaver-app/src/components/editor/input.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; use super::document::EditorDocument; use super::formatting::{self, FormatAction}; -use super::offset_map::SnapDirection; +use weaver_editor_core::SnapDirection; /// Check if we need to intercept this key event. /// Returns true for content-modifying operations, false for navigation. diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index bec51bd..408c504 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -15,7 +15,6 @@ mod formatting; mod image_upload; mod input; mod log_buffer; -mod offset_map; mod paragraph; mod platform; mod publish; @@ -54,15 +53,19 @@ pub use document::{ #[allow(unused_imports)] pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; -// Rendering +// Rendering - re-export core types #[allow(unused_imports)] -pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; +pub use weaver_editor_core::{ + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, RenderResult, + SegmentedWriter, SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, find_mapping_for_byte, +}; #[allow(unused_imports)] pub use paragraph::ParagraphRender; #[allow(unused_imports)] pub use render::{RenderCache, render_paragraphs_incremental}; +// App-specific image resolver #[allow(unused_imports)] -pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, WriterResult}; +pub use writer::embed::EditorImageResolver; // Storage #[allow(unused_imports)] diff --git a/crates/weaver-app/src/components/editor/offset_map.rs b/crates/weaver-app/src/components/editor/offset_map.rs deleted file mode 100644 index 41636c8..0000000 --- a/crates/weaver-app/src/components/editor/offset_map.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! Offset mapping between source text and rendered DOM. -//! -//! When rendering markdown to HTML, some characters disappear (table pipes) -//! and content gets split across nodes (syntax highlighting). Offset maps -//! track how source byte positions map to DOM node positions. - -use std::ops::Range; - -/// Result of rendering markdown with offset tracking. -#[derive(Debug, Clone, PartialEq)] -pub struct RenderResult { - /// Rendered HTML string - pub html: String, - - /// Mappings from source bytes to DOM positions - pub offset_map: Vec, -} - -/// Maps a source range to a position in the rendered DOM. -/// -/// # Example -/// -/// Source: `| foo | bar |` -/// Bytes: 0 2-5 7-10 12 -/// Chars: 0 2-5 7-10 12 (ASCII, so same) -/// -/// Rendered: -/// ```html -/// -/// -///
foobar
-/// ``` -/// -/// Mappings: -/// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible -/// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible -/// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible -/// - etc. -#[derive(Debug, Clone, PartialEq)] -pub struct OffsetMapping { - /// Source byte range (UTF-8 bytes, from parser) - pub byte_range: Range, - - /// Source char range (Unicode scalar values, for rope indexing) - pub char_range: Range, - - /// DOM node ID containing this content - /// For invisible content, this is the nearest visible container - pub node_id: String, - - /// Position within the node - /// - If child_index is Some: cursor at that child index in the element - /// - If child_index is None: UTF-16 offset in text content - pub char_offset_in_node: usize, - - /// If Some, position cursor at this child index in the element (not in text) - /// Used for positions after
or at empty lines - pub child_index: Option, - - /// Length of this mapping in UTF-16 chars in DOM - /// If 0, these source bytes aren't rendered (table pipes, etc) - pub utf16_len: usize, -} - -impl OffsetMapping { - /// Check if this mapping contains the given byte offset - pub fn contains_byte(&self, byte_offset: usize) -> bool { - self.byte_range.contains(&byte_offset) - } - - /// Check if this mapping contains the given char offset - pub fn contains_char(&self, char_offset: usize) -> bool { - self.char_range.contains(&char_offset) - } - - /// Check if this mapping represents invisible content - pub fn is_invisible(&self) -> bool { - self.utf16_len == 0 - } -} - -/// Find the offset mapping containing the given byte offset. -/// -/// Returns the mapping and whether the cursor should snap to the next -/// visible position (for invisible content). -pub fn find_mapping_for_byte( - offset_map: &[OffsetMapping], - byte_offset: usize, -) -> Option<(&OffsetMapping, bool)> { - // Binary search for the mapping - // Note: We allow cursor at the end boundary of a mapping (cursor after text) - let idx = offset_map - .binary_search_by(|mapping| { - if mapping.byte_range.end < byte_offset { - std::cmp::Ordering::Less - } else if mapping.byte_range.start > byte_offset { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Equal - } - }) - .ok()?; - - let mapping = &offset_map[idx]; - let should_snap = mapping.is_invisible(); - - Some((mapping, should_snap)) -} - -/// Find the offset mapping containing the given char offset. -/// -/// This is the primary lookup method for cursor restoration, since -/// cursor positions are tracked as char offsets in the rope. -/// -/// Returns the mapping and whether the cursor should snap to the next -/// visible position (for invisible content). -#[allow(dead_code)] -pub fn find_mapping_for_char( - offset_map: &[OffsetMapping], - char_offset: usize, -) -> Option<(&OffsetMapping, bool)> { - // Binary search for the mapping - // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. - // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), - // prefer the NEXT mapping so cursor goes "down" to new content. - let result = offset_map.binary_search_by(|mapping| { - if mapping.char_range.end <= char_offset { - // Cursor is at or after end of this mapping - look forward - std::cmp::Ordering::Less - } else if mapping.char_range.start > char_offset { - // Cursor is before this mapping - std::cmp::Ordering::Greater - } else { - // Cursor is within [start, end) - std::cmp::Ordering::Equal - } - }); - - let mapping = match result { - Ok(idx) => &offset_map[idx], - Err(idx) => { - // No exact match - cursor is at boundary between mappings (or past end) - // If cursor is exactly at end of previous mapping, return that mapping - // This handles cursor at end of document or end of last mapping - if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { - &offset_map[idx - 1] - } else { - return None; - } - } - }; - - let should_snap = mapping.is_invisible(); - Some((mapping, should_snap)) -} - -/// Direction hint for cursor snapping. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SnapDirection { - Backward, - Forward, -} - -/// Result of finding a valid cursor position. -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SnappedPosition<'a> { - pub mapping: &'a OffsetMapping, - pub offset_in_mapping: usize, - pub snapped: Option, -} - -#[allow(dead_code)] -impl SnappedPosition<'_> { - /// Get the absolute char offset for this position. - pub fn char_offset(&self) -> usize { - self.mapping.char_range.start + self.offset_in_mapping - } -} - -/// Find the nearest valid cursor position to a char offset. -/// -/// A valid position is one that maps to visible content (utf16_len > 0). -/// If the position is already valid, returns it directly. Otherwise, -/// searches in the preferred direction first, falling back to the other -/// direction if needed. -#[allow(dead_code)] -pub fn find_nearest_valid_position( - offset_map: &[OffsetMapping], - char_offset: usize, - preferred_direction: Option, -) -> Option> { - if offset_map.is_empty() { - return None; - } - - // Try exact match first - if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { - if !should_snap { - // Position is valid, return it directly - let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); - return Some(SnappedPosition { - mapping, - offset_in_mapping, - snapped: None, - }); - } - } - - // Position is invalid or not found - search for nearest valid - let search_order = match preferred_direction { - Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], - Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], - }; - - for direction in search_order { - if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { - return Some(pos); - } - } - - None -} - -/// Search for a valid position in a specific direction. -#[allow(dead_code)] -fn find_valid_in_direction( - offset_map: &[OffsetMapping], - char_offset: usize, - direction: SnapDirection, -) -> Option> { - match direction { - SnapDirection::Forward => { - // Find first visible mapping at or after char_offset - for mapping in offset_map { - if mapping.char_range.start >= char_offset && !mapping.is_invisible() { - return Some(SnappedPosition { - mapping, - offset_in_mapping: 0, - snapped: Some(SnapDirection::Forward), - }); - } - // Also check if char_offset falls within this visible mapping - if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { - let offset_in_mapping = char_offset - mapping.char_range.start; - return Some(SnappedPosition { - mapping, - offset_in_mapping, - snapped: Some(SnapDirection::Forward), - }); - } - } - None - } - SnapDirection::Backward => { - // Find last visible mapping at or before char_offset - for mapping in offset_map.iter().rev() { - if mapping.char_range.end <= char_offset && !mapping.is_invisible() { - // Snap to end of this mapping - let offset_in_mapping = mapping.char_range.len(); - return Some(SnappedPosition { - mapping, - offset_in_mapping, - snapped: Some(SnapDirection::Backward), - }); - } - // Also check if char_offset falls within this visible mapping - if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { - let offset_in_mapping = char_offset - mapping.char_range.start; - return Some(SnappedPosition { - mapping, - offset_in_mapping, - snapped: Some(SnapDirection::Backward), - }); - } - } - None - } - } -} - -/// Check if a char offset is at a valid (non-invisible) cursor position. -#[allow(dead_code)] -pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { - find_mapping_for_char(offset_map, char_offset) - .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_find_mapping_by_byte() { - let mappings = vec![ - OffsetMapping { - byte_range: 0..2, - char_range: 0..2, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 0, // invisible - }, - OffsetMapping { - byte_range: 2..5, - char_range: 2..5, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 3, - }, - OffsetMapping { - byte_range: 5..7, - char_range: 5..7, - node_id: "n0".to_string(), - char_offset_in_node: 3, - child_index: None, - utf16_len: 0, // invisible - }, - ]; - - // Byte 0 (invisible) - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); - assert_eq!(mapping.byte_range, 0..2); - assert!(should_snap); - - // Byte 3 (visible) - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); - assert_eq!(mapping.byte_range, 2..5); - assert!(!should_snap); - - // Byte 6 (invisible) - let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); - assert_eq!(mapping.byte_range, 5..7); - assert!(should_snap); - } - - #[test] - fn test_find_mapping_by_char() { - let mappings = vec![ - OffsetMapping { - byte_range: 0..2, - char_range: 0..2, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 0, // invisible - }, - OffsetMapping { - byte_range: 2..5, - char_range: 2..5, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 3, - }, - OffsetMapping { - byte_range: 5..7, - char_range: 5..7, - node_id: "n0".to_string(), - char_offset_in_node: 3, - child_index: None, - utf16_len: 0, // invisible - }, - ]; - - // Char 0 (invisible) - let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); - assert_eq!(mapping.char_range, 0..2); - assert!(should_snap); - - // Char 3 (visible) - let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); - assert_eq!(mapping.char_range, 2..5); - assert!(!should_snap); - - // Char 6 (invisible) - let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); - assert_eq!(mapping.char_range, 5..7); - assert!(should_snap); - } - - #[test] - fn test_contains_byte() { - let mapping = OffsetMapping { - byte_range: 10..20, - char_range: 10..20, - node_id: "test".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 5, - }; - - assert!(!mapping.contains_byte(9)); - assert!(mapping.contains_byte(10)); - assert!(mapping.contains_byte(15)); - assert!(mapping.contains_byte(19)); - assert!(!mapping.contains_byte(20)); - } - - #[test] - fn test_contains_char() { - let mapping = OffsetMapping { - byte_range: 10..20, - char_range: 8..15, // emoji example: fewer chars than bytes - node_id: "test".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 5, - }; - - assert!(!mapping.contains_char(7)); - assert!(mapping.contains_char(8)); - assert!(mapping.contains_char(12)); - assert!(mapping.contains_char(14)); - assert!(!mapping.contains_char(15)); - } - - fn make_test_mappings() -> Vec { - vec![ - OffsetMapping { - byte_range: 0..2, - char_range: 0..2, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 0, // invisible: "![" - }, - OffsetMapping { - byte_range: 2..5, - char_range: 2..5, - node_id: "n0".to_string(), - char_offset_in_node: 0, - child_index: None, - utf16_len: 3, // visible: "alt" - }, - OffsetMapping { - byte_range: 5..15, - char_range: 5..15, - node_id: "n0".to_string(), - char_offset_in_node: 3, - child_index: None, - utf16_len: 0, // invisible: "](url.png)" - }, - OffsetMapping { - byte_range: 15..20, - char_range: 15..20, - node_id: "n0".to_string(), - char_offset_in_node: 3, - child_index: None, - utf16_len: 5, // visible: " text" - }, - ] - } - - #[test] - fn test_find_nearest_valid_position_exact_match() { - let mappings = make_test_mappings(); - - // Position 3 is in visible mapping (2..5) - let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); - assert_eq!(pos.char_offset(), 3); - assert!(pos.snapped.is_none()); - } - - #[test] - fn test_find_nearest_valid_position_snap_forward() { - let mappings = make_test_mappings(); - - // Position 0 is invisible, should snap forward to 2 - let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); - assert_eq!(pos.char_offset(), 2); - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); - } - - #[test] - fn test_find_nearest_valid_position_snap_backward() { - let mappings = make_test_mappings(); - - // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) - let pos = - find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); - assert_eq!(pos.char_offset(), 5); // end of "alt" mapping - assert_eq!(pos.snapped, Some(SnapDirection::Backward)); - } - - #[test] - fn test_find_nearest_valid_position_default_forward() { - let mappings = make_test_mappings(); - - // Position 0 is invisible, None direction defaults to forward - let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); - assert_eq!(pos.char_offset(), 2); - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); - } - - #[test] - fn test_find_nearest_valid_position_snap_forward_from_invisible() { - let mappings = make_test_mappings(); - - // Position 10 is in invisible range (5..15), forward finds visible (15..20) - let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); - assert_eq!(pos.char_offset(), 15); - assert_eq!(pos.snapped, Some(SnapDirection::Forward)); - } - - #[test] - fn test_is_valid_cursor_position() { - let mappings = make_test_mappings(); - - // Invisible positions - assert!(!is_valid_cursor_position(&mappings, 0)); - assert!(!is_valid_cursor_position(&mappings, 1)); - assert!(!is_valid_cursor_position(&mappings, 10)); - - // Visible positions - assert!(is_valid_cursor_position(&mappings, 2)); - assert!(is_valid_cursor_position(&mappings, 3)); - assert!(is_valid_cursor_position(&mappings, 4)); - assert!(is_valid_cursor_position(&mappings, 15)); - assert!(is_valid_cursor_position(&mappings, 17)); - } - - #[test] - fn test_find_nearest_valid_position_empty() { - let mappings: Vec = vec![]; - assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); - } -} diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs index 964f676..9212179 100644 --- a/crates/weaver-app/src/components/editor/paragraph.rs +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -3,10 +3,9 @@ //! Paragraphs are discovered during markdown rendering by tracking //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. -use super::offset_map::OffsetMapping; -use super::writer::SyntaxSpanInfo; use loro::LoroText; use std::ops::Range; +use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; /// A rendered paragraph with its source range and offset mappings. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 629488a..6b68429 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -5,15 +5,15 @@ //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. use super::document::EditInfo; -#[allow(unused_imports)] -use super::offset_map::{OffsetMapping, RenderResult}; use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; -#[allow(unused_imports)] -use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; +use super::writer::embed::EditorImageResolver; use loro::LoroText; use markdown_weaver::Parser; use std::ops::Range; use weaver_common::{EntryIndex, ResolvedContent}; +use weaver_editor_core::{ + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SyntaxSpanInfo, +}; /// Cache for incremental paragraph rendering. /// Stores previously rendered paragraphs to avoid re-rendering unchanged content. @@ -413,18 +413,17 @@ pub fn render_paragraphs_incremental( let parser = Parser::new_ext(¶_source, weaver_renderer::default_md_options()) .into_offset_iter(); - let para_doc = loro::LoroDoc::new(); - let para_text = para_doc.get_text("content"); - let _ = para_text.insert(0, ¶_source); + let para_rope = EditorRope::from(para_source.as_str()); - let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( - ¶_source, - ¶_text, - parser, - ) - .with_node_id_prefix(&cached_para.id) - .with_image_resolver(&resolver) - .with_embed_provider(resolved_content); + let mut writer = + EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( + ¶_source, + ¶_rope, + parser, + ) + .with_node_id_prefix(&cached_para.id) + .with_image_resolver(&resolver) + .with_embed_provider(resolved_content); if let Some(idx) = entry_index { writer = writer.with_entry_index(idx); @@ -613,10 +612,8 @@ pub fn render_paragraphs_incremental( // Use provided resolver or empty default let resolver = image_resolver.cloned().unwrap_or_default(); - // Create a temporary LoroText for the slice (needed by writer) - let slice_doc = loro::LoroDoc::new(); - let slice_text = slice_doc.get_text("content"); - let _ = slice_text.insert(0, parse_slice); + // Create EditorRope for efficient offset conversions + let slice_rope = EditorRope::from(parse_slice); // Determine starting paragraph ID for freshly parsed paragraphs // This MUST match the IDs we assign later - the writer bakes node ID prefixes into HTML @@ -665,9 +662,9 @@ pub fn render_paragraphs_incremental( }); // Build writer with all resolvers and auto-incrementing paragraph prefixes - let mut writer = EditorWriter::<_, &ResolvedContent, &EditorImageResolver>::new( + let mut writer = EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver, ()>::new( parse_slice, - &slice_text, + &slice_rope, parser, ) .with_auto_incrementing_prefix(parsed_para_id_start) 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 ecac4e7..eba330c 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" + html: "

> This is a quote\n

" offset_map: - byte_range: - 2 @@ -61,32 +61,13 @@ expression: result child_index: ~ utf16_len: 1 source_hash: 11268153476336590706 -- byte_range: - - 18 - - 22 - char_range: - - 18 - - 22 - html: "​" - offset_map: - - byte_range: - - 18 - - 22 - char_range: - - 18 - - 22 - node_id: gap-20-22 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 1 - source_hash: 6078396554664461735 - byte_range: - 22 - 41 char_range: - 22 - 41 - html: ">\n> \n

With multiple lines

\n" + html: ">\n>

With multiple lines

" offset_map: - byte_range: - 18 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap index 323f2f1..30ae785 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some **bold** text

\n" + html: "

Some **bold** text

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap index 94d4d8d..ec2a55f 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__bold_italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "

Some ***bold italic*** text

\n" + html: "

Some ***bold italic*** text

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap index c2495e8..04c6bd1 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 24 - html: "```rust\n
fn main() {}\n
```" + html: "```rust
fn main() {}\n
```" offset_map: - byte_range: - 8 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 597e5d5..6028e13 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

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

Paragraph below

\n" + html: "\n

Paragraph below

" offset_map: - byte_range: - 10 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 556c922..3846fc0 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

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap index 14c269c..29e1e89 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_h1.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 11 - html: "

# Heading 1

\n" + html: "

# Heading 1

" 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 922ba3b..37dc92d 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

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

## H2\n

\n" + html: "\n\n

## H2\n

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

### H3\n

\n" + html: "\n\n

### H3\n

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

#### H4

\n" + html: "\n\n

#### H4

" offset_map: - byte_range: - 20 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap index e719850..88c96ef 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 16 - html: "

Some `code` here

\n" + html: "

Some `code` here

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap index 4b3bde2..2a5e45f 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__italic.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 18 - html: "

Some *italic* text

\n" + html: "

Some *italic* text

" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap index 3be09e2..827ef5b 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__mixed_unicode_ascii.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 16 - html: "

Hello 你好 world 🎉

\n" + html: "

Hello 你好 world 🎉

" offset_map: - byte_range: - 0 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 1c8e396..8e4c160 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

" offset_map: - byte_range: - 0 @@ -41,32 +41,13 @@ expression: result child_index: ~ utf16_len: 1 source_hash: 6799294212516041738 -- byte_range: - - 6 - - 9 - char_range: - - 6 - - 9 - html: "​" - offset_map: - - byte_range: - - 6 - - 9 - char_range: - - 6 - - 9 - node_id: gap-8-9 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 1 - source_hash: 4789919281594481934 - byte_range: - 9 - 15 char_range: - 9 - 15 - html: "\n\n\n\n

Second

\n" + html: "\n\n\n

Second

" offset_map: - byte_range: - 6 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap index 2061656..c1bfb65 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 32 - html: "

**Bold** and *italic* and `code`

\n" + html: "

**Bold** and *italic* and `code`

" 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 45eb510..5b3da23 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 @@ -2,32 +2,13 @@ source: crates/weaver-app/src/components/editor/tests.rs expression: result --- -- byte_range: - - 0 - - 11 - char_range: - - 0 - - 11 - html: "​" - offset_map: - - byte_range: - - 0 - - 11 - char_range: - - 0 - - 11 - node_id: gap-2-11 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 1 - source_hash: 10732713550789592057 - byte_range: - 11 - 33 char_range: - 11 - 33 - html: "
    \n
  • - Parent\n \n
      \n
    • - Child 1\n
    • \n \n
    • - Child 2\n
    • \n
    \n" + html: "
    • - Parent\n
      • - Child 1\n
      • - Child 2\n
      " offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap index b83e67d..be748aa 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__only_newlines.snap @@ -2,22 +2,4 @@ source: crates/weaver-app/src/components/editor/tests.rs expression: result --- -- byte_range: - - 0 - - 3 - char_range: - - 0 - - 3 - html: "​" - offset_map: - - byte_range: - - 0 - - 3 - char_range: - - 0 - - 3 - node_id: gap-0-3 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 1 - source_hash: 0 +[] 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 5c23598..8511299 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: "
      1. 1. First\n
      2. 2. Second\n
      3. 3. Third
      " offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap index 354b3c3..914afb6 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__single_paragraph.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 11 - html: "

      Hello world

      \n" + html: "

      Hello world

      " 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 e94240b..08b70c0 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

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

      Two.\n

      \n" + html: "\n

      Two.\n

      " offset_map: - byte_range: - 5 @@ -96,7 +96,7 @@ expression: result char_range: - 12 - 18 - html: "\n\n

      Three.

      \n" + html: "\n

      Three.

      " offset_map: - byte_range: - 11 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 015dc06..6b999f4 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

      " offset_map: - byte_range: - 0 @@ -41,22 +41,3 @@ expression: result child_index: ~ utf16_len: 1 source_hash: 1280328235052234789 -- byte_range: - - 6 - - 7 - char_range: - - 6 - - 7 - html: "​" - offset_map: - - byte_range: - - 6 - - 7 - char_range: - - 6 - - 7 - node_id: gap-6-7 - char_offset_in_node: 0 - child_index: ~ - utf16_len: 1 - source_hash: 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 7e2c852..6b999f4 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

      " 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 a63f165..8c38833 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

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

      Second paragraph.

      \n" + html: "\n

      Second paragraph.

      " offset_map: - byte_range: - 17 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap index 3be4818..95b96b4 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unicode_cjk.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 4 - html: "

      你好世界

      \n" + html: "

      你好世界

      " 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 3b51496..081735a 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -1,6 +1,6 @@ //! Snapshot tests for the markdown editor rendering pipeline. -use super::offset_map::{OffsetMapping, find_mapping_for_char}; +use weaver_editor_core::{OffsetMapping, find_mapping_for_char}; use super::paragraph::ParagraphRender; use super::render::render_paragraphs_incremental; use loro::LoroDoc; @@ -45,7 +45,7 @@ impl From<&OffsetMapping> for TestOffsetMapping { TestOffsetMapping { byte_range: (m.byte_range.start, m.byte_range.end), char_range: (m.char_range.start, m.char_range.end), - node_id: m.node_id.clone(), + node_id: m.node_id.to_string(), char_offset_in_node: m.char_offset_in_node, child_index: m.child_index, utf16_len: m.utf16_len, @@ -246,7 +246,7 @@ fn test_find_mapping_exact_start() { let mappings = vec![OffsetMapping { byte_range: 0..5, char_range: 0..5, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -264,7 +264,7 @@ fn test_find_mapping_exact_end_inclusive() { let mappings = vec![OffsetMapping { byte_range: 0..5, char_range: 0..5, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -280,7 +280,7 @@ fn test_find_mapping_middle() { let mappings = vec![OffsetMapping { byte_range: 0..10, char_range: 0..10, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 10, @@ -295,7 +295,7 @@ fn test_find_mapping_before_first() { let mappings = vec![OffsetMapping { byte_range: 5..10, char_range: 5..10, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -311,7 +311,7 @@ fn test_find_mapping_after_last() { let mappings = vec![OffsetMapping { byte_range: 0..5, char_range: 0..5, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -335,7 +335,7 @@ fn test_find_mapping_invisible_snaps() { let mappings = vec![OffsetMapping { byte_range: 0..2, char_range: 0..2, - node_id: "n0".to_string(), + node_id: "n0".into(), char_offset_in_node: 0, child_index: None, utf16_len: 0, // invisible @@ -726,7 +726,7 @@ fn test_blockquote_needs_space_or_newline() { char_range: - 0 - 6 - html: "
      \n

      >quote

      \n" + html: "

      >quote

      " offset_map: - byte_range: - 1 diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs index 1b13f40..b9c801b 100644 --- a/crates/weaver-app/src/components/editor/visibility.rs +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -3,6 +3,8 @@ //! Implements Obsidian-style formatting character visibility: syntax markers //! are hidden when cursor is not near them, revealed when cursor approaches. +use weaver_editor_core::SmolStr; + use super::document::Selection; use super::paragraph::ParagraphRender; use super::writer::{SyntaxSpanInfo, SyntaxType}; @@ -13,7 +15,7 @@ use std::ops::Range; #[derive(Debug, Clone, Default)] pub struct VisibilityState { /// Set of syn_ids that should be visible - pub visible_span_ids: HashSet, + pub visible_span_ids: HashSet, } impl VisibilityState { @@ -253,7 +255,7 @@ mod tests { syntax_type: SyntaxType, ) -> SyntaxSpanInfo { SyntaxSpanInfo { - syn_id: syn_id.to_string(), + syn_id: syn_id.into(), char_range: start..end, syntax_type, formatted_range: None, @@ -268,7 +270,7 @@ mod tests { formatted_range: Range, ) -> SyntaxSpanInfo { SyntaxSpanInfo { - syn_id: syn_id.to_string(), + syn_id: syn_id.into(), char_range: start..end, syntax_type, formatted_range: Some(formatted_range), diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 3d2f5de..fe8e74f 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -1,1325 +1,16 @@ -//! HTML writer for markdown editor with visible formatting characters. +//! HTML writer for markdown editor - re-exports from weaver-editor-core. //! -//! Based on ClientWriter from weaver-renderer, but modified to preserve -//! formatting characters (**, *, #, etc) wrapped in styled spans. -//! -//! Uses Parser::into_offset_iter() to track gaps between events, which -//! represent consumed formatting characters. -pub mod embed; -pub mod segmented; -pub mod syntax; -pub mod tags; - -use super::offset_map::OffsetMapping; -use crate::components::editor::writer::segmented::SegmentedWriter; -pub use embed::{EditorImageResolver, EmbedContentProvider, ImageResolver}; -use loro::LoroText; -use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes}; -use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count}; -use std::collections::HashMap; -use std::fmt; -use std::ops::Range; -pub use syntax::{SyntaxSpanInfo, SyntaxType}; -use weaver_common::EntryIndex; - -/// Result of rendering with the EditorWriter. -#[derive(Debug, Clone)] -pub struct WriterResult { - /// HTML segments, one per paragraph (parallel to paragraph_ranges) - pub html_segments: Vec, - - /// Offset mappings from source to DOM positions, grouped by paragraph - /// Each inner Vec corresponds to a paragraph in html_segments - pub offset_maps_by_paragraph: Vec>, - - /// Paragraph boundaries in source: (byte_range, char_range) - /// These are extracted during rendering by tracking Tag::Paragraph events - pub paragraph_ranges: Vec<(Range, Range)>, - - /// Syntax spans that can be conditionally hidden, grouped by paragraph - pub syntax_spans_by_paragraph: Vec>, - - /// Refs (wikilinks, AT embeds) collected during this render pass, grouped by paragraph - pub collected_refs_by_paragraph: Vec>, -} - -/// Tracks the type of wrapper element emitted for WeaverBlock prefix -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WrapperElement { - Aside, - Div, -} - -#[derive(Debug, Clone, Copy)] -pub enum TableState { - Head, - Body, -} - -/// HTML writer that preserves markdown formatting characters. -/// -/// This writer processes offset-iter events to detect gaps (consumed formatting) -/// and emits them as styled spans for visibility in the editor. -/// -/// Output is segmented by paragraph boundaries - each paragraph's HTML goes into -/// a separate String in the output segments Vec. -pub struct EditorWriter<'a, I: Iterator, Range)>, E = (), R = ()> { - source: &'a str, - source_text: &'a LoroText, - events: I, - writer: SegmentedWriter, - last_byte_offset: usize, - last_char_offset: usize, - - end_newline: bool, - in_non_writing_block: bool, - - table_state: TableState, - table_alignments: Vec, - table_cell_index: usize, - - numbers: HashMap, - - embed_provider: Option, - image_resolver: Option, - entry_index: Option<&'a EntryIndex>, - - code_buffer: Option<(Option, String)>, // (lang, content) - code_buffer_byte_range: Option>, // byte range of buffered code content - code_buffer_char_range: Option>, // char range of buffered code content - code_block_char_start: Option, // char offset where code block started - code_block_opening_span_idx: Option, // index of opening fence syntax span - pending_blockquote_range: Option>, // range for emitting > inside next paragraph - - // Table rendering mode - render_tables_as_markdown: bool, - table_start_offset: Option, // track start of table for markdown rendering - - // Offset mapping tracking - current paragraph - offset_maps: Vec, - node_id_prefix: Option, // paragraph ID prefix for stable node IDs - auto_increment_prefix: Option, // if set, auto-increment prefix per paragraph from this value - static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index - current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) - next_node_id: usize, - current_node_id: Option, // node ID for current text container - current_node_char_offset: usize, // UTF-16 offset within current node - current_node_child_count: usize, // number of child elements/text nodes in current container - - // Incremental UTF-16 offset tracking (replaces rope.chars_to_wchars) - // Maps char_offset -> utf16_offset at checkpoints we've traversed. - // Can be reused for future lookups or passed to subsequent writers. - utf16_checkpoints: Vec<(usize, usize)>, // (char_offset, utf16_offset) - - // Paragraph boundary tracking for incremental rendering - paragraph_ranges: Vec<(Range, Range)>, // (byte_range, char_range) - current_paragraph_start: Option<(usize, usize)>, // (byte_offset, char_offset) - list_depth: usize, // Track nesting depth to avoid paragraph boundary override inside lists - in_footnote_def: bool, // Suppress inner paragraph boundaries in footnote definitions - - // Syntax span tracking for conditional visibility - current paragraph - syntax_spans: Vec, - next_syn_id: usize, - /// Stack of pending inline formats: (syn_id of opening span, char start of region) - /// Used to set formatted_range when closing paired inline markers - pending_inline_formats: Vec<(String, usize)>, - - /// Collected refs (wikilinks, AT embeds, AT links) for current paragraph - ref_collector: weaver_common::RefCollector, - - // Per-paragraph accumulated results (completed paragraphs) - offset_maps_by_para: Vec>, - syntax_spans_by_para: Vec>, - refs_by_para: Vec>, - - // WeaverBlock prefix system - /// Pending WeaverBlock attrs to apply to the next block element - pending_block_attrs: Option>, - /// Type of wrapper element currently open (needs closing on block end) - active_wrapper: Option, - /// Buffer for WeaverBlock text content (to parse for attrs) - weaver_block_buffer: String, - /// Start char offset of current WeaverBlock (for syntax span) - weaver_block_char_start: Option, - - // Footnote syntax linking (ref ↔ definition visibility) - /// Maps footnote name → (syntax_span_index, char_start) for linking ref and def - footnote_ref_spans: HashMap, - /// Current footnote definition being processed (name, syntax_span_index, char_start) - current_footnote_def: Option<(String, usize, usize)>, - - _phantom: std::marker::PhantomData<&'a ()>, -} - -impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> - EditorWriter<'a, I, E, R> -{ - pub fn new(source: &'a str, source_text: &'a LoroText, events: I) -> Self { - Self::new_with_all_offsets(source, source_text, events, 0, 0, 0, 0) - } - - /// Get the next paragraph ID that would be assigned (for tracking allocations). - #[allow(dead_code)] - pub fn next_paragraph_id(&self) -> Option { - self.auto_increment_prefix - } - - /// Override the auto-incrementing prefix for a specific paragraph index. - /// Use this when you need a specific paragraph (e.g., cursor paragraph) to have - /// a stable prefix for DOM/offset_map compatibility. - pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self { - self.static_prefix_override = Some((index, prefix.to_string())); - // If this is for paragraph 0, apply it immediately - if index == 0 { - self.node_id_prefix = Some(prefix.to_string()); - self.next_node_id = 0; - } - self - } - - /// Finalize the current paragraph: move accumulated items to per-para vectors, - /// start a new output segment for the next paragraph. - fn finalize_paragraph(&mut self, byte_range: Range, char_range: Range) { - // Record paragraph boundary - self.paragraph_ranges.push((byte_range, char_range)); - - // Move current paragraph's data to per-para vectors - self.offset_maps_by_para - .push(std::mem::take(&mut self.offset_maps)); - self.syntax_spans_by_para - .push(std::mem::take(&mut self.syntax_spans)); - self.refs_by_para - .push(std::mem::take(&mut self.ref_collector.refs)); - - // Advance to next paragraph - self.current_paragraph_index += 1; - - // Determine prefix for next paragraph - if let Some((override_idx, ref override_prefix)) = self.static_prefix_override { - if self.current_paragraph_index == override_idx { - // Use the static override for this paragraph - self.node_id_prefix = Some(override_prefix.clone()); - self.next_node_id = 0; - } else if let Some(ref mut current_id) = self.auto_increment_prefix { - // Use auto-increment (skip the override index to avoid collision) - *current_id += 1; - self.node_id_prefix = Some(format!("p-{}", *current_id)); - self.next_node_id = 0; - } - } else if let Some(ref mut current_id) = self.auto_increment_prefix { - // Normal auto-increment - *current_id += 1; - self.node_id_prefix = Some(format!("p-{}", *current_id)); - self.next_node_id = 0; - } - - // Start new output segment for next paragraph - self.writer.new_segment(); - } - - #[inline] - fn write_newline(&mut self) -> fmt::Result { - self.end_newline = true; - self.writer.write_str("\n") - } - - #[inline] - fn write(&mut self, s: &str) -> fmt::Result { - if !s.is_empty() { - self.end_newline = s.ends_with('\n'); - } - self.writer.write_str(s) - } - - /// Generate a unique syntax span ID - fn gen_syn_id(&mut self) -> String { - let id = format!("s{}", self.next_syn_id); - self.next_syn_id += 1; - id - } - - /// Finalize a paired inline format (Strong, Emphasis, Strikethrough). - /// Pops the pending format info and sets formatted_range on both opening and closing spans. - fn finalize_paired_inline_format(&mut self) { - if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() { - let format_end = self.last_char_offset; - let formatted_range = format_start..format_end; - - // Update the opening span's formatted_range - if let Some(opening_span) = self - .syntax_spans - .iter_mut() - .find(|s| s.syn_id == opening_syn_id) - { - opening_span.formatted_range = Some(formatted_range.clone()); - } else { - tracing::warn!( - "[FINALIZE_PAIRED] Could not find opening span {}", - opening_syn_id - ); - } - - // Update the closing span's formatted_range (the most recent one) - // The closing syntax was just emitted by emit_gap_before, so it's the last span - if let Some(closing_span) = self.syntax_spans.last_mut() { - // Only update if it's an inline span (closing syntax should be inline) - if closing_span.syntax_type == SyntaxType::Inline { - closing_span.formatted_range = Some(formatted_range); - } - } - } - } - - /// Generate a unique node ID. - /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. - /// Otherwise produces `n{counter}` for backwards compatibility. - fn gen_node_id(&mut self) -> String { - let id = if let Some(ref prefix) = self.node_id_prefix { - format!("{}-n{}", prefix, self.next_node_id) - } else { - format!("n{}", self.next_node_id) - }; - self.next_node_id += 1; - id - } - - /// Start tracking a new text container node - fn begin_node(&mut self, node_id: String) { - self.current_node_id = Some(node_id); - self.current_node_char_offset = 0; - self.current_node_child_count = 0; - } - - /// Stop tracking current node - fn end_node(&mut self) { - self.current_node_id = None; - self.current_node_char_offset = 0; - self.current_node_child_count = 0; - } - - /// Compute UTF-16 length for a text slice with fast path for ASCII. - #[inline] - fn utf16_len_for_slice(text: &str) -> usize { - let byte_len = text.len(); - let char_len = text.chars().count(); - - // Fast path: if byte_len == char_len, all ASCII, so utf16_len == char_len - if byte_len == char_len { - char_len - } else { - // Slow path: has multi-byte chars, need to count UTF-16 code units - text.encode_utf16().count() - } - } - - /// Record an offset mapping for the given byte and char ranges. - /// - /// Builds up utf16_checkpoints incrementally for efficient lookups. - fn record_mapping(&mut self, byte_range: Range, char_range: Range) { - if let Some(ref node_id) = self.current_node_id { - // Get UTF-16 length using fast path - let text_slice = &self.source[byte_range.clone()]; - let utf16_len = Self::utf16_len_for_slice(text_slice); - - // Record checkpoint at end of this range for future lookups - let last_checkpoint = self.utf16_checkpoints.last().copied().unwrap_or((0, 0)); - let new_utf16_offset = last_checkpoint.1 + utf16_len; - - // Only add checkpoint if we've advanced - if char_range.end > last_checkpoint.0 { - self.utf16_checkpoints - .push((char_range.end, new_utf16_offset)); - } - - let mapping = OffsetMapping { - byte_range: byte_range.clone(), - char_range: char_range.clone(), - node_id: node_id.clone(), - char_offset_in_node: self.current_node_char_offset, - child_index: None, // text-based position - utf16_len, - }; - self.offset_maps.push(mapping); - self.current_node_char_offset += utf16_len; - } else { - tracing::debug!("[RECORD_MAPPING] SKIPPED - current_node_id is None!"); - } - } - - /// Process markdown events and write HTML. - /// - /// Returns offset mappings and paragraph boundaries. The HTML is written - /// to the writer passed in the constructor. - pub fn run(mut self) -> Result { - while let Some((event, range)) = self.events.next() { - tracing::trace!( - target: "weaver::writer", - event = ?event, - byte_range = ?range, - "processing event" - ); - - // For End events, emit any trailing content within the event's range - // BEFORE calling end_tag (which calls end_node and clears current_node_id) - // - // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), - // the closing syntax must be emitted AFTER the closing HTML tag, not before. - // Otherwise the closing `**` span ends up INSIDE the element. - // These tags handle their own closing syntax in end_tag(). - // Image and Embed handle ALL their syntax in the Start event, so exclude them too. - use markdown_weaver::TagEnd; - let is_self_handled_end = matches!( - &event, - Event::End( - TagEnd::Strong - | TagEnd::Emphasis - | TagEnd::Strikethrough - | TagEnd::Image - | TagEnd::Embed - ) - ); - - if matches!(&event, Event::End(_)) && !is_self_handled_end { - // Emit gap from last_byte_offset to range.end - self.emit_gap_before(range.end)?; - } else if !matches!(&event, Event::End(_)) { - // For other events, emit any gap before range.start - // (emit_syntax handles char offset tracking) - self.emit_gap_before(range.start)?; - } - // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML - - // Store last_byte before processing - let last_byte_before = self.last_byte_offset; - - // Process the event (passing range for tag syntax) - self.process_event(event, range.clone())?; - - // Update tracking - but don't override if start_tag manually updated it - // (for inline formatting tags that emit opening syntax) - if self.last_byte_offset == last_byte_before { - // Event didn't update offset, so we update it - self.last_byte_offset = range.end; - } - // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value - } - - // Emit any trailing syntax - self.emit_gap_before(self.source.len())?; - - // Handle unmapped trailing content (stripped by parser) - // This includes trailing spaces that markdown ignores - let doc_byte_len = self.source.len(); - let doc_char_len = self.source_text.len_unicode(); - - if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { - // Emit the trailing content as visible syntax - if self.last_byte_offset < doc_byte_len { - let trailing = &self.source[self.last_byte_offset..]; - if !trailing.is_empty() { - let char_start = self.last_char_offset; - let trailing_char_len = trailing.chars().count(); - - let char_end = char_start + trailing_char_len; - let syn_id = self.gen_syn_id(); - - 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, - // }); - - // Record mapping if we have a node - if let Some(ref node_id) = self.current_node_id { - let mapping = OffsetMapping { - byte_range: self.last_byte_offset..doc_byte_len, - char_range: char_start..char_end, - node_id: node_id.clone(), - char_offset_in_node: self.current_node_char_offset, - child_index: None, - utf16_len: trailing_char_len, // visible - }; - self.offset_maps.push(mapping); - self.current_node_char_offset += trailing_char_len; - } - - self.last_char_offset = char_start + trailing_char_len; - } - } - } - - // Add any remaining accumulated data for the last paragraph - // (content that wasn't followed by a paragraph boundary) - if !self.offset_maps.is_empty() - || !self.syntax_spans.is_empty() - || !self.ref_collector.refs.is_empty() - { - self.offset_maps_by_para.push(self.offset_maps); - self.syntax_spans_by_para.push(self.syntax_spans); - self.refs_by_para.push(self.ref_collector.refs); - } - - // Get HTML segments from writer - let html_segments = self.writer.into_segments(); - - Ok(WriterResult { - html_segments, - offset_maps_by_paragraph: self.offset_maps_by_para, - paragraph_ranges: self.paragraph_ranges, - syntax_spans_by_paragraph: self.syntax_spans_by_para, - collected_refs_by_paragraph: self.refs_by_para, - }) - } - - // Consume raw text events until end tag, for alt attributes - #[allow(dead_code)] - fn raw_text(&mut self) -> Result<(), fmt::Error> { - use Event::*; - let mut nest = 0; - while let Some((event, _range)) = self.events.next() { - match event { - Start(_) => nest += 1, - End(_) => { - if nest == 0 { - break; - } - nest -= 1; - } - Html(_) => {} - InlineHtml(text) | Code(text) | Text(text) => { - // Don't use escape_html_body_text here. - // The output of this function is used in the `alt` attribute. - escape_html(&mut self.writer, &text)?; - self.end_newline = text.ends_with('\n'); - } - InlineMath(text) => { - self.write("$")?; - escape_html(&mut self.writer, &text)?; - self.write("$")?; - } - DisplayMath(text) => { - self.write("$$")?; - escape_html(&mut self.writer, &text)?; - self.write("$$")?; - } - SoftBreak | HardBreak | Rule => { - self.write(" ")?; - } - FootnoteReference(name) => { - let len = self.numbers.len() + 1; - let number = *self.numbers.entry(name.into_string()).or_insert(len); - write!(&mut self.writer, "[{}]", number)?; - } - TaskListMarker(true) => self.write("[x]")?, - TaskListMarker(false) => self.write("[ ]")?, - WeaverBlock(_) => {} - } - } - Ok(()) - } - - /// Consume events until End tag without writing anything. - /// Used when we've already extracted content from source and just need to advance the iterator. - fn consume_until_end(&mut self) { - use Event::*; - let mut nest = 0; - while let Some((event, _range)) = self.events.next() { - match event { - Start(_) => nest += 1, - End(_) => { - if nest == 0 { - break; - } - nest -= 1; - } - _ => {} - } - } - } - - /// Parse WeaverBlock text content into attributes. - /// Format: comma-separated, colon for key:value, otherwise class. - /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")] - fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { - let mut classes = Vec::new(); - let mut attrs = Vec::new(); - - for part in text.split(',') { - let part = part.trim(); - if part.is_empty() { - continue; - } +//! The core EditorWriter lives in weaver-editor-core. This module provides: +//! - Re-exports of core types for convenience +//! - App-specific EditorImageResolver for image URL resolution - if let Some((key, value)) = part.split_once(':') { - let key = key.trim(); - let value = value.trim(); - if !key.is_empty() && !value.is_empty() { - attrs.push(( - CowStr::from(key.to_string()), - CowStr::from(value.to_string()), - )); - } - } else { - // No colon - treat as class, strip leading dot if present - let class = part.strip_prefix('.').unwrap_or(part); - if !class.is_empty() { - classes.push(CowStr::from(class.to_string())); - } - } - } - - WeaverAttributes { classes, attrs } - } - - /// Emit wrapper element start based on pending block attrs. - /// Returns true if a wrapper was emitted. - fn emit_wrapper_start(&mut self) -> Result { - if let Some(attrs) = self.pending_block_attrs.take() { - let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); - - if is_aside { - self.write(" element) - let classes: Vec<_> = if is_aside { - attrs - .classes - .iter() - .filter(|c| c.as_ref() != "aside") - .collect() - } else { - attrs.classes.iter().collect() - }; - - if !classes.is_empty() { - self.write(" class=\"")?; - for (i, class) in classes.iter().enumerate() { - if i > 0 { - self.write(" ")?; - } - escape_html(&mut self.writer, class)?; - } - self.write("\"")?; - } - - // Write other attrs - for (attr, value) in &attrs.attrs { - self.write(" ")?; - escape_html(&mut self.writer, attr)?; - self.write("=\"")?; - escape_html(&mut self.writer, value)?; - self.write("\"")?; - } - - self.write(">")?; - Ok(true) - } else { - Ok(false) - } - } - - /// Close active wrapper element if one is open - fn close_wrapper(&mut self) -> Result<(), fmt::Error> { - if let Some(wrapper) = self.active_wrapper.take() { - match wrapper { - WrapperElement::Aside => self.write("")?, - WrapperElement::Div => self.write("")?, - } - } - Ok(()) - } - - fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), fmt::Error> { - use Event::*; - - match event { - Start(tag) => self.start_tag(tag, range)?, - End(tag) => self.end_tag(tag, range)?, - Text(text) => { - // If buffering code, append to buffer instead of writing - if let Some((_, ref mut buffer)) = self.code_buffer { - buffer.push_str(&text); - - // Track byte and char ranges for code block content - let text_char_len = text.chars().count(); - let text_byte_len = text.len(); - if let Some(ref mut code_byte_range) = self.code_buffer_byte_range { - // Extend existing ranges - code_byte_range.end = range.end; - if let Some(ref mut code_char_range) = self.code_buffer_char_range { - code_char_range.end = self.last_char_offset + text_char_len; - } - } else { - // First text in code block - start tracking - self.code_buffer_byte_range = Some(range.clone()); - self.code_buffer_char_range = - Some(self.last_char_offset..self.last_char_offset + text_char_len); - } - // Update offsets so paragraph boundary is correct - self.last_char_offset += text_char_len; - self.last_byte_offset += text_byte_len; - } else if !self.in_non_writing_block { - // Escape HTML and count chars in one pass - let char_start = self.last_char_offset; - let text_char_len = - escape_html_body_text_with_char_count(&mut self.writer, &text)?; - let char_end = char_start + text_char_len; - - // Text becomes a text node child of the current container - if text_char_len > 0 { - self.current_node_child_count += 1; - } - - // Record offset mapping - self.record_mapping(range.clone(), char_start..char_end); - - // Update char offset tracking - self.last_char_offset = char_end; - self.end_newline = text.ends_with('\n'); - } - } - Code(text) => { - let format_start = self.last_char_offset; - let raw_text = &self.source[range.clone()]; - - // Track opening span index so we can set formatted_range later - let opening_span_idx = if raw_text.starts_with('`') { - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let backtick_char_end = char_start + 1; - write!( - &mut self.writer, - "`", - syn_id, char_start, backtick_char_end - )?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..backtick_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, // Set after we know the full range - }); - self.last_char_offset += 1; - Some(self.syntax_spans.len() - 1) - } else { - None - }; - - self.write("")?; - - // Track offset mapping for code content - let content_char_start = self.last_char_offset; - let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; - let content_char_end = content_char_start + text_char_len; - - // Record offset mapping (code content is visible) - self.record_mapping(range.clone(), content_char_start..content_char_end); - self.last_char_offset = content_char_end; - - self.write("")?; - - // Emit closing backtick and track it - if raw_text.ends_with('`') { - let syn_id = self.gen_syn_id(); - let backtick_char_start = self.last_char_offset; - let backtick_char_end = backtick_char_start + 1; - write!( - &mut self.writer, - "`", - syn_id, backtick_char_start, backtick_char_end - )?; - - // Now we know the full formatted range - let formatted_range = format_start..backtick_char_end; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: backtick_char_start..backtick_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - - // Update opening span with formatted_range - if let Some(idx) = opening_span_idx { - self.syntax_spans[idx].formatted_range = Some(formatted_range); - } - - self.last_char_offset += 1; - } - } - InlineMath(text) => { - // Math rendering follows embed pattern: syntax spans hide when cursor outside, - // rendered content always visible - let raw_text = &self.source[range.clone()]; - let syn_id = self.gen_syn_id(); - let opening_char_start = self.last_char_offset; - - // Calculate char positions - let text_char_len = text.chars().count(); - let opening_char_end = opening_char_start + 1; // "$" - let content_char_start = opening_char_end; - let content_char_end = content_char_start + text_char_len; - let closing_char_start = content_char_end; - let closing_char_end = closing_char_start + 1; // "$" - let formatted_range = opening_char_start..closing_char_end; - - // 1. Emit opening $ syntax span - if raw_text.starts_with('$') { - write!( - &mut self.writer, - "$", - syn_id, opening_char_start, opening_char_end - )?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: opening_char_start..opening_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.start..range.start + 1, - opening_char_start..opening_char_end, - ); - } - - // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) - write!( - &mut self.writer, - "", - syn_id, content_char_start, content_char_end - )?; - escape_html(&mut self.writer, &text)?; - self.write("")?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: content_char_start..content_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.start + 1..range.end - 1, - content_char_start..content_char_end, - ); - - // 3. Emit closing $ syntax span - if raw_text.ends_with('$') { - write!( - &mut self.writer, - "$", - syn_id, closing_char_start, closing_char_end - )?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: closing_char_start..closing_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.end - 1..range.end, - closing_char_start..closing_char_end, - ); - } - - // 4. Emit rendered MathML (always visible, not tied to syn_id) - // Include data-char-target so clicking moves cursor into the math region - // contenteditable="false" so DOM walker skips this for offset counting - match weaver_renderer::math::render_math(&text, false) { - weaver_renderer::math::MathResult::Success(mathml) => { - write!( - &mut self.writer, - "{}", - content_char_start, mathml - )?; - } - weaver_renderer::math::MathResult::Error { html, .. } => { - // Show error indicator (also always visible) - self.write(&html)?; - } - } - - self.last_char_offset = closing_char_end; - } - DisplayMath(text) => { - // Math rendering follows embed pattern: syntax spans hide when cursor outside, - // rendered content always visible - let raw_text = &self.source[range.clone()]; - let syn_id = self.gen_syn_id(); - let opening_char_start = self.last_char_offset; - - // Calculate char positions - let text_char_len = text.chars().count(); - let opening_char_end = opening_char_start + 2; // "$$" - let content_char_start = opening_char_end; - let content_char_end = content_char_start + text_char_len; - let closing_char_start = content_char_end; - let closing_char_end = closing_char_start + 2; // "$$" - let formatted_range = opening_char_start..closing_char_end; - - // 1. Emit opening $$ syntax span - // Use Block syntax type so visibility is based on "cursor in same paragraph" - if raw_text.starts_with("$$") { - write!( - &mut self.writer, - "$$", - syn_id, opening_char_start, opening_char_end - )?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: opening_char_start..opening_char_end, - syntax_type: SyntaxType::Block, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.start..range.start + 2, - opening_char_start..opening_char_end, - ); - } - - // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) - write!( - &mut self.writer, - "", - syn_id, content_char_start, content_char_end - )?; - escape_html(&mut self.writer, &text)?; - self.write("")?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: content_char_start..content_char_end, - syntax_type: SyntaxType::Block, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.start + 2..range.end - 2, - content_char_start..content_char_end, - ); - - // 3. Emit closing $$ syntax span - if raw_text.ends_with("$$") { - write!( - &mut self.writer, - "$$", - syn_id, closing_char_start, closing_char_end - )?; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: closing_char_start..closing_char_end, - syntax_type: SyntaxType::Block, - formatted_range: Some(formatted_range.clone()), - }); - self.record_mapping( - range.end - 2..range.end, - closing_char_start..closing_char_end, - ); - } - - // 4. Emit rendered MathML (always visible, not tied to syn_id) - // Include data-char-target so clicking moves cursor into the math region - // contenteditable="false" so DOM walker skips this for offset counting - match weaver_renderer::math::render_math(&text, true) { - weaver_renderer::math::MathResult::Success(mathml) => { - write!( - &mut self.writer, - "{}", - content_char_start, mathml - )?; - } - weaver_renderer::math::MathResult::Error { html, .. } => { - // Show error indicator (also always visible) - self.write(&html)?; - } - } - - self.last_char_offset = closing_char_end; - } - Html(html) => { - // Track offset mapping for raw HTML - let char_start = self.last_char_offset; - let html_char_len = html.chars().count(); - let char_end = char_start + html_char_len; - - self.write(&html)?; - - // Record mapping for inline HTML - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; - } - InlineHtml(html) => { - // Track offset mapping for raw HTML - let char_start = self.last_char_offset; - let html_char_len = html.chars().count(); - let char_end = char_start + html_char_len; - self.write(r#""#)?; - self.write(&html)?; - self.write("")?; - // Record mapping for inline HTML - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; - } - SoftBreak => { - // Emit
      for visual line break, plus a space for cursor positioning. - // This space maps to the \n so the cursor can land here when navigating. - let char_start = self.last_char_offset; - - // Emit
      - self.write("
      ")?; - self.current_node_child_count += 1; - - // Emit space for cursor positioning - this gives the browser somewhere - // to place the cursor when navigating to this line - self.write("\u{200B}")?; - self.current_node_child_count += 1; - - // Map the space to the newline position - cursor landing here means - // we're at the end of the line (after the \n) - if let Some(ref node_id) = self.current_node_id { - let mapping = OffsetMapping { - byte_range: range.clone(), - char_range: char_start..char_start + 1, - node_id: node_id.clone(), - char_offset_in_node: self.current_node_char_offset, - child_index: None, - utf16_len: 1, // the space we emitted - }; - self.offset_maps.push(mapping); - self.current_node_char_offset += 1; - } - - self.last_char_offset = char_start + 1; // +1 for the \n - } - HardBreak => { - // Emit the two spaces as visible (dimmed) text, then
      - let gap = &self.source[range.clone()]; - if gap.ends_with('\n') { - let spaces = &gap[..gap.len() - 1]; // everything except the \n - let char_start = self.last_char_offset; - let spaces_char_len = spaces.chars().count(); - let char_end = char_start + spaces_char_len; - - // Emit and map the visible spaces - 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("")?; - - // Count this span as a child - self.current_node_child_count += 1; - - self.record_mapping( - range.start..range.start + spaces.len(), - char_start..char_end, - ); - - // Now the actual line break
      - self.write("
      ")?; - - // Count the
      as a child - self.current_node_child_count += 1; - - // After
      , emit plain zero-width space for cursor positioning - self.write("\u{200B}")?; - - // Count the zero-width space text node as a child - self.current_node_child_count += 1; - - // Map the newline position to the zero-width space text node - if let Some(ref node_id) = self.current_node_id { - let newline_char_offset = char_start + spaces_char_len; - let mapping = OffsetMapping { - byte_range: range.start + spaces.len()..range.end, - char_range: newline_char_offset..newline_char_offset + 1, - node_id: node_id.clone(), - char_offset_in_node: self.current_node_char_offset, - child_index: None, // text node - TreeWalker will find it - utf16_len: 1, // zero-width space is 1 UTF-16 unit - }; - self.offset_maps.push(mapping); - - // Increment char offset - TreeWalker will encounter this text node - self.current_node_char_offset += 1; - } - - self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n - } else { - // Fallback: just
      - self.write("
      ")?; - } - } - Rule => { - if !self.end_newline { - self.write("\n")?; - } - - // Emit syntax span before the rendered element - if range.start < range.end { - let raw_text = &self.source[range]; - let trimmed = raw_text.trim(); - if !trimmed.is_empty() { - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let char_len = trimmed.chars().count(); - let char_end = char_start + char_len; - - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, trimmed)?; - self.write("")?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Block, - formatted_range: None, - }); - } - } - - // Wrap
      in toggle-block for future cursor-based toggling - self.write("

      ")?; - } - FootnoteReference(name) => { - // Emit [^name] as styled (but NOT hidden) inline span - let raw_text = &self.source[range.clone()]; - let char_start = self.last_char_offset; - let syntax_char_len = raw_text.chars().count(); - let char_end = char_start + syntax_char_len; - - // Use footnote-ref class for styling, not md-syntax-inline (which hides) - write!( - &mut self.writer, - "", - char_start, char_end, name - )?; - escape_html(&mut self.writer, raw_text)?; - self.write("")?; - - // Record offset mapping - self.record_mapping(range.clone(), char_start..char_end); - - // Count as child - self.current_node_child_count += 1; - - // Update tracking - self.last_char_offset = char_end; - self.last_byte_offset = range.end; - } - TaskListMarker(checked) => { - // Emit the [ ] or [x] syntax - if range.start < range.end { - let raw_text = &self.source[range]; - if let Some(bracket_pos) = raw_text.find('[') { - let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); - let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; - - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let syntax_char_len = syntax.chars().count(); - let char_end = char_start + syntax_char_len; - - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, syntax)?; - self.write(" ")?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, - }); - } - } - - if checked { - self.write("")?; - } else { - self.write("")?; - } - } - WeaverBlock(text) => { - // Buffer WeaverBlock content for parsing on End - self.weaver_block_buffer.push_str(&text); - } - } - Ok(()) - } - - pub fn new_with_all_offsets( - source: &'a str, - source_text: &'a LoroText, - events: I, - node_id_offset: usize, - syn_id_offset: usize, - char_offset_base: usize, - byte_offset_base: usize, - ) -> Self { - Self { - source, - source_text, - events, - writer: SegmentedWriter::new(), - last_byte_offset: byte_offset_base, - last_char_offset: char_offset_base, - end_newline: true, - in_non_writing_block: false, - table_state: TableState::Head, - table_alignments: vec![], - table_cell_index: 0, - numbers: HashMap::new(), - embed_provider: None, - image_resolver: None, - entry_index: None, - code_buffer: None, - code_buffer_byte_range: None, - code_buffer_char_range: None, - code_block_char_start: None, - code_block_opening_span_idx: None, - pending_blockquote_range: None, - render_tables_as_markdown: true, - table_start_offset: None, - offset_maps: Vec::new(), - node_id_prefix: None, - auto_increment_prefix: None, - static_prefix_override: None, - current_paragraph_index: 0, - next_node_id: node_id_offset, - current_node_id: None, - current_node_char_offset: 0, - current_node_child_count: 0, - utf16_checkpoints: vec![(0, 0)], - paragraph_ranges: Vec::new(), - current_paragraph_start: None, - list_depth: 0, - in_footnote_def: false, - syntax_spans: Vec::new(), - next_syn_id: syn_id_offset, - pending_inline_formats: Vec::new(), - ref_collector: weaver_common::RefCollector::new(), - offset_maps_by_para: Vec::new(), - syntax_spans_by_para: Vec::new(), - refs_by_para: Vec::new(), - pending_block_attrs: None, - active_wrapper: None, - weaver_block_buffer: String::new(), - weaver_block_char_start: None, - footnote_ref_spans: HashMap::new(), - current_footnote_def: None, - _phantom: std::marker::PhantomData, - } - } - - /// Add an embed content provider - pub fn with_embed_provider(mut self, provider: E) -> EditorWriter<'a, I, E, R> { - self.embed_provider = Some(provider); - self - } - - /// Add an image resolver for mapping markdown image URLs to CDN URLs - pub fn with_image_resolver( - self, - resolver: R2, - ) -> EditorWriter<'a, I, E, R2> { - EditorWriter { - source: self.source, - source_text: self.source_text, - events: self.events, - writer: self.writer, - last_byte_offset: self.last_byte_offset, - last_char_offset: self.last_char_offset, - end_newline: self.end_newline, - in_non_writing_block: self.in_non_writing_block, - table_state: self.table_state, - table_alignments: self.table_alignments, - table_cell_index: self.table_cell_index, - numbers: self.numbers, - embed_provider: self.embed_provider, - image_resolver: Some(resolver), - entry_index: self.entry_index, - code_buffer: self.code_buffer, - code_buffer_byte_range: self.code_buffer_byte_range, - code_buffer_char_range: self.code_buffer_char_range, - code_block_char_start: self.code_block_char_start, - code_block_opening_span_idx: self.code_block_opening_span_idx, - pending_blockquote_range: self.pending_blockquote_range, - render_tables_as_markdown: self.render_tables_as_markdown, - table_start_offset: self.table_start_offset, - offset_maps: self.offset_maps, - node_id_prefix: self.node_id_prefix, - auto_increment_prefix: self.auto_increment_prefix, - static_prefix_override: self.static_prefix_override, - current_paragraph_index: self.current_paragraph_index, - next_node_id: self.next_node_id, - current_node_id: self.current_node_id, - current_node_char_offset: self.current_node_char_offset, - current_node_child_count: self.current_node_child_count, - utf16_checkpoints: self.utf16_checkpoints, - paragraph_ranges: self.paragraph_ranges, - current_paragraph_start: self.current_paragraph_start, - list_depth: self.list_depth, - in_footnote_def: self.in_footnote_def, - syntax_spans: self.syntax_spans, - next_syn_id: self.next_syn_id, - pending_inline_formats: self.pending_inline_formats, - ref_collector: self.ref_collector, - offset_maps_by_para: self.offset_maps_by_para, - syntax_spans_by_para: self.syntax_spans_by_para, - refs_by_para: self.refs_by_para, - pending_block_attrs: self.pending_block_attrs, - active_wrapper: self.active_wrapper, - weaver_block_buffer: self.weaver_block_buffer, - weaver_block_char_start: self.weaver_block_char_start, - footnote_ref_spans: self.footnote_ref_spans, - current_footnote_def: self.current_footnote_def, - _phantom: std::marker::PhantomData, - } - } - - /// Add an entry index for wikilink resolution feedback - pub fn with_entry_index(mut self, index: &'a EntryIndex) -> Self { - self.entry_index = Some(index); - self - } +pub mod embed; - /// Set a prefix for node IDs (typically the paragraph ID). - /// This makes node IDs paragraph-scoped and stable across re-renders. - /// Use this for single-paragraph renders where the paragraph ID is known. - pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { - self.node_id_prefix = Some(prefix.to_string()); - self.next_node_id = 0; // Reset counter since each paragraph is independent - self - } +// Re-export everything from core +pub use weaver_editor_core::{ + EditorRope, EditorWriter, EmbedContentProvider, ImageResolver, OffsetMapping, SegmentedWriter, + SyntaxSpanInfo, SyntaxType, TextBuffer, WriterResult, +}; - /// Enable auto-incrementing paragraph prefixes for multi-paragraph renders. - /// Each paragraph gets prefix "p-{N}" where N starts at `start_id` and increments. - /// Node IDs reset to 0 for each paragraph, giving "p-{N}-n0", "p-{N}-n1", etc. - pub fn with_auto_incrementing_prefix(mut self, start_id: usize) -> Self { - self.auto_increment_prefix = Some(start_id); - self.node_id_prefix = Some(format!("p-{}", start_id)); - self.next_node_id = 0; - self - } -} +// App-specific image resolver +pub use embed::EditorImageResolver; diff --git a/crates/weaver-app/src/components/editor/writer/embed.rs b/crates/weaver-app/src/components/editor/writer/embed.rs index cbbd5f2..79cfa8c 100644 --- a/crates/weaver-app/src/components/editor/writer/embed.rs +++ b/crates/weaver-app/src/components/editor/writer/embed.rs @@ -1,62 +1,13 @@ -use core::fmt; -use std::ops::Range; +//! App-specific image resolver for the editor. +//! +//! Provides EditorImageResolver which maps image names to URLs based on +//! image state (pending upload, draft, published). use jacquard::types::{ident::AtIdentifier, string::Rkey}; -use markdown_weaver::{CowStr, EmbedType, Event, Tag}; -use markdown_weaver_escape::{StrWrite, escape_html}; -use weaver_common::ResolvedContent; +use weaver_editor_core::ImageResolver; -use crate::components::editor::{ - SyntaxSpanInfo, SyntaxType, document::EditorImage, writer::EditorWriter, -}; +use crate::components::editor::document::EditorImage; -/// Synchronous callback for injecting embed content -/// -/// Takes the embed tag and returns optional HTML content to inject. -pub trait EmbedContentProvider { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option; -} - -impl EmbedContentProvider for () { - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { - None - } -} - -impl EmbedContentProvider for &ResolvedContent { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option { - if let Tag::Embed { dest_url, .. } = tag { - let url = dest_url.as_ref(); - if url.starts_with("at://") { - if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { - return ResolvedContent::get_embed_content(self, &at_uri) - .map(|s| s.to_string()); - } - } - } - None - } -} - -/// Resolves image URLs to CDN URLs based on stored images. -/// -/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). -/// This trait maps those names to the actual CDN URL using the blob CID and owner DID. -pub trait ImageResolver { - /// Resolve an image URL from markdown to a CDN URL. - /// - /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. - fn resolve_image_url(&self, url: &str) -> Option; -} - -impl ImageResolver for () { - fn resolve_image_url(&self, _url: &str) -> Option { - None - } -} - -/// Concrete image resolver that maps image names to URLs. -/// /// Resolved image path type #[derive(Clone, Debug)] enum ResolvedImage { @@ -201,171 +152,3 @@ impl ImageResolver for EditorImageResolver { } } -impl ImageResolver for &EditorImageResolver { - fn resolve_image_url(&self, url: &str) -> Option { - (*self).resolve_image_url(url) - } -} - -impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> - EditorWriter<'a, I, E, R> -{ - pub(crate) fn write_embed( - &mut self, - range: Range, - embed_type: EmbedType, - dest_url: CowStr<'_>, - title: CowStr<'_>, - id: CowStr<'_>, - attrs: Option>, - ) -> Result<(), fmt::Error> { - // Embed rendering: all syntax elements share one syn_id for visibility toggling - // Structure: ![[ url-as-link ]] - let raw_text = &self.source[range.clone()]; - let syn_id = self.gen_syn_id(); - let opening_char_start = self.last_char_offset; - - // Extract the URL from raw text (between ![[ and ]]) - let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { - &raw_text[3..raw_text.len() - 2] - } else { - dest_url.as_ref() - }; - - // Calculate char positions - let url_char_len = url_text.chars().count(); - let opening_char_end = opening_char_start + 3; // "![[" - let url_char_start = opening_char_end; - let url_char_end = url_char_start + url_char_len; - let closing_char_start = url_char_end; - let closing_char_end = closing_char_start + 2; // "]]" - let formatted_range = opening_char_start..closing_char_end; - - // 1. Emit opening ![[ syntax span - if raw_text.starts_with("![[") { - write!( - &mut self.writer, - "![[", - syn_id, opening_char_start, opening_char_end - )?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: opening_char_start..opening_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - - self.record_mapping( - range.start..range.start + 3, - opening_char_start..opening_char_end, - ); - } - - // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) - let url = dest_url.as_ref(); - let link_href = if url.starts_with("at://") { - format!("https://alpha.weaver.sh/record/{}", url) - } else { - url.to_string() - }; - - write!( - &mut self.writer, - "", - link_href, syn_id, url_char_start, url_char_end - )?; - escape_html(&mut self.writer, url_text)?; - self.write("")?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: url_char_start..url_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - - self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); - - // 3. Emit closing ]] syntax span - if raw_text.ends_with("]]") { - write!( - &mut self.writer, - "]]", - syn_id, closing_char_start, closing_char_end - )?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: closing_char_start..closing_char_end, - syntax_type: SyntaxType::Inline, - formatted_range: Some(formatted_range.clone()), - }); - - self.record_mapping( - range.end - 2..range.end, - closing_char_start..closing_char_end, - ); - } - - // Collect AT URI for later resolution - if url.starts_with("at://") || url.starts_with("did:") { - self.ref_collector.add_at_embed( - url, - if title.is_empty() { - None - } else { - Some(title.as_ref()) - }, - ); - } - - // 4. Emit the actual embed content - // Try to get content from attributes first - let content_from_attrs = if let Some(ref attrs) = attrs { - attrs - .attrs - .iter() - .find(|(k, _)| k.as_ref() == "content") - .map(|(_, v)| v.as_ref().to_string()) - } else { - None - }; - - // If no content in attrs, try provider - let content = if let Some(content) = content_from_attrs { - Some(content) - } else if let Some(ref provider) = self.embed_provider { - let tag = Tag::Embed { - embed_type, - dest_url: dest_url.clone(), - title: title.clone(), - id: id.clone(), - attrs: attrs.clone(), - }; - provider.get_embed_content(&tag) - } else { - None - }; - - if let Some(html_content) = content { - // Write the pre-rendered content directly - self.write(&html_content)?; - } else { - // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) - self.write("
      ")?; - self.write("Loading embed...")?; - self.write("
      ")?; - } - - // Consume the text events for the URL (they're still in the iterator) - // Use consume_until_end() since we already wrote the URL from source - self.consume_until_end(); - - // Update offsets - self.last_char_offset = closing_char_end; - self.last_byte_offset = range.end; - - Ok(()) - } -} diff --git a/crates/weaver-app/src/components/editor/writer/segmented.rs b/crates/weaver-app/src/components/editor/writer/segmented.rs deleted file mode 100644 index 4cd7b96..0000000 --- a/crates/weaver-app/src/components/editor/writer/segmented.rs +++ /dev/null @@ -1,66 +0,0 @@ -use core::fmt; - -use markdown_weaver_escape::StrWrite; - -/// Writer that segments output by paragraph boundaries. -/// -/// Each paragraph's HTML is written to a separate String in the segments Vec. -/// Call `new_segment()` at paragraph boundaries to start a new segment. -#[derive(Debug, Clone, Default)] -pub struct SegmentedWriter { - pub segments: Vec, -} - -#[allow(dead_code)] -impl SegmentedWriter { - pub fn new() -> Self { - Self { - segments: vec![String::new()], - } - } - - /// Start a new segment for the next paragraph. - pub fn new_segment(&mut self) { - self.segments.push(String::new()); - } - - /// Get the completed segments. - pub fn into_segments(self) -> Vec { - self.segments - } - - /// Get current segment count. - pub fn segment_count(&self) -> usize { - self.segments.len() - } -} - -impl StrWrite for SegmentedWriter { - type Error = fmt::Error; - - #[inline] - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { - if let Some(segment) = self.segments.last_mut() { - segment.push_str(s); - } - Ok(()) - } - - #[inline] - fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { - if let Some(segment) = self.segments.last_mut() { - fmt::Write::write_fmt(segment, args)?; - } - Ok(()) - } -} - -impl fmt::Write for SegmentedWriter { - fn write_str(&mut self, s: &str) -> fmt::Result { - ::write_str(self, s) - } - - fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { - ::write_fmt(self, args) - } -} diff --git a/crates/weaver-app/src/components/editor/writer/syntax.rs b/crates/weaver-app/src/components/editor/writer/syntax.rs deleted file mode 100644 index be2903c..0000000 --- a/crates/weaver-app/src/components/editor/writer/syntax.rs +++ /dev/null @@ -1,264 +0,0 @@ -use core::fmt; -use std::ops::Range; - -use markdown_weaver::Event; -use markdown_weaver_escape::{StrWrite, escape_html}; - -use crate::components::editor::writer::{ - EditorWriter, - embed::{EmbedContentProvider, ImageResolver}, -}; - -/// Classification of markdown syntax characters -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SyntaxType { - /// Inline formatting: **, *, ~~, `, $, [, ], (, ) - Inline, - /// Block formatting: #, >, -, *, 1., ```, --- - Block, -} - -/// Information about a syntax span for conditional visibility -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SyntaxSpanInfo { - /// Unique identifier for this syntax span (e.g., "s0", "s1") - pub syn_id: String, - /// Source char range this syntax covers (just this marker) - pub char_range: Range, - /// Whether this is inline or block-level syntax - pub syntax_type: SyntaxType, - /// For paired inline syntax (**, *, etc), the full formatted region - /// from opening marker through content to closing marker. - /// When cursor is anywhere in this range, the syntax is visible. - pub formatted_range: Option>, -} - -impl SyntaxSpanInfo { - /// Adjust all position fields by a character delta. - /// - /// This adjusts both `char_range` and `formatted_range` (if present) together, - /// ensuring they stay in sync. Use this instead of manually adjusting fields - /// to avoid forgetting one. - pub fn adjust_positions(&mut self, char_delta: isize) { - self.char_range.start = (self.char_range.start as isize + char_delta) as usize; - self.char_range.end = (self.char_range.end as isize + char_delta) as usize; - if let Some(ref mut fr) = self.formatted_range { - fr.start = (fr.start as isize + char_delta) as usize; - fr.end = (fr.end as isize + char_delta) as usize; - } - } -} - -/// Classify syntax text as inline or block level -pub(crate) fn classify_syntax(text: &str) -> SyntaxType { - let trimmed = text.trim_start(); - - // Check for block-level markers - if trimmed.starts_with('#') - || trimmed.starts_with('>') - || trimmed.starts_with("```") - || trimmed.starts_with("---") - || (trimmed.starts_with('-') - && trimmed - .chars() - .nth(1) - .map(|c| c.is_whitespace()) - .unwrap_or(false)) - || (trimmed.starts_with('*') - && trimmed - .chars() - .nth(1) - .map(|c| c.is_whitespace()) - .unwrap_or(false)) - || trimmed - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - && trimmed.contains('.') - { - SyntaxType::Block - } else { - SyntaxType::Inline - } -} - -impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> - EditorWriter<'a, I, E, R> -{ - /// Emit syntax span for a given range and record offset mapping - pub(crate) fn emit_syntax(&mut self, range: Range) -> Result<(), fmt::Error> { - if range.start < range.end { - let syntax = &self.source[range.clone()]; - if !syntax.is_empty() { - let char_start = self.last_char_offset; - let syntax_char_len = syntax.chars().count(); - let char_end = char_start + syntax_char_len; - - tracing::trace!( - target: "weaver::writer", - byte_range = ?range, - char_range = ?(char_start..char_end), - syntax = %syntax.escape_debug(), - "emit_syntax" - ); - - // 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(); - - 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 - }; - - escape_html(&mut self.writer, syntax)?; - - // Record offset mapping BEFORE end_node (which clears current_node_id) - self.record_mapping(range.clone(), char_start..char_end); - self.last_char_offset = char_end; - self.last_byte_offset = range.end; - - if created_node { - self.write("")?; - self.end_node(); - } - } 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(); - - // 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)?; - self.write("")?; - - // 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(); - } - } - } - } - Ok(()) - } - - /// Emit syntax span inside current node with full offset tracking. - /// - /// Use this for syntax markers that appear inside block elements (headings, lists, - /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates - /// wrapper nodes, this assumes we're already inside a tracked node. - /// - /// - Writes `{syntax}` - /// - Records offset mapping (for cursor positioning) - /// - Updates both `last_char_offset` and `last_byte_offset` - pub(crate) fn emit_inner_syntax( - &mut self, - syntax: &str, - byte_start: usize, - syntax_type: SyntaxType, - ) -> Result<(), fmt::Error> { - if syntax.is_empty() { - return Ok(()); - } - - let char_start = self.last_char_offset; - let syntax_char_len = syntax.chars().count(); - let char_end = char_start + syntax_char_len; - let byte_end = byte_start + syntax.len(); - - let class_str = 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(); - - write!( - &mut self.writer, - "", - class_str, syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; - - // 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 cursor positioning - self.record_mapping(byte_start..byte_end, char_start..char_end); - - self.last_char_offset = char_end; - self.last_byte_offset = byte_end; - - Ok(()) - } - - /// Emit any gap between last position and next offset - pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { - // Skip gap emission if we're inside a table being rendered as markdown - if self.table_start_offset.is_some() && self.render_tables_as_markdown { - return Ok(()); - } - - // Skip gap emission if we're buffering code block content - // The code block handler manages its own syntax emission - if self.code_buffer.is_some() { - return Ok(()); - } - - if next_offset > self.last_byte_offset { - self.emit_syntax(self.last_byte_offset..next_offset)?; - } - Ok(()) - } -} diff --git a/crates/weaver-app/src/components/editor/writer/tags.rs b/crates/weaver-app/src/components/editor/writer/tags.rs deleted file mode 100644 index 1a38bf5..0000000 --- a/crates/weaver-app/src/components/editor/writer/tags.rs +++ /dev/null @@ -1,1509 +0,0 @@ -use core::fmt; -use std::ops::Range; - -use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag}; -use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; - -use crate::components::editor::{ - OffsetMapping, SyntaxSpanInfo, SyntaxType, - writer::{ - EditorWriter, TableState, - embed::{EmbedContentProvider, ImageResolver}, - syntax::classify_syntax, - }, -}; - -impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> - EditorWriter<'a, I, E, R> -{ - /// Detect text direction by scanning source from a byte offset. - /// Looks for the first strong directional character. - /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found. - fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> { - if start_byte >= self.source.len() { - return None; - } - - // Scan from start_byte through the source looking for first strong directional char - let text = &self.source[start_byte..]; - weaver_renderer::utils::detect_text_direction(text) - } - - pub(crate) fn start_tag( - &mut self, - tag: Tag<'_>, - range: Range, - ) -> Result<(), fmt::Error> { - // Check if this is a block-level tag that should have syntax inside - let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); - - // For inline tags, emit syntax before tag - if !is_block_tag && range.start < range.end { - let raw_text = &self.source[range.clone()]; - let opening_syntax = match &tag { - Tag::Strong => { - if raw_text.starts_with("**") { - Some("**") - } else if raw_text.starts_with("__") { - Some("__") - } else { - None - } - } - Tag::Emphasis => { - if raw_text.starts_with("*") { - Some("*") - } else if raw_text.starts_with("_") { - Some("_") - } else { - None - } - } - Tag::Strikethrough => { - if raw_text.starts_with("~~") { - Some("~~") - } else { - None - } - } - Tag::Link { link_type, .. } => { - if matches!(link_type, LinkType::WikiLink { .. }) { - if raw_text.starts_with("[[") { - Some("[[") - } else { - None - } - } else if raw_text.starts_with('[') { - Some("[") - } else { - None - } - } - // Note: Tag::Image and Tag::Embed handle their own syntax spans - // in their respective handlers, so don't emit here - _ => None, - }; - - if let Some(syntax) = opening_syntax { - let syntax_type = classify_syntax(syntax); - let class = match syntax_type { - SyntaxType::Inline => "md-syntax-inline", - SyntaxType::Block => "md-syntax-block", - }; - - let char_start = self.last_char_offset; - let syntax_char_len = syntax.chars().count(); - let char_end = char_start + syntax_char_len; - let syntax_byte_len = syntax.len(); - - // Generate unique ID for this syntax span - let syn_id = self.gen_syn_id(); - - write!( - &mut self.writer, - "", - class, syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; - - // Record syntax span info for visibility toggling - self.syntax_spans.push(SyntaxSpanInfo { - syn_id: syn_id.clone(), - char_range: char_start..char_end, - syntax_type, - formatted_range: None, // Will be updated when closing tag is emitted - }); - - // Record offset mapping for cursor positioning - // This is critical - without it, current_node_char_offset is wrong - // and all subsequent cursor positions are shifted - let byte_start = range.start; - let byte_end = range.start + syntax_byte_len; - self.record_mapping(byte_start..byte_end, char_start..char_end); - - // For paired inline syntax, track opening span for formatted_range - if matches!( - tag, - Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } - ) { - self.pending_inline_formats.push((syn_id, char_start)); - } - - // Update tracking - we've consumed this opening syntax - self.last_char_offset = char_end; - self.last_byte_offset = range.start + syntax_byte_len; - } - } - - // Emit the opening tag - match tag { - // HTML blocks get their own paragraph to try and corral them better - Tag::HtmlBlock => { - // Record paragraph start for boundary tracking - // Skip if inside a list or footnote def - they own their paragraph boundary - if self.list_depth == 0 && !self.in_footnote_def { - self.current_paragraph_start = - Some((self.last_byte_offset, self.last_char_offset)); - } - let node_id = self.gen_node_id(); - - if self.end_newline { - write!( - &mut self.writer, - r#"

      "#, - node_id - )?; - } else { - write!( - &mut self.writer, - r#"

      "#, - node_id - )?; - } - self.begin_node(node_id.clone()); - - // Map the start position of the paragraph (before any content) - // This allows cursor to be placed at the very beginning - let para_start_char = self.last_char_offset; - let mapping = OffsetMapping { - byte_range: range.start..range.start, - char_range: para_start_char..para_start_char, - node_id, - char_offset_in_node: 0, - child_index: Some(0), // position before first child - utf16_len: 0, - }; - self.offset_maps.push(mapping); - - Ok(()) - } - Tag::Paragraph(_) => { - // Handle wrapper before block - self.emit_wrapper_start()?; - - // Record paragraph start for boundary tracking - // Skip if inside a list or footnote def - they own their paragraph boundary - if self.list_depth == 0 && !self.in_footnote_def { - self.current_paragraph_start = - Some((self.last_byte_offset, self.last_char_offset)); - } - - let node_id = self.gen_node_id(); - - // Detect text direction for this paragraph - let dir = self.detect_paragraph_direction(self.last_byte_offset); - - if self.end_newline { - if let Some(dir_value) = dir { - write!(&mut self.writer, "

      ", node_id, dir_value)?; - } else { - write!(&mut self.writer, "

      ", node_id)?; - } - } else { - if let Some(dir_value) = dir { - write!(&mut self.writer, "

      ", node_id, dir_value)?; - } else { - write!(&mut self.writer, "

      ", node_id)?; - } - } - self.begin_node(node_id.clone()); - - // Map the start position of the paragraph (before any content) - // This allows cursor to be placed at the very beginning - let para_start_char = self.last_char_offset; - let mapping = OffsetMapping { - byte_range: range.start..range.start, - char_range: para_start_char..para_start_char, - node_id, - char_offset_in_node: 0, - child_index: Some(0), // position before first child - utf16_len: 0, - }; - self.offset_maps.push(mapping); - - // Emit > syntax if we're inside a blockquote - 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('>') { - // Extract > [!NOTE] or just > - let after_gt = &raw_text[gt_pos + 1..]; - let syntax_end = if after_gt.trim_start().starts_with("[!") { - // Find the closing ] - if let Some(close_bracket) = after_gt.find(']') { - gt_pos + 1 + close_bracket + 1 - } else { - gt_pos + 1 - } - } else { - // Just > and maybe a space - (gt_pos + 1).min(raw_text.len()) - }; - - let syntax = &raw_text[gt_pos..syntax_end]; - let syntax_byte_start = bq_range.start + gt_pos; - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; - } - } - } - Ok(()) - } - Tag::Heading { - level, - id, - classes, - attrs, - } => { - // Emit wrapper if pending (but don't close on heading end - wraps following block too) - self.emit_wrapper_start()?; - - // Record paragraph start for boundary tracking - // Treat headings as paragraph-level blocks - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - - if !self.end_newline { - self.write_newline()?; - } - - // Generate node ID for offset tracking - let node_id = self.gen_node_id(); - - // Detect text direction for this heading - let dir = self.detect_paragraph_direction(self.last_byte_offset); - - self.write("<")?; - write!(&mut self.writer, "{}", level)?; - - // Add our tracking ID as data attribute (preserve user's id if present) - self.write(" data-node-id=\"")?; - self.write(&node_id)?; - self.write("\"")?; - - if let Some(id) = id { - self.write(" id=\"")?; - escape_html(&mut self.writer, &id)?; - self.write("\"")?; - } - if !classes.is_empty() { - self.write(" class=\"")?; - for (i, class) in classes.iter().enumerate() { - if i > 0 { - self.write(" ")?; - } - escape_html(&mut self.writer, class)?; - } - self.write("\"")?; - } - - // Add dir attribute if text direction was detected - if let Some(dir_value) = dir { - self.write(" dir=\"")?; - self.write(dir_value)?; - self.write("\"")?; - } - - for (attr, value) in attrs { - self.write(" ")?; - escape_html(&mut self.writer, &attr)?; - if let Some(val) = value { - self.write("=\"")?; - escape_html(&mut self.writer, &val)?; - self.write("\"")?; - } else { - self.write("=\"\"")?; - } - } - self.write(">")?; - - // Begin node tracking for offset mapping - self.begin_node(node_id.clone()); - - // Map the start position of the heading (before any content) - // This allows cursor to be placed at the very beginning - let heading_start_char = self.last_char_offset; - let mapping = OffsetMapping { - byte_range: range.start..range.start, - char_range: heading_start_char..heading_start_char, - node_id: node_id.clone(), - char_offset_in_node: 0, - child_index: Some(0), // position before first child - utf16_len: 0, - }; - self.offset_maps.push(mapping); - - // Emit # syntax inside the heading tag - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - let count = level as usize; - let pattern = "#".repeat(count); - - // Find where the # actually starts (might have leading whitespace) - if let Some(hash_pos) = raw_text.find(&pattern) { - // Extract "# " or "## " etc - let syntax_end = (hash_pos + count + 1).min(raw_text.len()); - let syntax = &raw_text[hash_pos..syntax_end]; - let syntax_byte_start = range.start + hash_pos; - - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; - } - } - Ok(()) - } - Tag::Table(alignments) => { - if self.render_tables_as_markdown { - // Store start offset and skip HTML rendering - self.table_start_offset = Some(range.start); - self.in_non_writing_block = true; // Suppress content output - Ok(()) - } else { - self.emit_wrapper_start()?; - self.table_alignments = alignments; - self.write("") - } - } - Tag::TableHead => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.table_state = TableState::Head; - self.table_cell_index = 0; - self.write("") - } - } - Tag::TableRow => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.table_cell_index = 0; - self.write("") - } - } - Tag::TableCell => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - match self.table_state { - TableState::Head => self.write(" self.write(" self.write(" style=\"text-align: left\">"), - Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), - Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), - _ => self.write(">"), - } - } - } - Tag::BlockQuote(kind) => { - self.emit_wrapper_start()?; - - let class_str = match kind { - None => "", - Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", - Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", - Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", - Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", - Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", - }; - if self.end_newline { - write!(&mut self.writer, "", class_str)?; - } else { - write!(&mut self.writer, "", class_str)?; - } - - // Store range for emitting > inside the next paragraph - self.pending_blockquote_range = Some(range); - Ok(()) - } - Tag::CodeBlock(info) => { - self.emit_wrapper_start()?; - - // Track code block as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - - if !self.end_newline { - self.write_newline()?; - } - - // Generate node ID for code block - let node_id = self.gen_node_id(); - - match info { - CodeBlockKind::Fenced(info) => { - // Emit opening ```language and track both char and byte offsets - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if let Some(fence_pos) = raw_text.find("```") { - let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); - let syntax = &raw_text[fence_pos..fence_end]; - let syntax_char_len = syntax.chars().count() + 1; // +1 for newline - let syntax_byte_len = syntax.len() + 1; // +1 for newline - - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let char_end = char_start + syntax_char_len; - - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; - - // Track opening span index for formatted_range update later - self.code_block_opening_span_idx = Some(self.syntax_spans.len()); - self.code_block_char_start = Some(char_start); - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Block, - formatted_range: None, // Will be set in TagEnd::CodeBlock - }); - - self.last_char_offset += syntax_char_len; - self.last_byte_offset = range.start + fence_pos + syntax_byte_len; - } - } - - let lang = info.split(' ').next().unwrap(); - let lang_opt = if lang.is_empty() { - None - } else { - Some(lang.to_string()) - }; - // Start buffering - self.code_buffer = Some((lang_opt, String::new())); - - // Begin node tracking for offset mapping - self.begin_node(node_id); - Ok(()) - } - CodeBlockKind::Indented => { - // Ignore indented code blocks (as per executive decision) - self.code_buffer = Some((None, String::new())); - - // Begin node tracking for offset mapping - self.begin_node(node_id); - Ok(()) - } - } - } - Tag::List(Some(1)) => { - self.emit_wrapper_start()?; - // Track list as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
        ") - } else { - self.write("
          ") - } - } - Tag::List(Some(start)) => { - self.emit_wrapper_start()?; - // Track list as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
            ") - } - Tag::List(None) => { - self.emit_wrapper_start()?; - // Track list as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
      ") - } - } - TagEnd::TableHead => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.write("")?; - self.table_state = TableState::Body; - Ok(()) - } - } - TagEnd::TableRow => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.write("") - } - } - TagEnd::TableCell => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - match self.table_state { - TableState::Head => self.write("")?, - TableState::Body => self.write("")?, - } - self.table_cell_index += 1; - Ok(()) - } - } - TagEnd::BlockQuote(_) => { - // If pending_blockquote_range is still set, the blockquote was empty - // (no paragraph inside). Emit the > as its own minimal paragraph. - let mut para_boundary = None; - 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, "

      ")?; - self.end_node(); - - // Capture paragraph boundary for later finalization - let byte_range = para_byte_start..bq_range.end; - let char_range = para_char_start..self.last_char_offset; - para_boundary = Some((byte_range, char_range)); - } - } - } - self.write("
      ")?; - self.close_wrapper()?; - - // Now finalize paragraph if we had one - if let Some((byte_range, char_range)) = para_boundary { - self.finalize_paragraph(byte_range, char_range); - } - Ok(()) - } - TagEnd::CodeBlock => { - use std::sync::LazyLock; - use syntect::parsing::SyntaxSet; - static SYNTAX_SET: LazyLock = - LazyLock::new(|| SyntaxSet::load_defaults_newlines()); - - if let Some((lang, buffer)) = self.code_buffer.take() { - // Create offset mapping for code block content if we tracked ranges - if let (Some(code_byte_range), Some(code_char_range)) = ( - self.code_buffer_byte_range.take(), - self.code_buffer_char_range.take(), - ) { - // Record mapping before writing HTML - // (current_node_id should be set by start_tag for CodeBlock) - self.record_mapping(code_byte_range, code_char_range); - } - - // Get node_id for data-node-id attribute (needed for cursor positioning) - let node_id = self.current_node_id.clone(); - - if let Some(ref lang_str) = lang { - // Use a temporary String buffer for syntect - let mut temp_output = String::new(); - match weaver_renderer::code_pretty::highlight( - &SYNTAX_SET, - Some(lang_str), - &buffer, - &mut temp_output, - ) { - Ok(_) => { - // Inject data-node-id into the
       tag for cursor positioning
      -                                if let Some(ref nid) = node_id {
      -                                    let injected = temp_output.replacen(
      -                                        "
      ",
      -                                        &format!("
      ", nid),
      -                                        1,
      -                                    );
      -                                    self.write(&injected)?;
      -                                } else {
      -                                    self.write(&temp_output)?;
      -                                }
      -                            }
      -                            Err(_) => {
      -                                // Fallback to plain code block
      -                                if let Some(ref nid) = node_id {
      -                                    write!(
      -                                        &mut self.writer,
      -                                        "
      ")?;
      -                                escape_html_body_text(&mut self.writer, &buffer)?;
      -                                self.write("
      ")?; - } - } - } else { - if let Some(ref nid) = node_id { - write!(&mut self.writer, "
      ", nid)?;
      -                        } else {
      -                            self.write("
      ")?;
      -                        }
      -                        escape_html_body_text(&mut self.writer, &buffer)?;
      -                        self.write("
      ")?; - } - - // End node tracking - self.end_node(); - } else { - self.write("
      ")?; - } - - // Emit closing ``` (emit_gap_before is skipped while buffering) - // Track the opening span index and char start before we potentially clear them - let opening_span_idx = self.code_block_opening_span_idx.take(); - let code_block_start = self.code_block_char_start.take(); - - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if let Some(fence_line) = raw_text.lines().last() { - if fence_line.trim().starts_with("```") { - let fence = fence_line.trim(); - let fence_char_len = fence.chars().count(); - - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let char_end = char_start + fence_char_len; - - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, fence)?; - self.write("")?; - - self.last_char_offset += fence_char_len; - self.last_byte_offset += fence.len(); - - // Compute formatted_range for entire code block (opening fence to closing fence) - let formatted_range = - code_block_start.map(|start| start..self.last_char_offset); - - // Update opening fence span with formatted_range - if let (Some(idx), Some(fr)) = - (opening_span_idx, formatted_range.as_ref()) - { - if let Some(span) = self.syntax_spans.get_mut(idx) { - span.formatted_range = Some(fr.clone()); - } - } - - // Push closing fence span with formatted_range - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Block, - formatted_range, - }); - } - } - } - - // Finalize code block paragraph - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.finalize_paragraph(byte_range, char_range); - } - - Ok(()) - } - TagEnd::List(true) => { - self.list_depth = self.list_depth.saturating_sub(1); - // Capture paragraph boundary BEFORE writing closing HTML - let para_boundary = - self.current_paragraph_start - .take() - .map(|(byte_start, char_start)| { - ( - byte_start..self.last_byte_offset, - char_start..self.last_char_offset, - ) - }); - - self.write("")?; - self.close_wrapper()?; - - // Finalize paragraph after closing HTML - if let Some((byte_range, char_range)) = para_boundary { - self.finalize_paragraph(byte_range, char_range); - } - Ok(()) - } - TagEnd::List(false) => { - self.list_depth = self.list_depth.saturating_sub(1); - // Capture paragraph boundary BEFORE writing closing HTML - let para_boundary = - self.current_paragraph_start - .take() - .map(|(byte_start, char_start)| { - ( - byte_start..self.last_byte_offset, - char_start..self.last_char_offset, - ) - }); - - self.write("
    ")?; - self.close_wrapper()?; - - // Finalize paragraph after closing HTML - if let Some((byte_range, char_range)) = para_boundary { - self.finalize_paragraph(byte_range, char_range); - } - Ok(()) - } - TagEnd::Item => { - self.end_node(); - self.write("
  • ") - } - TagEnd::DefinitionList => { - self.write("")?; - self.close_wrapper() - } - TagEnd::DefinitionListTitle => { - self.end_node(); - self.write("") - } - TagEnd::DefinitionListDefinition => { - self.end_node(); - self.write("") - } - TagEnd::Emphasis => { - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag - self.write("")?; - self.emit_gap_before(range.end)?; - self.finalize_paired_inline_format(); - Ok(()) - } - TagEnd::Superscript => self.write(""), - TagEnd::Subscript => self.write(""), - TagEnd::Strong => { - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag - self.write("")?; - self.emit_gap_before(range.end)?; - self.finalize_paired_inline_format(); - Ok(()) - } - TagEnd::Strikethrough => { - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag - self.write("")?; - self.emit_gap_before(range.end)?; - self.finalize_paired_inline_format(); - Ok(()) - } - TagEnd::Link => { - self.write("")?; - // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) - let raw_text = &self.source[range.clone()]; - if raw_text.ends_with("]]") { - // WikiLink: emit ]] as closing syntax - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let char_end = char_start + 2; - - write!( - &mut self.writer, - "]]", - syn_id, char_start, char_end - )?; - - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, // Will be set by finalize - }); - - self.last_char_offset = char_end; - self.last_byte_offset = range.end; - } else { - self.emit_gap_before(range.end)?; - } - self.finalize_paired_inline_format(); - Ok(()) - } - TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event - TagEnd::Embed => Ok(()), - TagEnd::WeaverBlock(_) => { - self.in_non_writing_block = false; - - // Emit the { content } as a hideable syntax span - if let Some(char_start) = self.weaver_block_char_start.take() { - // Build the full syntax text: { buffered_content } - let syntax_text = format!("{{{}}}", self.weaver_block_buffer); - let syntax_char_len = syntax_text.chars().count(); - let char_end = char_start + syntax_char_len; - - let syn_id = self.gen_syn_id(); - - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, &syntax_text)?; - self.write("")?; - - // Track the syntax span - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Block, - formatted_range: None, - }); - - // Record offset mapping for the syntax span - self.record_mapping(range.clone(), char_start..char_end); - - // Update tracking - self.last_char_offset = char_end; - self.last_byte_offset = range.end; - } - - // Parse the buffered text for attrs and store for next block - if !self.weaver_block_buffer.is_empty() { - let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); - self.weaver_block_buffer.clear(); - // Merge with any existing pending attrs or set new - if let Some(ref mut existing) = self.pending_block_attrs { - existing.classes.extend(parsed.classes); - existing.attrs.extend(parsed.attrs); - } else { - self.pending_block_attrs = Some(parsed); - } - } - - Ok(()) - } - TagEnd::FootnoteDefinition => { - // End node tracking (inner paragraphs may have already cleared it) - self.end_node(); - self.write("")?; - - // Clear footnote tracking - self.current_footnote_def.take(); - self.in_footnote_def = false; - - // Finalize paragraph boundary for incremental rendering - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { - let byte_range = byte_start..self.last_byte_offset; - let char_range = char_start..self.last_char_offset; - self.finalize_paragraph(byte_range, char_range); - } - - Ok(()) - } - TagEnd::MetadataBlock(_) => { - self.in_non_writing_block = false; - Ok(()) - } - }; - - result?; - - // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) - // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. - // This ensures the closing syntax span appears OUTSIDE the formatted element. - // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. - - Ok(()) - } -} diff --git a/crates/weaver-editor-core/src/render.rs b/crates/weaver-editor-core/src/render.rs index 48d3659..46eefb3 100644 --- a/crates/weaver-editor-core/src/render.rs +++ b/crates/weaver-editor-core/src/render.rs @@ -7,21 +7,26 @@ //! //! Implementations are provided by the consuming application (e.g., weaver-app). +use markdown_weaver::Tag; + /// Provides HTML content for embedded resources. /// /// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait /// is consulted to get the pre-rendered HTML for the embed. +/// +/// The full `Tag::Embed` is provided so implementations can access all context: +/// embed_type, dest_url, title, id, and attrs. pub trait EmbedContentProvider { - /// Get HTML content for an embed URL. + /// Get HTML content for an embed tag. /// /// Returns `Some(html)` if the embed content is available, /// `None` to render a placeholder. - fn get_embed_html(&self, url: &str) -> Option<&str>; + fn get_embed_content(&self, tag: &Tag<'_>) -> Option; } /// Unit type implementation - no embeds available. impl EmbedContentProvider for () { - fn get_embed_html(&self, _url: &str) -> Option<&str> { + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { None } } @@ -63,8 +68,8 @@ impl WikilinkValidator for () { /// Reference implementations for common patterns. impl EmbedContentProvider for &T { - fn get_embed_html(&self, url: &str) -> Option<&str> { - (*self).get_embed_html(url) + fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + (*self).get_embed_content(tag) } } @@ -81,8 +86,8 @@ impl WikilinkValidator for &T { } impl EmbedContentProvider for Option { - fn get_embed_html(&self, url: &str) -> Option<&str> { - self.as_ref().and_then(|p| p.get_embed_html(url)) + fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + self.as_ref().and_then(|p| p.get_embed_content(tag)) } } @@ -98,19 +103,39 @@ impl WikilinkValidator for Option { } } +/// Implementation for ResolvedContent from weaver-common. +/// +/// Resolves AT Protocol embeds by looking up the content in the ResolvedContent map. +impl EmbedContentProvider for weaver_common::ResolvedContent { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + if let Tag::Embed { dest_url, .. } = tag { + let url = dest_url.as_ref(); + if url.starts_with("at://") { + if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { + return weaver_common::ResolvedContent::get_embed_content(self, &at_uri) + .map(|s| s.to_string()); + } + } + } + None + } +} + #[cfg(test)] mod tests { use super::*; + use markdown_weaver::EmbedType; struct TestEmbedProvider; impl EmbedContentProvider for TestEmbedProvider { - fn get_embed_html(&self, url: &str) -> Option<&str> { - if url == "at://test/embed" { - Some("
    Test Embed
    ") - } else { - None + fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + if let Tag::Embed { dest_url, .. } = tag { + if dest_url.as_ref() == "at://test/embed" { + return Some("
    Test Embed
    ".to_string()); + } } + None } } @@ -136,14 +161,24 @@ mod tests { } } + fn make_embed_tag(url: &str) -> Tag<'_> { + Tag::Embed { + embed_type: EmbedType::Other, + dest_url: url.into(), + title: "".into(), + id: "".into(), + attrs: None, + } + } + #[test] fn test_embed_provider() { let provider = TestEmbedProvider; assert_eq!( - provider.get_embed_html("at://test/embed"), - Some("
    Test Embed
    ") + provider.get_embed_content(&make_embed_tag("at://test/embed")), + Some("
    Test Embed
    ".to_string()) ); - assert_eq!(provider.get_embed_html("at://other"), None); + assert_eq!(provider.get_embed_content(&make_embed_tag("at://other")), None); } #[test] @@ -169,7 +204,7 @@ mod tests { #[test] fn test_unit_impls() { let embed: () = (); - assert_eq!(embed.get_embed_html("anything"), None); + assert_eq!(embed.get_embed_content(&make_embed_tag("anything")), None); let image: () = (); assert_eq!(image.resolve_image_url("anything"), None); @@ -182,11 +217,11 @@ mod tests { fn test_option_impls() { let some_provider: Option = Some(TestEmbedProvider); assert_eq!( - some_provider.get_embed_html("at://test/embed"), - Some("
    Test Embed
    ") + some_provider.get_embed_content(&make_embed_tag("at://test/embed")), + Some("
    Test Embed
    ".to_string()) ); let none_provider: Option = None; - assert_eq!(none_provider.get_embed_html("at://test/embed"), None); + assert_eq!(none_provider.get_embed_content(&make_embed_tag("at://test/embed")), None); } } diff --git a/crates/weaver-editor-core/src/text.rs b/crates/weaver-editor-core/src/text.rs index 36f538b..c2fb2c4 100644 --- a/crates/weaver-editor-core/src/text.rs +++ b/crates/weaver-editor-core/src/text.rs @@ -10,7 +10,7 @@ use std::ops::Range; /// A text buffer that supports efficient editing and offset conversion. /// /// All offsets are in Unicode scalar values (chars), not bytes or UTF-16. -pub trait TextBuffer: Default + Clone { +pub trait TextBuffer { /// Total length in bytes (UTF-8). fn len_bytes(&self) -> usize; diff --git a/crates/weaver-editor-core/src/undo.rs b/crates/weaver-editor-core/src/undo.rs index ce617fb..ef2b005 100644 --- a/crates/weaver-editor-core/src/undo.rs +++ b/crates/weaver-editor-core/src/undo.rs @@ -47,15 +47,25 @@ struct EditOperation { /// /// This is the standard way to get undo support for local editing. /// All mutations go through this wrapper, which records them for undo. -#[derive(Clone)] -pub struct UndoableBuffer { +pub struct UndoableBuffer { buffer: T, undo_stack: Vec, redo_stack: Vec, max_steps: usize, } -impl Default for UndoableBuffer { +impl Clone for UndoableBuffer { + fn clone(&self) -> Self { + Self { + buffer: self.buffer.clone(), + undo_stack: self.undo_stack.clone(), + redo_stack: self.redo_stack.clone(), + max_steps: self.max_steps, + } + } +} + +impl Default for UndoableBuffer { fn default() -> Self { Self::new(T::default(), 100) } diff --git a/crates/weaver-editor-core/src/writer/embed.rs b/crates/weaver-editor-core/src/writer/embed.rs index 5780bbe..94f6cd4 100644 --- a/crates/weaver-editor-core/src/writer/embed.rs +++ b/crates/weaver-editor-core/src/writer/embed.rs @@ -6,7 +6,7 @@ use std::ops::Range; use jacquard::IntoStatic; use jacquard::types::{ident::AtIdentifier, string::Rkey}; -use markdown_weaver::{CowStr, EmbedType, Event}; +use markdown_weaver::{CowStr, Event, Tag}; use markdown_weaver_escape::{StrWrite, escape_html}; use smol_str::SmolStr; @@ -65,8 +65,10 @@ impl EditorImageResolver { blob_rkey: Rkey<'static>, ident: AtIdentifier<'static>, ) { - self.images - .insert(SmolStr::new(name), ResolvedImage::Draft { blob_rkey, ident }); + self.images.insert( + SmolStr::new(name), + ResolvedImage::Draft { blob_rkey, ident }, + ); } /// Add an already-uploaded draft image. @@ -160,8 +162,9 @@ impl ImageResolver for EditorImageResolver { } // write_embed implementation -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver, @@ -170,12 +173,18 @@ where pub(crate) fn write_embed( &mut self, range: Range, - _embed_type: EmbedType, - dest_url: CowStr<'_>, - title: CowStr<'_>, - _id: CowStr<'_>, - attrs: Option>, + tag: Tag<'_>, ) -> Result<(), fmt::Error> { + let Tag::Embed { + dest_url, + title, + attrs, + .. + } = &tag + else { + return Ok(()); + }; + // Embed rendering: all syntax elements share one syn_id for visibility toggling // Structure: ![[ url-as-link ]] let raw_text = &self.source[range.clone()]; @@ -279,7 +288,7 @@ where // 4. Emit the actual embed content // Try to get content from attributes first - let content_from_attrs = if let Some(ref attrs) = attrs { + let content_from_attrs = if let Some(attrs) = attrs { attrs .attrs .iter() @@ -290,12 +299,10 @@ where }; // If no content in attrs, try provider - // Convert to owned to avoid borrow checker issues with self.write() - // TODO: figure out a way to do this that doesn't involve cloning let content: Option = if content_from_attrs.is_some() { content_from_attrs } else if let Some(ref provider) = self.embed_provider { - provider.get_embed_html(url).map(|s| s.to_string()) + provider.get_embed_content(&tag) } else { None }; diff --git a/crates/weaver-editor-core/src/writer/events.rs b/crates/weaver-editor-core/src/writer/events.rs index 56f20b9..162400f 100644 --- a/crates/weaver-editor-core/src/writer/events.rs +++ b/crates/weaver-editor-core/src/writer/events.rs @@ -14,8 +14,9 @@ use crate::syntax::{SyntaxSpanInfo, SyntaxType}; use super::{EditorWriter, WriterResult}; // Main run loop -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver, @@ -84,7 +85,7 @@ where // Handle unmapped trailing content (stripped by parser) // This includes trailing spaces that markdown ignores let doc_byte_len = self.source.len(); - let doc_char_len = self.source_len_chars; + let doc_char_len = self.text_buffer.len_chars(); if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { // Emit the trailing content as visible syntax diff --git a/crates/weaver-editor-core/src/writer/mod.rs b/crates/weaver-editor-core/src/writer/mod.rs index f487c08..e6b8afa 100644 --- a/crates/weaver-editor-core/src/writer/mod.rs +++ b/crates/weaver-editor-core/src/writer/mod.rs @@ -94,17 +94,19 @@ impl markdown_weaver_escape::StrWrite for SegmentedWriter { /// HTML writer that preserves markdown formatting characters. /// /// Generic over: +/// - `T`: Text buffer for efficient offset conversions /// - `I`: Iterator of markdown events with byte ranges /// - `E`: Embed content provider (optional) /// - `R`: Image resolver (optional) /// - `W`: Wikilink validator (optional) -pub struct EditorWriter<'a, I, E = (), R = (), W = ()> +pub struct EditorWriter<'a, T, I, E = (), R = (), W = ()> where + T: crate::TextBuffer, I: Iterator, Range)>, { // === Input === source: &'a str, - source_len_chars: usize, + text_buffer: &'a T, events: I, // === Output === @@ -146,19 +148,20 @@ where ref_collector: weaver_common::RefCollector, } -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, { /// Create a new EditorWriter. /// - /// `source` is the markdown source text. - /// `source_len_chars` is the length in Unicode chars (for bounds checking). + /// `source` is the markdown source text (should match text_buffer content). + /// `text_buffer` provides efficient offset conversions. /// `events` is the markdown parser event iterator. - pub fn new(source: &'a str, source_len_chars: usize, events: I) -> Self { + pub fn new(source: &'a str, text_buffer: &'a T, events: I) -> Self { Self { source, - source_len_chars, + text_buffer, events, writer: SegmentedWriter::new(), last_byte_offset: 0, @@ -232,10 +235,10 @@ where pub fn with_embed_provider( self, provider: E2, - ) -> EditorWriter<'a, I, E2, R, W> { + ) -> EditorWriter<'a, T, I, E2, R, W> { EditorWriter { source: self.source, - source_len_chars: self.source_len_chars, + text_buffer: self.text_buffer, events: self.events, writer: self.writer, last_byte_offset: self.last_byte_offset, @@ -268,10 +271,10 @@ where pub fn with_image_resolver( self, resolver: R2, - ) -> EditorWriter<'a, I, E, R2, W> { + ) -> EditorWriter<'a, T, I, E, R2, W> { EditorWriter { source: self.source, - source_len_chars: self.source_len_chars, + text_buffer: self.text_buffer, events: self.events, writer: self.writer, last_byte_offset: self.last_byte_offset, @@ -304,10 +307,10 @@ where pub fn with_wikilink_validator( self, validator: W2, - ) -> EditorWriter<'a, I, E, R, W2> { + ) -> EditorWriter<'a, T, I, E, R, W2> { EditorWriter { source: self.source, - source_len_chars: self.source_len_chars, + text_buffer: self.text_buffer, events: self.events, writer: self.writer, last_byte_offset: self.last_byte_offset, @@ -344,8 +347,9 @@ where } // Core helper methods -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, { /// Write a string to the output. diff --git a/crates/weaver-editor-core/src/writer/syntax.rs b/crates/weaver-editor-core/src/writer/syntax.rs index f1de835..be68885 100644 --- a/crates/weaver-editor-core/src/writer/syntax.rs +++ b/crates/weaver-editor-core/src/writer/syntax.rs @@ -11,8 +11,9 @@ use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; use super::EditorWriter; -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver, diff --git a/crates/weaver-editor-core/src/writer/tags.rs b/crates/weaver-editor-core/src/writer/tags.rs index d426740..8b81f2b 100644 --- a/crates/weaver-editor-core/src/writer/tags.rs +++ b/crates/weaver-editor-core/src/writer/tags.rs @@ -13,8 +13,9 @@ use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; use super::{EditorWriter, TableState}; -impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W> where + T: crate::TextBuffer, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver, @@ -747,14 +748,15 @@ where if matches!(link_type, LinkType::WikiLink { .. }) && (url.starts_with("at://") || url.starts_with("did:")) { - return self.write_embed( - range, - EmbedType::Other, // AT embeds - disambiguated via NSID later + // Construct an Embed tag from the Image fields + let embed_tag = Tag::Embed { + embed_type: EmbedType::Other, dest_url, title, id, attrs, - ); + }; + return self.write_embed(range, embed_tag); } // Image rendering: all syntax elements share one syn_id for visibility toggling @@ -906,13 +908,7 @@ where Ok(()) } - Tag::Embed { - embed_type, - dest_url, - title, - id, - attrs, - } => self.write_embed(range, embed_type, dest_url, title, id, attrs), + tag @ Tag::Embed { .. } => self.write_embed(range, tag), Tag::WeaverBlock(_, attrs) => { self.in_non_writing_block = true; self.weaver_block.buffer.clear();