WIP editor

Orual 582d42dc 6188b05c

+3063 -7
+20 -2
Cargo.lock
··· 4801 4801 ] 4802 4802 4803 4803 [[package]] 4804 + name = "jumprope" 4805 + version = "1.1.2" 4806 + source = "registry+https://github.com/rust-lang/crates.io-index" 4807 + checksum = "829c74fe88dda0d2a5425b022b44921574a65c4eb78e6e39a61b40eb416a4ef8" 4808 + dependencies = [ 4809 + "rand 0.8.5", 4810 + "str_indices", 4811 + ] 4812 + 4813 + [[package]] 4804 4814 name = "k256" 4805 4815 version = "0.13.4" 4806 4816 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5168 5178 [[package]] 5169 5179 name = "markdown-weaver" 5170 5180 version = "0.13.0" 5171 - source = "git+https://github.com/rsform/markdown-weaver#46f3eee6c93118da84a5de3d25fef642735aedd2" 5172 5181 dependencies = [ 5173 5182 "bitflags 2.10.0", 5174 5183 "getopts", ··· 5181 5190 [[package]] 5182 5191 name = "markdown-weaver-escape" 5183 5192 version = "0.11.0" 5184 - source = "git+https://github.com/rsform/markdown-weaver#46f3eee6c93118da84a5de3d25fef642735aedd2" 5185 5193 5186 5194 [[package]] 5187 5195 name = "markup5ever" ··· 7839 7847 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 7840 7848 7841 7849 [[package]] 7850 + name = "str_indices" 7851 + version = "0.4.4" 7852 + source = "registry+https://github.com/rust-lang/crates.io-index" 7853 + checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" 7854 + 7855 + [[package]] 7842 7856 name = "string_cache" 7843 7857 version = "0.8.9" 7844 7858 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 9426 9440 "dioxus-primitives", 9427 9441 "dotenvy", 9428 9442 "gloo-storage", 9443 + "gloo-timers", 9429 9444 "http", 9430 9445 "humansize", 9431 9446 "jacquard", ··· 9433 9448 "jacquard-identity", 9434 9449 "jacquard-lexicon", 9435 9450 "js-sys", 9451 + "jumprope", 9436 9452 "lol_alloc", 9437 9453 "markdown-weaver", 9454 + "markdown-weaver-escape", 9438 9455 "mime-sniffer", 9439 9456 "mini-moka 0.11.0", 9440 9457 "n0-future", ··· 9443 9460 "serde_html_form", 9444 9461 "serde_ipld_dagcbor", 9445 9462 "serde_json", 9463 + "syntect", 9446 9464 "time", 9447 9465 "tokio", 9448 9466 "tracing",
+4 -2
Cargo.toml
··· 28 28 syntect = { version = "5.2.0", default-features = false } 29 29 n0-future = "=0.1.3" 30 30 tracing = { version = "0.1.41", default-features = false, features = ["std"] } 31 - markdown-weaver = { git = "https://github.com/rsform/markdown-weaver" } 32 - markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } 31 + # markdown-weaver = { git = "https://github.com/rsform/markdown-weaver" } 32 + # markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } 33 + markdown-weaver = { path = "../markdown-weaver/markdown-weaver" } 34 + markdown-weaver-escape = { path = "../markdown-weaver/markdown-weaver-escape" } 33 35 34 36 # jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", default-features = false, features = ["derive", "api_bluesky", "tracing"] } 35 37 # jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["cache"] }
+5 -2
crates/weaver-app/Cargo.toml
··· 42 42 http = "1.3" 43 43 reqwest = { version = "0.12", default-features = false, features = ["json"] } 44 44 dioxus-free-icons = { version = "0.9", features = ["font-awesome-brands"] } 45 - 45 + syntect = { workspace = true, default-features = false, features = ["default-fancy"]} 46 46 # diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } 47 47 # diesel_migrations = { version = "2.3", features = ["sqlite"] } 48 48 tokio = { version = "1.28", features = ["sync"] } 49 49 serde_html_form = "0.2.8" 50 50 tracing.workspace = true 51 51 serde_ipld_dagcbor = { version = "0.6" } 52 + jumprope = { version = "1.1", features = ["wchar_conversion"] } 53 + markdown-weaver-escape = { workspace = true } 52 54 53 55 [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] 54 56 webbrowser = "1.0.6" ··· 63 65 chrono = { version = "0.4", features = ["wasmbind"] } 64 66 wasm-bindgen = "0.2" 65 67 wasm-bindgen-futures = "0.4" 66 - web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement"] } 68 + web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter"] } 67 69 js-sys = "0.3" 68 70 gloo-storage = "0.3" 71 + gloo-timers = "0.3" 69 72 lol_alloc = "0.4.1" 70 73 71 74 [build-dependencies]
+101
crates/weaver-app/assets/styling/editor.css
··· 1 + /* Markdown Editor Styling - using Rose Pine theme variables */ 2 + 3 + .editor-page { 4 + width: 100%; 5 + height: 100%; 6 + background: var(--color-base); 7 + overflow: hidden; 8 + } 9 + 10 + .markdown-editor-container { 11 + display: flex; 12 + flex-direction: row; 13 + height: 100%; 14 + max-width: 1200px; 15 + margin: 0 auto; 16 + font-family: var(--font-body); 17 + background: var(--color-base); 18 + color: var(--color-text); 19 + } 20 + 21 + .editor-content-wrapper { 22 + display: flex; 23 + flex-direction: column; 24 + flex: 1; 25 + min-height: 0; 26 + } 27 + 28 + .editor-content { 29 + flex: 1; 30 + padding: 20px; 31 + overflow-y: auto; 32 + outline: none; 33 + line-height: var(--spacing-line-height); 34 + font-size: 16px; 35 + background: var(--color-surface); 36 + border: 1px solid var(--color-border); 37 + color: var(--color-text); 38 + } 39 + 40 + .editor-content:focus { 41 + background: var(--color-surface); 42 + } 43 + 44 + .editor-toolbar { 45 + display: flex; 46 + flex-direction: column; 47 + gap: 4px; 48 + padding: 8px; 49 + background: var(--color-base); 50 + flex-shrink: 0; 51 + min-width: 60px; 52 + } 53 + 54 + .toolbar-button { 55 + padding: 8px 12px; 56 + border: 1px solid var(--color-border); 57 + background: var(--color-surface); 58 + color: var(--color-text); 59 + border-radius: 4px; 60 + cursor: pointer; 61 + font-weight: 600; 62 + text-align: center; 63 + transition: background 0.2s ease; 64 + } 65 + 66 + .toolbar-button:hover { 67 + background: var(--color-overlay); 68 + } 69 + 70 + .toolbar-separator { 71 + height: 1px; 72 + background: var(--color-border); 73 + margin: 4px 0; 74 + } 75 + 76 + .editor-debug { 77 + padding: 8px; 78 + background: var(--color-base); 79 + font-family: var(--font-mono); 80 + font-size: 12px; 81 + flex-shrink: 0; 82 + color: var(--color-muted); 83 + } 84 + 85 + /* Markdown syntax characters - inline (**, *, ~~, `, etc) */ 86 + .md-syntax-inline { 87 + color: var(--color-muted); 88 + opacity: 0.6; 89 + user-select: none; 90 + } 91 + 92 + /* Markdown syntax characters - block level (#, >, -, etc) */ 93 + .md-syntax-block { 94 + color: var(--color-muted); 95 + opacity: 0.7; 96 + user-select: none; 97 + font-weight: normal; 98 + } 99 + 100 + /* Future: contextual hiding based on cursor position */ 101 + /* .cursor-active .md-syntax-inline { display: none; } */
+156
crates/weaver-app/src/components/editor/cursor.rs
··· 1 + //! Cursor position restoration in the DOM. 2 + //! 3 + //! After re-rendering HTML, we need to restore the cursor to its original 4 + //! position in the source text. This involves: 5 + //! 1. Finding the offset mapping for the cursor's char position 6 + //! 2. Getting the DOM element by node ID 7 + //! 3. Walking text nodes to find the UTF-16 offset within the element 8 + //! 4. Setting cursor with web_sys Selection API 9 + 10 + use super::offset_map::{find_mapping_for_char, OffsetMapping}; 11 + use jumprope::JumpRopeBuf; 12 + 13 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 14 + use wasm_bindgen::JsCast; 15 + 16 + /// Restore cursor position in the DOM after re-render. 17 + /// 18 + /// # Arguments 19 + /// - `rope`: The document content (for length bounds checking) 20 + /// - `char_offset`: Cursor position as char offset in rope 21 + /// - `offset_map`: Mappings from source to DOM positions 22 + /// - `editor_id`: DOM ID of the contenteditable element 23 + /// 24 + /// # Algorithm 25 + /// 1. Find offset mapping containing char_offset 26 + /// 2. Get DOM node by mapping.node_id 27 + /// 3. Walk text nodes to find UTF-16 position 28 + /// 4. Set cursor with Selection API 29 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 30 + pub fn restore_cursor_position( 31 + rope: &JumpRopeBuf, 32 + char_offset: usize, 33 + offset_map: &[OffsetMapping], 34 + editor_id: &str, 35 + ) -> Result<(), wasm_bindgen::JsValue> { 36 + // Bounds check 37 + let max_offset = rope.len_chars(); 38 + if char_offset > max_offset { 39 + return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into()); 40 + } 41 + 42 + // Empty document - no cursor to restore 43 + if offset_map.is_empty() || max_offset == 0 { 44 + return Ok(()); 45 + } 46 + 47 + // Find mapping for this cursor position 48 + let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset) 49 + .ok_or("no mapping found for cursor offset")?; 50 + 51 + // If cursor is in invisible content, snap to next visible position 52 + // For now, we'll still use the mapping but this is a future enhancement 53 + if should_snap { 54 + tracing::debug!("cursor in invisible content at offset {}", char_offset); 55 + } 56 + 57 + // Get window and document 58 + let window = web_sys::window().ok_or("no window")?; 59 + let document = window.document().ok_or("no document")?; 60 + 61 + // Get the container element by node ID 62 + let container = document 63 + .get_element_by_id(&mapping.node_id) 64 + .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; 65 + 66 + // Set selection using Range API 67 + let selection = window 68 + .get_selection()? 69 + .ok_or("no selection object")?; 70 + let range = document.create_range()?; 71 + 72 + // Check if this is an element-based position (e.g., after <br />) 73 + if let Some(child_index) = mapping.child_index { 74 + // Position cursor at child index in the element 75 + range.set_start(&container, child_index as u32)?; 76 + } else { 77 + // Position cursor in text content 78 + let container_element = container.dyn_into::<web_sys::HtmlElement>()?; 79 + let offset_in_range = char_offset - mapping.char_range.start; 80 + let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 81 + let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?; 82 + range.set_start(&text_node, node_offset as u32)?; 83 + } 84 + 85 + range.collapse_with_to_start(true); 86 + 87 + selection.remove_all_ranges()?; 88 + selection.add_range(&range)?; 89 + 90 + Ok(()) 91 + } 92 + 93 + /// Find text node at given UTF-16 offset within element. 94 + /// 95 + /// Walks all text nodes in the container, accumulating their UTF-16 lengths 96 + /// until we find the node containing the target offset. 97 + /// 98 + /// Returns (text_node, offset_within_node). 99 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 100 + fn find_text_node_at_offset( 101 + container: &web_sys::HtmlElement, 102 + target_utf16_offset: usize, 103 + ) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> { 104 + let document = web_sys::window() 105 + .ok_or("no window")? 106 + .document() 107 + .ok_or("no document")?; 108 + 109 + // Create tree walker to find text nodes 110 + // SHOW_TEXT = 4 (from DOM spec) 111 + let walker = document.create_tree_walker_with_what_to_show( 112 + container, 113 + 4, 114 + )?; 115 + 116 + let mut accumulated_utf16 = 0; 117 + let mut last_node: Option<web_sys::Node> = None; 118 + 119 + while let Some(node) = walker.next_node()? { 120 + last_node = Some(node.clone()); 121 + 122 + if let Some(text) = node.text_content() { 123 + let text_len = text.encode_utf16().count(); 124 + 125 + // Found the node containing target offset 126 + if accumulated_utf16 + text_len >= target_utf16_offset { 127 + let offset_in_node = target_utf16_offset - accumulated_utf16; 128 + return Ok((node, offset_in_node)); 129 + } 130 + 131 + accumulated_utf16 += text_len; 132 + } 133 + } 134 + 135 + // Fallback: return last node at its end 136 + // This handles cursor at end of document 137 + if let Some(node) = last_node { 138 + if let Some(text) = node.text_content() { 139 + let text_len = text.encode_utf16().count(); 140 + return Ok((node, text_len)); 141 + } 142 + } 143 + 144 + Err("no text node found in container".into()) 145 + } 146 + 147 + /// Non-WASM stub for testing 148 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 149 + pub fn restore_cursor_position( 150 + _rope: &JumpRopeBuf, 151 + _char_offset: usize, 152 + _offset_map: &[OffsetMapping], 153 + _editor_id: &str, 154 + ) -> Result<(), String> { 155 + Ok(()) 156 + }
+85
crates/weaver-app/src/components/editor/document.rs
··· 1 + //! Core data structures for the markdown editor. 2 + 3 + use jumprope::JumpRopeBuf; 4 + 5 + /// Single source of truth for editor state. 6 + /// 7 + /// Contains the document text, cursor position, selection, and IME composition state. 8 + #[derive(Clone, Debug)] 9 + pub struct EditorDocument { 10 + /// The rope storing document text (uses char offsets, not bytes). 11 + /// Uses JumpRopeBuf to batch consecutive edits for performance. 12 + pub rope: JumpRopeBuf, 13 + 14 + /// Current cursor position (char offset) 15 + pub cursor: CursorState, 16 + 17 + /// Active selection if any 18 + pub selection: Option<Selection>, 19 + 20 + /// IME composition state (for Phase 3) 21 + pub composition: Option<CompositionState>, 22 + } 23 + 24 + /// Cursor state including position and affinity. 25 + #[derive(Clone, Debug, Copy)] 26 + pub struct CursorState { 27 + /// Character offset in rope (NOT byte offset!) 28 + pub offset: usize, 29 + 30 + /// Prefer left/right when at boundary (for vertical cursor movement) 31 + pub affinity: Affinity, 32 + } 33 + 34 + /// Cursor affinity for vertical movement. 35 + #[derive(Clone, Debug, Copy, PartialEq, Eq)] 36 + pub enum Affinity { 37 + Before, 38 + After, 39 + } 40 + 41 + /// Text selection with anchor and head positions. 42 + #[derive(Clone, Debug, Copy)] 43 + pub struct Selection { 44 + /// Where selection started 45 + pub anchor: usize, 46 + /// Where cursor is now 47 + pub head: usize, 48 + } 49 + 50 + /// IME composition state (for international text input). 51 + #[derive(Clone, Debug)] 52 + pub struct CompositionState { 53 + pub start_offset: usize, 54 + pub text: String, 55 + } 56 + 57 + impl EditorDocument { 58 + /// Create a new editor document with the given content. 59 + pub fn new(content: String) -> Self { 60 + Self { 61 + rope: JumpRopeBuf::from(content.as_str()), 62 + cursor: CursorState { 63 + offset: 0, 64 + affinity: Affinity::Before, 65 + }, 66 + selection: None, 67 + composition: None, 68 + } 69 + } 70 + 71 + /// Convert the document to a string. 72 + pub fn to_string(&self) -> String { 73 + self.rope.to_string() 74 + } 75 + 76 + /// Get the length of the document in characters. 77 + pub fn len_chars(&self) -> usize { 78 + self.rope.len_chars() 79 + } 80 + 81 + /// Check if the document is empty. 82 + pub fn is_empty(&self) -> bool { 83 + self.rope.len_chars() == 0 84 + } 85 + }
+152
crates/weaver-app/src/components/editor/formatting.rs
··· 1 + //! Formatting actions and utilities for applying markdown formatting. 2 + 3 + use super::document::EditorDocument; 4 + 5 + /// Formatting actions available in the editor. 6 + #[derive(Clone, Debug, PartialEq)] 7 + pub enum FormatAction { 8 + Bold, 9 + Italic, 10 + Strikethrough, 11 + Code, 12 + Link, 13 + Image, 14 + Heading(u8), // 1-6 15 + BulletList, 16 + NumberedList, 17 + Quote, 18 + } 19 + 20 + /// Find word boundaries around cursor position. 21 + /// 22 + /// Expands to whitespace boundaries. Used when applying formatting 23 + /// without a selection. 24 + pub fn find_word_boundaries(rope: &jumprope::JumpRopeBuf, offset: usize) -> (usize, usize) { 25 + let rope = rope.borrow(); 26 + let mut start = 0; 27 + let mut end = rope.len_chars(); 28 + 29 + // Find start by scanning backwards 30 + let mut char_pos = 0; 31 + for substr in rope.slice_substrings(0..offset) { 32 + for c in substr.chars() { 33 + if c.is_whitespace() { 34 + start = char_pos + 1; 35 + } 36 + char_pos += 1; 37 + } 38 + } 39 + 40 + // Find end by scanning forwards 41 + char_pos = offset; 42 + let byte_len = rope.len_bytes(); 43 + for substr in rope.slice_substrings(offset..byte_len) { 44 + for c in substr.chars() { 45 + if c.is_whitespace() { 46 + end = char_pos; 47 + return (start, end); 48 + } 49 + char_pos += 1; 50 + } 51 + } 52 + 53 + (start, end) 54 + } 55 + 56 + /// Apply formatting to document. 57 + /// 58 + /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 59 + pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { 60 + let (start, end) = if let Some(sel) = doc.selection { 61 + // Use selection 62 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 63 + } else { 64 + // Expand to word 65 + find_word_boundaries(&doc.rope, doc.cursor.offset) 66 + }; 67 + 68 + match action { 69 + FormatAction::Bold => { 70 + doc.rope.insert(end, "**"); 71 + doc.rope.insert(start, "**"); 72 + doc.cursor.offset = end + 4; 73 + doc.selection = None; 74 + } 75 + FormatAction::Italic => { 76 + doc.rope.insert(end, "*"); 77 + doc.rope.insert(start, "*"); 78 + doc.cursor.offset = end + 2; 79 + doc.selection = None; 80 + } 81 + FormatAction::Strikethrough => { 82 + doc.rope.insert(end, "~~"); 83 + doc.rope.insert(start, "~~"); 84 + doc.cursor.offset = end + 4; 85 + doc.selection = None; 86 + } 87 + FormatAction::Code => { 88 + doc.rope.insert(end, "`"); 89 + doc.rope.insert(start, "`"); 90 + doc.cursor.offset = end + 2; 91 + doc.selection = None; 92 + } 93 + FormatAction::Link => { 94 + // Insert [selected text](url) 95 + doc.rope.insert(end, "](url)"); 96 + doc.rope.insert(start, "["); 97 + doc.cursor.offset = end + 8; // Position cursor after ](url) 98 + doc.selection = None; 99 + } 100 + FormatAction::Image => { 101 + // Insert ![alt text](url) 102 + doc.rope.insert(end, "](url)"); 103 + doc.rope.insert(start, "!["); 104 + doc.cursor.offset = end + 9; 105 + doc.selection = None; 106 + } 107 + FormatAction::Heading(level) => { 108 + // Find start of current line 109 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 110 + let prefix = "#".repeat(level as usize) + " "; 111 + doc.rope.insert(line_start, &prefix); 112 + doc.cursor.offset += prefix.len(); 113 + doc.selection = None; 114 + } 115 + FormatAction::BulletList => { 116 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 117 + doc.rope.insert(line_start, "- "); 118 + doc.cursor.offset += 2; 119 + doc.selection = None; 120 + } 121 + FormatAction::NumberedList => { 122 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 123 + doc.rope.insert(line_start, "1. "); 124 + doc.cursor.offset += 3; 125 + doc.selection = None; 126 + } 127 + FormatAction::Quote => { 128 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 129 + doc.rope.insert(line_start, "> "); 130 + doc.cursor.offset += 2; 131 + doc.selection = None; 132 + } 133 + } 134 + } 135 + 136 + /// Find start of line containing offset (same as in mod.rs) 137 + fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 138 + let mut char_pos = 0; 139 + let mut last_newline_pos = None; 140 + 141 + let rope = rope.borrow(); 142 + for substr in rope.slice_substrings(0..offset) { 143 + for c in substr.chars() { 144 + if c == '\n' { 145 + last_newline_pos = Some(char_pos); 146 + } 147 + char_pos += 1; 148 + } 149 + } 150 + 151 + last_newline_pos.map(|pos| pos + 1).unwrap_or(0) 152 + }
+328
crates/weaver-app/src/components/editor/mod.rs
··· 1 + //! Markdown editor component with Obsidian-style formatting visibility. 2 + //! 3 + //! This module implements a WYSIWYG-like markdown editor where formatting 4 + //! characters are hidden contextually based on cursor position, while still 5 + //! editing plain markdown text under the hood. 6 + 7 + mod cursor; 8 + mod document; 9 + mod formatting; 10 + mod offset_map; 11 + mod offsets; 12 + mod render; 13 + mod rope_writer; 14 + mod storage; 15 + mod toolbar; 16 + mod writer; 17 + 18 + pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; 19 + pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 20 + pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 21 + pub use render::render_markdown_simple; 22 + pub use rope_writer::RopeWriter; 23 + pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; 24 + pub use toolbar::EditorToolbar; 25 + 26 + use dioxus::prelude::*; 27 + 28 + /// Main markdown editor component. 29 + /// 30 + /// # Props 31 + /// - `initial_content`: Optional initial markdown content 32 + /// 33 + /// # Features 34 + /// - JumpRope-based text storage for efficient editing 35 + /// - Event interception for full control over editing operations 36 + /// - Toolbar formatting buttons 37 + /// - LocalStorage auto-save with debouncing 38 + /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 39 + /// 40 + /// # Phase 1 Limitations 41 + /// - Cursor jumps to end after each keystroke (acceptable for MVP) 42 + /// - All formatting characters visible (no hiding based on cursor position) 43 + /// - No proper grapheme cluster handling 44 + /// - No IME composition support 45 + /// - No undo/redo 46 + /// - No selection with Shift+Arrow 47 + /// - No mouse selection 48 + #[component] 49 + pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 50 + // Try to restore from localStorage 51 + let restored = use_memo(move || { 52 + storage::load_from_storage() 53 + .map(|s| s.content) 54 + .or_else(|| initial_content.clone()) 55 + .unwrap_or_default() 56 + }); 57 + 58 + let mut document = use_signal(|| EditorDocument::new(restored())); 59 + let editor_id = "markdown-editor"; 60 + 61 + // Render markdown to HTML with offset mappings 62 + let render_result = use_memo(move || render::render_markdown_simple(&document().to_string())); 63 + let rendered_html = use_memo(move || render_result.read().html.clone()); 64 + let offset_map = use_memo(move || render_result.read().offset_map.clone()); 65 + 66 + // Auto-save with debounce 67 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 68 + use_effect(move || { 69 + let doc = document(); 70 + 71 + // Save after 500ms of no typing 72 + let timer = gloo_timers::callback::Timeout::new(500, move || { 73 + let _ = storage::save_to_storage(&doc.to_string(), doc.cursor.offset); 74 + }); 75 + timer.forget(); 76 + }); 77 + 78 + // Restore cursor after re-render 79 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 80 + use_effect(move || { 81 + use wasm_bindgen::prelude::*; 82 + use wasm_bindgen::JsCast; 83 + 84 + let cursor_offset = document().cursor.offset; 85 + let rope = document().rope.clone(); 86 + let map = offset_map.read().clone(); 87 + 88 + // Use requestAnimationFrame to wait for browser paint 89 + let window = web_sys::window().expect("no window"); 90 + 91 + let closure = Closure::once(move || { 92 + if let Err(e) = cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) { 93 + tracing::warn!("Cursor restoration failed: {:?}", e); 94 + } 95 + }); 96 + 97 + let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); 98 + closure.forget(); 99 + }); 100 + 101 + rsx! { 102 + Stylesheet { href: asset!("/assets/styling/editor.css") } 103 + div { class: "markdown-editor-container", 104 + div { class: "editor-content-wrapper", 105 + // Debug panel 106 + div { class: "editor-debug", 107 + "Cursor: {document().cursor.offset}, " 108 + "Chars: {document().len_chars()}" 109 + } 110 + div { 111 + id: "{editor_id}", 112 + class: "editor-content", 113 + contenteditable: "true", 114 + dangerous_inner_html: "{rendered_html}", 115 + 116 + onkeydown: move |evt| { 117 + evt.prevent_default(); 118 + handle_keydown(evt, &mut document); 119 + }, 120 + 121 + onpaste: move |evt| { 122 + evt.prevent_default(); 123 + handle_paste(evt, &mut document); 124 + }, 125 + 126 + // Phase 1: Accept that cursor position will jump 127 + // Phase 2: Restore cursor properly 128 + } 129 + 130 + 131 + } 132 + 133 + EditorToolbar { 134 + on_format: move |action| { 135 + document.with_mut(|doc| { 136 + formatting::apply_formatting(doc, action); 137 + }); 138 + } 139 + } 140 + } 141 + } 142 + } 143 + 144 + /// Handle paste events and insert text at cursor 145 + fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 146 + // Downcast to web_sys event to get clipboard data 147 + #[cfg(target_arch = "wasm32")] 148 + if let Some(web_evt) = evt.data().downcast::<web_sys::ClipboardEvent>() { 149 + if let Some(data_transfer) = web_evt.clipboard_data() { 150 + if let Ok(text) = data_transfer.get_data("text/plain") { 151 + document.with_mut(|doc| { 152 + // Delete selection if present 153 + if let Some(sel) = doc.selection { 154 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 155 + doc.rope.remove(start..end); 156 + doc.cursor.offset = start; 157 + doc.selection = None; 158 + } 159 + 160 + // Insert pasted text 161 + doc.rope.insert(doc.cursor.offset, &text); 162 + doc.cursor.offset += text.chars().count(); 163 + }); 164 + } 165 + } 166 + } 167 + } 168 + 169 + /// Handle keyboard events and update document state 170 + fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 171 + use dioxus::prelude::keyboard_types::Key; 172 + 173 + let key = evt.key(); 174 + let mods = evt.modifiers(); 175 + 176 + document.with_mut(|doc| { 177 + match key { 178 + Key::Character(ch) => { 179 + // Keyboard shortcuts first 180 + if mods.ctrl() { 181 + match ch.as_str() { 182 + "b" => { 183 + formatting::apply_formatting(doc, FormatAction::Bold); 184 + return; 185 + } 186 + "i" => { 187 + formatting::apply_formatting(doc, FormatAction::Italic); 188 + return; 189 + } 190 + _ => {} 191 + } 192 + } 193 + 194 + // Insert character at cursor 195 + if doc.selection.is_some() { 196 + // Delete selection first 197 + let sel = doc.selection.unwrap(); 198 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 199 + doc.rope.remove(start..end); 200 + doc.cursor.offset = start; 201 + doc.selection = None; 202 + } 203 + 204 + doc.rope.insert(doc.cursor.offset, &ch); 205 + doc.cursor.offset += ch.chars().count(); 206 + } 207 + 208 + Key::Backspace => { 209 + if let Some(sel) = doc.selection { 210 + // Delete selection 211 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 212 + doc.rope.remove(start..end); 213 + doc.cursor.offset = start; 214 + doc.selection = None; 215 + } else if doc.cursor.offset > 0 { 216 + // Delete previous char 217 + let prev = doc.cursor.offset - 1; 218 + doc.rope.remove(prev..doc.cursor.offset); 219 + doc.cursor.offset = prev; 220 + } 221 + } 222 + 223 + Key::Delete => { 224 + if let Some(sel) = doc.selection { 225 + // Delete selection 226 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 227 + doc.rope.remove(start..end); 228 + doc.cursor.offset = start; 229 + doc.selection = None; 230 + } else if doc.cursor.offset < doc.len_chars() { 231 + // Delete next char 232 + doc.rope.remove(doc.cursor.offset..doc.cursor.offset + 1); 233 + } 234 + } 235 + 236 + Key::ArrowLeft => { 237 + if mods.ctrl() { 238 + // Word boundary (implement later) 239 + if doc.cursor.offset > 0 { 240 + doc.cursor.offset -= 1; 241 + } 242 + } else if doc.cursor.offset > 0 { 243 + doc.cursor.offset -= 1; 244 + } 245 + doc.selection = None; 246 + } 247 + 248 + Key::ArrowRight => { 249 + if mods.ctrl() { 250 + // Word boundary (implement later) 251 + if doc.cursor.offset < doc.len_chars() { 252 + doc.cursor.offset += 1; 253 + } 254 + } else if doc.cursor.offset < doc.len_chars() { 255 + doc.cursor.offset += 1; 256 + } 257 + doc.selection = None; 258 + } 259 + 260 + Key::Enter => { 261 + if doc.selection.is_some() { 262 + let sel = doc.selection.unwrap(); 263 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 264 + doc.rope.remove(start..end); 265 + doc.cursor.offset = start; 266 + doc.selection = None; 267 + } 268 + // Insert two spaces + newline for hard line break 269 + doc.rope.insert(doc.cursor.offset, " \n"); 270 + doc.cursor.offset += 3; 271 + } 272 + 273 + Key::Home => { 274 + let line_start = find_line_start(&doc.rope, doc.cursor.offset); 275 + doc.cursor.offset = line_start; 276 + doc.selection = None; 277 + } 278 + 279 + Key::End => { 280 + let line_end = find_line_end(&doc.rope, doc.cursor.offset); 281 + doc.cursor.offset = line_end; 282 + doc.selection = None; 283 + } 284 + 285 + _ => {} 286 + } 287 + }); 288 + } 289 + 290 + /// Find start of line containing offset 291 + fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 292 + // Search backwards from cursor for newline 293 + let mut char_pos = 0; 294 + let mut last_newline_pos = None; 295 + 296 + let rope = rope.borrow(); 297 + for substr in rope.slice_substrings(0..offset) { 298 + // TODO: make more efficient 299 + for c in substr.chars() { 300 + if c == '\n' { 301 + last_newline_pos = Some(char_pos); 302 + } 303 + char_pos += 1; 304 + } 305 + } 306 + 307 + last_newline_pos.map(|pos| pos + 1).unwrap_or(0) 308 + } 309 + 310 + /// Find end of line containing offset 311 + fn find_line_end(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { 312 + // Search forwards from cursor for newline 313 + let mut char_pos = offset; 314 + 315 + let rope = rope.borrow(); 316 + let byte_len = rope.len_bytes() - 1; 317 + for substr in rope.slice_substrings(offset..byte_len) { 318 + // TODO: make more efficient 319 + for c in substr.chars() { 320 + if c == '\n' { 321 + return char_pos; 322 + } 323 + char_pos += 1; 324 + } 325 + } 326 + 327 + rope.len_chars() 328 + }
+275
crates/weaver-app/src/components/editor/offset_map.rs
··· 1 + //! Offset mapping between source text and rendered DOM. 2 + //! 3 + //! When rendering markdown to HTML, some characters disappear (table pipes) 4 + //! and content gets split across nodes (syntax highlighting). Offset maps 5 + //! track how source byte positions map to DOM node positions. 6 + 7 + use std::ops::Range; 8 + 9 + /// Result of rendering markdown with offset tracking. 10 + #[derive(Debug, Clone, PartialEq)] 11 + pub struct RenderResult { 12 + /// Rendered HTML string 13 + pub html: String, 14 + 15 + /// Mappings from source bytes to DOM positions 16 + pub offset_map: Vec<OffsetMapping>, 17 + } 18 + 19 + /// Maps a source range to a position in the rendered DOM. 20 + /// 21 + /// # Example 22 + /// 23 + /// Source: `| foo | bar |` 24 + /// Bytes: 0 2-5 7-10 12 25 + /// Chars: 0 2-5 7-10 12 (ASCII, so same) 26 + /// 27 + /// Rendered: 28 + /// ```html 29 + /// <table id="t0"> 30 + /// <tr><td id="t0-c0">foo</td><td id="t0-c1">bar</td></tr> 31 + /// </table> 32 + /// ``` 33 + /// 34 + /// Mappings: 35 + /// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible 36 + /// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible 37 + /// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible 38 + /// - etc. 39 + #[derive(Debug, Clone, PartialEq)] 40 + pub struct OffsetMapping { 41 + /// Source byte range (UTF-8 bytes, from parser) 42 + pub byte_range: Range<usize>, 43 + 44 + /// Source char range (Unicode scalar values, for rope indexing) 45 + pub char_range: Range<usize>, 46 + 47 + /// DOM node ID containing this content 48 + /// For invisible content, this is the nearest visible container 49 + pub node_id: String, 50 + 51 + /// Position within the node 52 + /// - If child_index is Some: cursor at that child index in the element 53 + /// - If child_index is None: UTF-16 offset in text content 54 + pub char_offset_in_node: usize, 55 + 56 + /// If Some, position cursor at this child index in the element (not in text) 57 + /// Used for positions after <br /> or at empty lines 58 + pub child_index: Option<usize>, 59 + 60 + /// Length of this mapping in UTF-16 chars in DOM 61 + /// If 0, these source bytes aren't rendered (table pipes, etc) 62 + pub utf16_len: usize, 63 + } 64 + 65 + impl OffsetMapping { 66 + /// Check if this mapping contains the given byte offset 67 + pub fn contains_byte(&self, byte_offset: usize) -> bool { 68 + self.byte_range.contains(&byte_offset) 69 + } 70 + 71 + /// Check if this mapping contains the given char offset 72 + pub fn contains_char(&self, char_offset: usize) -> bool { 73 + self.char_range.contains(&char_offset) 74 + } 75 + 76 + /// Check if this mapping represents invisible content 77 + pub fn is_invisible(&self) -> bool { 78 + self.utf16_len == 0 79 + } 80 + } 81 + 82 + /// Find the offset mapping containing the given byte offset. 83 + /// 84 + /// Returns the mapping and whether the cursor should snap to the next 85 + /// visible position (for invisible content). 86 + pub fn find_mapping_for_byte( 87 + offset_map: &[OffsetMapping], 88 + byte_offset: usize, 89 + ) -> Option<(&OffsetMapping, bool)> { 90 + // Binary search for the mapping 91 + // Note: We allow cursor at the end boundary of a mapping (cursor after text) 92 + let idx = offset_map 93 + .binary_search_by(|mapping| { 94 + if mapping.byte_range.end < byte_offset { 95 + std::cmp::Ordering::Less 96 + } else if mapping.byte_range.start > byte_offset { 97 + std::cmp::Ordering::Greater 98 + } else { 99 + std::cmp::Ordering::Equal 100 + } 101 + }) 102 + .ok()?; 103 + 104 + let mapping = &offset_map[idx]; 105 + let should_snap = mapping.is_invisible(); 106 + 107 + Some((mapping, should_snap)) 108 + } 109 + 110 + /// Find the offset mapping containing the given char offset. 111 + /// 112 + /// This is the primary lookup method for cursor restoration, since 113 + /// cursor positions are tracked as char offsets in the rope. 114 + /// 115 + /// Returns the mapping and whether the cursor should snap to the next 116 + /// visible position (for invisible content). 117 + pub fn find_mapping_for_char( 118 + offset_map: &[OffsetMapping], 119 + char_offset: usize, 120 + ) -> Option<(&OffsetMapping, bool)> { 121 + // Binary search for the mapping 122 + // Note: We allow cursor at the end boundary of a mapping (cursor after text) 123 + // This makes ranges END-INCLUSIVE for cursor positioning 124 + let idx = offset_map 125 + .binary_search_by(|mapping| { 126 + if mapping.char_range.end < char_offset { 127 + // Cursor is after this mapping 128 + std::cmp::Ordering::Less 129 + } else if mapping.char_range.start > char_offset { 130 + // Cursor is before this mapping 131 + std::cmp::Ordering::Greater 132 + } else { 133 + // Cursor is within [start, end] OR exactly at end (inclusive) 134 + // This handles cursor at position N matching range N-1..N 135 + std::cmp::Ordering::Equal 136 + } 137 + }) 138 + .ok()?; 139 + 140 + let mapping = &offset_map[idx]; 141 + let should_snap = mapping.is_invisible(); 142 + 143 + Some((mapping, should_snap)) 144 + } 145 + 146 + #[cfg(test)] 147 + mod tests { 148 + use super::*; 149 + 150 + #[test] 151 + fn test_find_mapping_by_byte() { 152 + let mappings = vec![ 153 + OffsetMapping { 154 + byte_range: 0..2, 155 + char_range: 0..2, 156 + node_id: "n0".to_string(), 157 + char_offset_in_node: 0, 158 + child_index: None, 159 + utf16_len: 0, // invisible 160 + }, 161 + OffsetMapping { 162 + byte_range: 2..5, 163 + char_range: 2..5, 164 + node_id: "n0".to_string(), 165 + char_offset_in_node: 0, 166 + child_index: None, 167 + utf16_len: 3, 168 + }, 169 + OffsetMapping { 170 + byte_range: 5..7, 171 + char_range: 5..7, 172 + node_id: "n0".to_string(), 173 + char_offset_in_node: 3, 174 + child_index: None, 175 + utf16_len: 0, // invisible 176 + }, 177 + ]; 178 + 179 + // Byte 0 (invisible) 180 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); 181 + assert_eq!(mapping.byte_range, 0..2); 182 + assert!(should_snap); 183 + 184 + // Byte 3 (visible) 185 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); 186 + assert_eq!(mapping.byte_range, 2..5); 187 + assert!(!should_snap); 188 + 189 + // Byte 6 (invisible) 190 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); 191 + assert_eq!(mapping.byte_range, 5..7); 192 + assert!(should_snap); 193 + } 194 + 195 + #[test] 196 + fn test_find_mapping_by_char() { 197 + let mappings = vec![ 198 + OffsetMapping { 199 + byte_range: 0..2, 200 + char_range: 0..2, 201 + node_id: "n0".to_string(), 202 + char_offset_in_node: 0, 203 + child_index: None, 204 + utf16_len: 0, // invisible 205 + }, 206 + OffsetMapping { 207 + byte_range: 2..5, 208 + char_range: 2..5, 209 + node_id: "n0".to_string(), 210 + char_offset_in_node: 0, 211 + child_index: None, 212 + utf16_len: 3, 213 + }, 214 + OffsetMapping { 215 + byte_range: 5..7, 216 + char_range: 5..7, 217 + node_id: "n0".to_string(), 218 + char_offset_in_node: 3, 219 + child_index: None, 220 + utf16_len: 0, // invisible 221 + }, 222 + ]; 223 + 224 + // Char 0 (invisible) 225 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); 226 + assert_eq!(mapping.char_range, 0..2); 227 + assert!(should_snap); 228 + 229 + // Char 3 (visible) 230 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); 231 + assert_eq!(mapping.char_range, 2..5); 232 + assert!(!should_snap); 233 + 234 + // Char 6 (invisible) 235 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); 236 + assert_eq!(mapping.char_range, 5..7); 237 + assert!(should_snap); 238 + } 239 + 240 + #[test] 241 + fn test_contains_byte() { 242 + let mapping = OffsetMapping { 243 + byte_range: 10..20, 244 + char_range: 10..20, 245 + node_id: "test".to_string(), 246 + char_offset_in_node: 0, 247 + child_index: None, 248 + utf16_len: 5, 249 + }; 250 + 251 + assert!(!mapping.contains_byte(9)); 252 + assert!(mapping.contains_byte(10)); 253 + assert!(mapping.contains_byte(15)); 254 + assert!(mapping.contains_byte(19)); 255 + assert!(!mapping.contains_byte(20)); 256 + } 257 + 258 + #[test] 259 + fn test_contains_char() { 260 + let mapping = OffsetMapping { 261 + byte_range: 10..20, 262 + char_range: 8..15, // emoji example: fewer chars than bytes 263 + node_id: "test".to_string(), 264 + char_offset_in_node: 0, 265 + child_index: None, 266 + utf16_len: 5, 267 + }; 268 + 269 + assert!(!mapping.contains_char(7)); 270 + assert!(mapping.contains_char(8)); 271 + assert!(mapping.contains_char(12)); 272 + assert!(mapping.contains_char(14)); 273 + assert!(!mapping.contains_char(15)); 274 + } 275 + }
+134
crates/weaver-app/src/components/editor/offsets.rs
··· 1 + //! Offset conversion utilities for converting between different offset systems. 2 + //! 3 + //! The editor deals with multiple offset systems: 4 + //! 1. **JumpRope**: Unicode scalar values (Rust `char` count) 5 + //! 2. **markdown-weaver**: UTF-8 byte offsets 6 + //! 3. **Rust strings**: UTF-8 byte indexing 7 + //! 4. **JavaScript DOM**: UTF-16 code units (Phase 2+) 8 + //! 9 + //! # Performance Notes 10 + //! 11 + //! **Prefer JumpRope's built-in methods:** 12 + //! - `rope.len_chars()` - O(1) character count 13 + //! - `rope.len_bytes()` - O(1) byte count 14 + //! - `rope.len_wchars()` - O(1) UTF-16 code unit count (Phase 2 with wchar_conversion) 15 + //! 16 + //! **Only use these conversion functions when:** 17 + //! - Converting markdown-weaver byte offsets to char offsets 18 + //! - Converting char offsets to byte offsets for markdown parsing 19 + //! 20 + //! For Phase 2+, use JumpRope's O(log n) UTF-16 conversions via the helpers below: 21 + //! - `char_to_utf16()` - O(log n) 22 + //! - `utf16_to_char()` - O(log n) 23 + 24 + /// Convert JumpRope char offset to UTF-8 byte offset. 25 + /// 26 + /// This is O(n) but acceptable for Phase 1 since we only render once per keystroke. 27 + /// For Phase 2+, we can optimize by caching or using string-offsets crate. 28 + /// 29 + /// # Example 30 + /// ``` 31 + /// let text = "Hello 🐻‍❄️ World"; 32 + /// // "Hello " = 6 chars, 6 bytes 33 + /// // "🐻‍❄️" = 4 chars, 13 bytes 34 + /// // Total at char 6 = byte 6 35 + /// assert_eq!(char_to_byte(text, 6), 6); 36 + /// // Total at char 10 (after emoji) = byte 19 37 + /// assert_eq!(char_to_byte(text, 10), 19); 38 + /// ``` 39 + pub fn char_to_byte(text: &str, char_offset: usize) -> usize { 40 + text.char_indices() 41 + .nth(char_offset) 42 + .map(|(byte_idx, _)| byte_idx) 43 + .unwrap_or(text.len()) 44 + } 45 + 46 + /// Convert UTF-8 byte offset to JumpRope char offset. 47 + /// 48 + /// Used when we need to map markdown-weaver byte offsets back to rope positions. 49 + /// 50 + /// # Example 51 + /// ``` 52 + /// let text = "Hello 🐻‍❄️ World"; 53 + /// assert_eq!(byte_to_char(text, 6), 6); 54 + /// assert_eq!(byte_to_char(text, 19), 10); 55 + /// ``` 56 + pub fn byte_to_char(text: &str, byte_offset: usize) -> usize { 57 + text.char_indices() 58 + .take_while(|(idx, _)| *idx < byte_offset) 59 + .count() 60 + } 61 + 62 + /// Convert JumpRope char offset to UTF-16 code units (for DOM Selection API). 63 + /// 64 + /// O(log n) - uses JumpRope's internal index. 65 + /// 66 + /// # Example 67 + /// ``` 68 + /// let rope = JumpRopeBuf::from("🐻‍❄️"); 69 + /// // Polar bear is 4 chars, 5 UTF-16 code units 70 + /// assert_eq!(char_to_utf16(&rope, 0), 0); 71 + /// assert_eq!(char_to_utf16(&rope, 4), 5); 72 + /// ``` 73 + pub fn char_to_utf16(rope: &jumprope::JumpRopeBuf, char_offset: usize) -> usize { 74 + rope.borrow().chars_to_wchars(char_offset) 75 + } 76 + 77 + /// Convert UTF-16 code units (from DOM) to JumpRope char offset. 78 + /// 79 + /// O(log n) - uses JumpRope's internal index. 80 + /// 81 + /// # Example 82 + /// ``` 83 + /// let rope = JumpRopeBuf::from("🐻‍❄️"); 84 + /// assert_eq!(utf16_to_char(&rope, 0), 0); 85 + /// assert_eq!(utf16_to_char(&rope, 5), 4); 86 + /// ``` 87 + pub fn utf16_to_char(rope: &jumprope::JumpRopeBuf, utf16_offset: usize) -> usize { 88 + rope.borrow().wchars_to_chars(utf16_offset) 89 + } 90 + 91 + #[cfg(test)] 92 + mod tests { 93 + use super::*; 94 + 95 + #[test] 96 + fn test_ascii() { 97 + let text = "hello"; 98 + assert_eq!(char_to_byte(text, 0), 0); 99 + assert_eq!(char_to_byte(text, 2), 2); 100 + assert_eq!(byte_to_char(text, 0), 0); 101 + assert_eq!(byte_to_char(text, 2), 2); 102 + } 103 + 104 + #[test] 105 + fn test_emoji() { 106 + // Polar bear: 4 chars, 13 bytes 107 + let text = "🐻‍❄️"; 108 + assert_eq!(text.chars().count(), 4); 109 + assert_eq!(text.len(), 13); 110 + 111 + assert_eq!(char_to_byte(text, 0), 0); 112 + assert_eq!(char_to_byte(text, 4), 13); 113 + 114 + assert_eq!(byte_to_char(text, 0), 0); 115 + assert_eq!(byte_to_char(text, 13), 4); 116 + } 117 + 118 + #[test] 119 + fn test_mixed() { 120 + let text = "Hello 🐻‍❄️ World"; 121 + // "Hello " = 6 chars, 6 bytes 122 + // "🐻‍❄️" = 4 chars, 13 bytes 123 + // " World" = 6 chars, 6 bytes 124 + // Total: 16 chars, 25 bytes 125 + 126 + assert_eq!(text.chars().count(), 16); 127 + assert_eq!(text.len(), 25); 128 + 129 + // Char 6 is start of emoji (byte 6) 130 + assert_eq!(char_to_byte(text, 6), 6); 131 + // Char 10 is after emoji (byte 19) 132 + assert_eq!(char_to_byte(text, 10), 19); 133 + } 134 + }
+51
crates/weaver-app/src/components/editor/render.rs
··· 1 + //! Markdown rendering for the editor. 2 + //! 3 + //! Phase 2: Full-document rendering with formatting characters visible as styled spans. 4 + //! Future: Incremental paragraph rendering and contextual formatting visibility. 5 + //! 6 + //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 7 + 8 + use markdown_weaver::Parser; 9 + use super::offset_map::RenderResult; 10 + use super::writer::EditorWriter; 11 + 12 + /// Render markdown to HTML with visible formatting characters and offset mappings. 13 + /// 14 + /// This function performs a full re-render of the document on every change. 15 + /// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility. 16 + /// 17 + /// Uses EditorWriter which processes offset_iter events to detect consumed 18 + /// formatting characters and emit them as `<span class="md-syntax-*">` elements. 19 + /// 20 + /// Returns both the rendered HTML and offset mappings for cursor restoration. 21 + /// 22 + /// # Phase 2 features 23 + /// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block) 24 + /// - Offset map generation for cursor restoration 25 + /// - Full document re-render (fast enough for current needs) 26 + /// 27 + /// # Future improvements 28 + /// - Paragraph-level incremental rendering 29 + /// - Contextual formatting hiding based on cursor position 30 + pub fn render_markdown_simple(source: &str) -> RenderResult { 31 + use jumprope::JumpRopeBuf; 32 + 33 + let source_rope = JumpRopeBuf::from(source); 34 + let parser = Parser::new_ext(source, weaver_renderer::default_md_options()) 35 + .into_offset_iter(); 36 + let mut output = String::new(); 37 + 38 + match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() { 39 + Ok(offset_map) => RenderResult { 40 + html: output, 41 + offset_map, 42 + }, 43 + Err(_) => { 44 + // Fallback to empty result on error 45 + RenderResult { 46 + html: String::new(), 47 + offset_map: Vec::new(), 48 + } 49 + } 50 + } 51 + }
+81
crates/weaver-app/src/components/editor/rope_writer.rs
··· 1 + //! StrWrite wrapper for JumpRopeBuf to enable efficient HTML rendering. 2 + 3 + use jumprope::JumpRopeBuf; 4 + use markdown_weaver_escape::StrWrite; 5 + 6 + /// Wrapper around JumpRopeBuf that implements StrWrite. 7 + /// 8 + /// This allows rendering HTML directly into a rope structure, enabling: 9 + /// - O(log n) insertions instead of O(n) string reallocation 10 + /// - Efficient splicing for incremental rendering 11 + /// - Fast paragraph replacement in cached output 12 + pub struct RopeWriter { 13 + rope: JumpRopeBuf, 14 + } 15 + 16 + impl RopeWriter { 17 + pub fn new() -> Self { 18 + Self { 19 + rope: JumpRopeBuf::new(), 20 + } 21 + } 22 + 23 + pub fn from_rope(rope: JumpRopeBuf) -> Self { 24 + Self { rope } 25 + } 26 + 27 + pub fn into_rope(self) -> JumpRopeBuf { 28 + self.rope 29 + } 30 + 31 + pub fn as_rope(&self) -> &JumpRopeBuf { 32 + &self.rope 33 + } 34 + 35 + pub fn to_string(&self) -> String { 36 + self.rope.to_string() 37 + } 38 + } 39 + 40 + impl Default for RopeWriter { 41 + fn default() -> Self { 42 + Self::new() 43 + } 44 + } 45 + 46 + impl StrWrite for RopeWriter { 47 + type Error = std::convert::Infallible; 48 + 49 + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { 50 + let offset = self.rope.len_chars(); 51 + self.rope.insert(offset, s); 52 + Ok(()) 53 + } 54 + 55 + fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> Result<(), Self::Error> { 56 + let mut temp = String::new(); 57 + std::fmt::Write::write_fmt(&mut temp, args).unwrap(); 58 + self.write_str(&temp) 59 + } 60 + } 61 + 62 + #[cfg(test)] 63 + mod tests { 64 + use super::*; 65 + 66 + #[test] 67 + fn test_rope_writer_basic() { 68 + let mut writer = RopeWriter::new(); 69 + writer.write_str("hello ").unwrap(); 70 + writer.write_str("world").unwrap(); 71 + assert_eq!(writer.to_string(), "hello world"); 72 + } 73 + 74 + #[test] 75 + fn test_rope_writer_fmt() { 76 + use std::fmt::Write; 77 + let mut writer = RopeWriter::new(); 78 + write!(&mut writer, "number: {}", 42).unwrap(); 79 + assert_eq!(writer.to_string(), "number: 42"); 80 + } 81 + }
+58
crates/weaver-app/src/components/editor/storage.rs
··· 1 + //! LocalStorage persistence for the editor. 2 + //! 3 + //! Only available on WASM targets. 4 + 5 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 6 + use gloo_storage::{LocalStorage, Storage}; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// Editor snapshot for persistence. 10 + #[derive(Serialize, Deserialize, Clone, Debug)] 11 + pub struct EditorSnapshot { 12 + pub content: String, 13 + pub cursor_offset: usize, 14 + } 15 + 16 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 17 + const STORAGE_KEY: &str = "weaver_editor_draft"; 18 + 19 + /// Save editor state to LocalStorage (WASM only). 20 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 + pub fn save_to_storage( 22 + content: &str, 23 + cursor_offset: usize, 24 + ) -> Result<(), gloo_storage::errors::StorageError> { 25 + let snapshot = EditorSnapshot { 26 + content: content.to_string(), 27 + cursor_offset, 28 + }; 29 + LocalStorage::set(STORAGE_KEY, &snapshot) 30 + } 31 + 32 + /// Load editor state from LocalStorage (WASM only). 33 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 34 + pub fn load_from_storage() -> Option<EditorSnapshot> { 35 + LocalStorage::get(STORAGE_KEY).ok() 36 + } 37 + 38 + /// Clear editor state from LocalStorage (WASM only). 39 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 40 + #[allow(dead_code)] 41 + pub fn clear_storage() { 42 + LocalStorage::delete(STORAGE_KEY); 43 + } 44 + 45 + // Stub implementations for non-WASM targets 46 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 47 + pub fn save_to_storage(_content: &str, _cursor_offset: usize) -> Result<(), String> { 48 + Ok(()) 49 + } 50 + 51 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 52 + pub fn load_from_storage() -> Option<EditorSnapshot> { 53 + None 54 + } 55 + 56 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 57 + #[allow(dead_code)] 58 + pub fn clear_storage() {}
+96
crates/weaver-app/src/components/editor/toolbar.rs
··· 1 + //! Editor toolbar component with formatting buttons. 2 + 3 + use super::formatting::FormatAction; 4 + use dioxus::prelude::*; 5 + 6 + /// Editor toolbar with formatting buttons. 7 + /// 8 + /// Provides buttons for common markdown formatting operations. 9 + #[component] 10 + pub fn EditorToolbar(on_format: EventHandler<FormatAction>) -> Element { 11 + rsx! { 12 + div { class: "editor-toolbar", 13 + button { 14 + class: "toolbar-button", 15 + title: "Bold (Ctrl+B)", 16 + onclick: move |_| on_format.call(FormatAction::Bold), 17 + "B" 18 + } 19 + button { 20 + class: "toolbar-button", 21 + title: "Italic (Ctrl+I)", 22 + onclick: move |_| on_format.call(FormatAction::Italic), 23 + "I" 24 + } 25 + button { 26 + class: "toolbar-button", 27 + title: "Strikethrough", 28 + onclick: move |_| on_format.call(FormatAction::Strikethrough), 29 + "S" 30 + } 31 + button { 32 + class: "toolbar-button", 33 + title: "Code", 34 + onclick: move |_| on_format.call(FormatAction::Code), 35 + "<>" 36 + } 37 + 38 + span { class: "toolbar-separator" } 39 + 40 + button { 41 + class: "toolbar-button", 42 + title: "Heading 1", 43 + onclick: move |_| on_format.call(FormatAction::Heading(1)), 44 + "H1" 45 + } 46 + button { 47 + class: "toolbar-button", 48 + title: "Heading 2", 49 + onclick: move |_| on_format.call(FormatAction::Heading(2)), 50 + "H2" 51 + } 52 + button { 53 + class: "toolbar-button", 54 + title: "Heading 3", 55 + onclick: move |_| on_format.call(FormatAction::Heading(3)), 56 + "H3" 57 + } 58 + 59 + span { class: "toolbar-separator" } 60 + 61 + button { 62 + class: "toolbar-button", 63 + title: "Bullet List", 64 + onclick: move |_| on_format.call(FormatAction::BulletList), 65 + "•" 66 + } 67 + button { 68 + class: "toolbar-button", 69 + title: "Numbered List", 70 + onclick: move |_| on_format.call(FormatAction::NumberedList), 71 + "1." 72 + } 73 + button { 74 + class: "toolbar-button", 75 + title: "Quote", 76 + onclick: move |_| on_format.call(FormatAction::Quote), 77 + "❝" 78 + } 79 + 80 + span { class: "toolbar-separator" } 81 + 82 + button { 83 + class: "toolbar-button", 84 + title: "Link", 85 + onclick: move |_| on_format.call(FormatAction::Link), 86 + "🔗" 87 + } 88 + button { 89 + class: "toolbar-button", 90 + title: "Image", 91 + onclick: move |_| on_format.call(FormatAction::Image), 92 + "🖼" 93 + } 94 + } 95 + } 96 + }
+1495
crates/weaver-app/src/components/editor/writer.rs
··· 1 + //! HTML writer for markdown editor with visible formatting characters. 2 + //! 3 + //! Based on ClientWriter from weaver-renderer, but modified to preserve 4 + //! formatting characters (**, *, #, etc) wrapped in styled spans. 5 + //! 6 + //! Uses Parser::into_offset_iter() to track gaps between events, which 7 + //! represent consumed formatting characters. 8 + 9 + use super::offset_map::{OffsetMapping, RenderResult}; 10 + use super::offsets::{byte_to_char, char_to_byte}; 11 + use jumprope::JumpRopeBuf; 12 + use markdown_weaver::{ 13 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 14 + }; 15 + use markdown_weaver_escape::{ 16 + StrWrite, escape_href, escape_html, escape_html_body_text, 17 + escape_html_body_text_with_char_count, 18 + }; 19 + use std::collections::HashMap; 20 + use std::ops::Range; 21 + 22 + /// Classification of markdown syntax characters 23 + #[derive(Debug, Clone, Copy, PartialEq)] 24 + enum SyntaxClass { 25 + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 26 + Inline, 27 + /// Block formatting: #, >, -, *, 1., ```, --- 28 + Block, 29 + } 30 + 31 + /// Classify syntax text as inline or block level 32 + fn classify_syntax(text: &str) -> SyntaxClass { 33 + let trimmed = text.trim_start(); 34 + 35 + // Check for block-level markers 36 + if trimmed.starts_with('#') 37 + || trimmed.starts_with('>') 38 + || trimmed.starts_with("```") 39 + || trimmed.starts_with("---") 40 + || (trimmed.starts_with('-') 41 + && trimmed 42 + .chars() 43 + .nth(1) 44 + .map(|c| c.is_whitespace()) 45 + .unwrap_or(false)) 46 + || (trimmed.starts_with('*') 47 + && trimmed 48 + .chars() 49 + .nth(1) 50 + .map(|c| c.is_whitespace()) 51 + .unwrap_or(false)) 52 + || trimmed 53 + .chars() 54 + .next() 55 + .map(|c| c.is_ascii_digit()) 56 + .unwrap_or(false) 57 + && trimmed.contains('.') 58 + { 59 + SyntaxClass::Block 60 + } else { 61 + SyntaxClass::Inline 62 + } 63 + } 64 + 65 + /// Synchronous callback for injecting embed content 66 + /// 67 + /// Takes the embed tag and returns optional HTML content to inject. 68 + pub trait EmbedContentProvider { 69 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 70 + } 71 + 72 + impl EmbedContentProvider for () { 73 + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 74 + None 75 + } 76 + } 77 + 78 + /// HTML writer that preserves markdown formatting characters. 79 + /// 80 + /// This writer processes offset-iter events to detect gaps (consumed formatting) 81 + /// and emits them as styled spans for visibility in the editor. 82 + pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> { 83 + source: &'a str, 84 + source_rope: &'a JumpRopeBuf, 85 + events: I, 86 + writer: W, 87 + last_byte_offset: usize, 88 + last_char_offset: usize, 89 + 90 + end_newline: bool, 91 + in_non_writing_block: bool, 92 + 93 + table_state: TableState, 94 + table_alignments: Vec<Alignment>, 95 + table_cell_index: usize, 96 + 97 + numbers: HashMap<String, usize>, 98 + 99 + embed_provider: Option<E>, 100 + 101 + code_buffer: Option<(Option<String>, String)>, // (lang, content) 102 + code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content 103 + pending_blockquote_range: Option<Range<usize>>, // range for emitting > inside next paragraph 104 + 105 + // Table rendering mode 106 + render_tables_as_markdown: bool, 107 + table_start_offset: Option<usize>, // track start of table for markdown rendering 108 + 109 + // Offset mapping tracking 110 + offset_maps: Vec<OffsetMapping>, 111 + next_node_id: usize, 112 + current_node_id: Option<String>, // node ID for current text container 113 + current_node_char_offset: usize, // UTF-16 offset within current node 114 + current_node_child_count: usize, // number of child elements/text nodes in current container 115 + 116 + _phantom: std::marker::PhantomData<&'a ()>, 117 + } 118 + 119 + #[derive(Debug, Clone, Copy)] 120 + enum TableState { 121 + Head, 122 + Body, 123 + } 124 + 125 + impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider> 126 + EditorWriter<'a, I, W, E> 127 + { 128 + pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self { 129 + Self { 130 + source, 131 + source_rope, 132 + events, 133 + writer, 134 + last_byte_offset: 0, 135 + last_char_offset: 0, 136 + end_newline: true, 137 + in_non_writing_block: false, 138 + table_state: TableState::Head, 139 + table_alignments: vec![], 140 + table_cell_index: 0, 141 + numbers: HashMap::new(), 142 + embed_provider: None, 143 + code_buffer: None, 144 + code_buffer_byte_range: None, 145 + pending_blockquote_range: None, 146 + render_tables_as_markdown: true, // Default to markdown rendering 147 + table_start_offset: None, 148 + offset_maps: Vec::new(), 149 + next_node_id: 0, 150 + current_node_id: None, 151 + current_node_char_offset: 0, 152 + current_node_child_count: 0, 153 + _phantom: std::marker::PhantomData, 154 + } 155 + } 156 + 157 + /// Add an embed content provider 158 + pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { 159 + EditorWriter { 160 + source: self.source, 161 + source_rope: self.source_rope, 162 + events: self.events, 163 + writer: self.writer, 164 + last_byte_offset: self.last_byte_offset, 165 + last_char_offset: self.last_char_offset, 166 + end_newline: self.end_newline, 167 + in_non_writing_block: self.in_non_writing_block, 168 + table_state: self.table_state, 169 + table_alignments: self.table_alignments, 170 + table_cell_index: self.table_cell_index, 171 + numbers: self.numbers, 172 + embed_provider: Some(provider), 173 + code_buffer: self.code_buffer, 174 + code_buffer_byte_range: self.code_buffer_byte_range, 175 + pending_blockquote_range: self.pending_blockquote_range, 176 + render_tables_as_markdown: self.render_tables_as_markdown, 177 + table_start_offset: self.table_start_offset, 178 + offset_maps: self.offset_maps, 179 + next_node_id: self.next_node_id, 180 + current_node_id: self.current_node_id, 181 + current_node_char_offset: self.current_node_char_offset, 182 + current_node_child_count: self.current_node_child_count, 183 + _phantom: std::marker::PhantomData, 184 + } 185 + } 186 + #[inline] 187 + fn write_newline(&mut self) -> Result<(), W::Error> { 188 + self.end_newline = true; 189 + self.writer.write_str("\n") 190 + } 191 + 192 + #[inline] 193 + fn write(&mut self, s: &str) -> Result<(), W::Error> { 194 + self.writer.write_str(s)?; 195 + if !s.is_empty() { 196 + self.end_newline = s.ends_with('\n'); 197 + } 198 + Ok(()) 199 + } 200 + 201 + /// Emit syntax span for a given range and record offset mapping 202 + fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), W::Error> { 203 + if range.start < range.end { 204 + let syntax = &self.source[range.clone()]; 205 + if !syntax.is_empty() { 206 + let class = match classify_syntax(syntax) { 207 + SyntaxClass::Inline => "md-syntax-inline", 208 + SyntaxClass::Block => "md-syntax-block", 209 + }; 210 + 211 + let char_start = self.last_char_offset; 212 + let syntax_char_len = syntax.chars().count(); 213 + 214 + tracing::debug!( 215 + "emit_syntax: range={:?}, chars={}..{}, syntax={:?}", 216 + range, 217 + char_start, 218 + char_start + syntax_char_len, 219 + syntax 220 + ); 221 + 222 + // If we're outside any node, create a wrapper span for tracking 223 + let created_node = if self.current_node_id.is_none() { 224 + let node_id = self.gen_node_id(); 225 + write!( 226 + &mut self.writer, 227 + "<span id=\"{}\" class=\"{}\">", 228 + node_id, class 229 + )?; 230 + self.begin_node(node_id); 231 + true 232 + } else { 233 + self.write("<span class=\"")?; 234 + self.write(class)?; 235 + self.write("\">")?; 236 + false 237 + }; 238 + 239 + escape_html(&mut self.writer, syntax)?; 240 + self.write("</span>")?; 241 + 242 + // Record offset mapping for this syntax 243 + self.record_mapping(range.clone(), char_start..char_start + syntax_char_len); 244 + self.last_char_offset = char_start + syntax_char_len; 245 + self.last_byte_offset = range.end; // Mark bytes as processed 246 + 247 + // Close wrapper if we created one 248 + if created_node { 249 + self.write("</span>")?; 250 + self.end_node(); 251 + } 252 + } 253 + } 254 + Ok(()) 255 + } 256 + 257 + /// Emit any gap between last position and next offset 258 + fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), W::Error> { 259 + // Skip gap emission if we're inside a table being rendered as markdown 260 + if self.table_start_offset.is_some() && self.render_tables_as_markdown { 261 + return Ok(()); 262 + } 263 + 264 + if next_offset > self.last_byte_offset { 265 + self.emit_syntax(self.last_byte_offset..next_offset)?; 266 + } 267 + Ok(()) 268 + } 269 + 270 + /// Generate a unique node ID 271 + fn gen_node_id(&mut self) -> String { 272 + let id = format!("n{}", self.next_node_id); 273 + self.next_node_id += 1; 274 + id 275 + } 276 + 277 + /// Start tracking a new text container node 278 + fn begin_node(&mut self, node_id: String) { 279 + self.current_node_id = Some(node_id); 280 + self.current_node_char_offset = 0; 281 + self.current_node_child_count = 0; 282 + } 283 + 284 + /// Stop tracking current node 285 + fn end_node(&mut self) { 286 + self.current_node_id = None; 287 + self.current_node_char_offset = 0; 288 + self.current_node_child_count = 0; 289 + } 290 + 291 + /// Record an offset mapping for the given byte and char ranges. 292 + /// 293 + /// Computes UTF-16 length efficiently using the rope's internal indexing. 294 + fn record_mapping(&mut self, byte_range: Range<usize>, char_range: Range<usize>) { 295 + if let Some(ref node_id) = self.current_node_id { 296 + // Use rope to convert char offsets to UTF-16 (wchar) offsets - O(log n) 297 + let rope = self.source_rope.borrow(); 298 + let wchar_start = rope.chars_to_wchars(char_range.start); 299 + let wchar_end = rope.chars_to_wchars(char_range.end); 300 + let utf16_len = wchar_end - wchar_start; 301 + 302 + let mapping = OffsetMapping { 303 + byte_range, 304 + char_range, 305 + node_id: node_id.clone(), 306 + char_offset_in_node: self.current_node_char_offset, 307 + child_index: None, // text-based position 308 + utf16_len, 309 + }; 310 + self.offset_maps.push(mapping); 311 + self.current_node_char_offset += utf16_len; 312 + } 313 + } 314 + 315 + /// Process markdown events and write HTML. 316 + /// 317 + /// Returns the offset mappings. The HTML is written to the writer 318 + /// passed in the constructor. 319 + pub fn run(mut self) -> Result<Vec<OffsetMapping>, W::Error> { 320 + while let Some((event, range)) = self.events.next() { 321 + // For End events, emit any trailing content within the event's range 322 + // BEFORE calling end_tag (which calls end_node and clears current_node_id) 323 + if matches!(&event, Event::End(_)) { 324 + // Emit gap from last_byte_offset to range.end 325 + // (emit_syntax handles char offset tracking) 326 + self.emit_gap_before(range.end)?; 327 + } else { 328 + // For other events, emit any gap before range.start 329 + // (emit_syntax handles char offset tracking) 330 + self.emit_gap_before(range.start)?; 331 + } 332 + 333 + // Process the event (passing range for tag syntax) 334 + self.process_event(event, range.clone())?; 335 + 336 + // Update tracking 337 + self.last_byte_offset = range.end; 338 + } 339 + 340 + // Emit any trailing syntax 341 + self.emit_gap_before(self.source.len())?; 342 + 343 + // Handle unmapped trailing content (stripped by parser) 344 + // This includes trailing spaces that markdown ignores 345 + let doc_byte_len = self.source.len(); 346 + let doc_char_len = self.source_rope.len_chars(); 347 + 348 + if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { 349 + tracing::debug!( 350 + "Unmapped trailing content: bytes {}..{}, chars {}..{}", 351 + self.last_byte_offset, 352 + doc_byte_len, 353 + self.last_char_offset, 354 + doc_char_len 355 + ); 356 + 357 + // Emit the trailing content as visible syntax 358 + if self.last_byte_offset < doc_byte_len { 359 + let trailing = &self.source[self.last_byte_offset..]; 360 + if !trailing.is_empty() { 361 + let char_start = self.last_char_offset; 362 + let trailing_char_len = trailing.chars().count(); 363 + 364 + self.write("<span class=\"md-syntax-inline\">")?; 365 + escape_html(&mut self.writer, trailing)?; 366 + self.write("</span>")?; 367 + 368 + // Record mapping if we have a node 369 + if let Some(ref node_id) = self.current_node_id { 370 + let mapping = OffsetMapping { 371 + byte_range: self.last_byte_offset..doc_byte_len, 372 + char_range: char_start..char_start + trailing_char_len, 373 + node_id: node_id.clone(), 374 + char_offset_in_node: self.current_node_char_offset, 375 + child_index: None, 376 + utf16_len: trailing_char_len, // visible 377 + }; 378 + self.offset_maps.push(mapping); 379 + self.current_node_char_offset += trailing_char_len; 380 + } 381 + 382 + self.last_char_offset = char_start + trailing_char_len; 383 + } 384 + } 385 + } 386 + 387 + Ok(self.offset_maps) 388 + } 389 + 390 + // Consume raw text events until end tag, for alt attributes 391 + fn raw_text(&mut self) -> Result<(), W::Error> { 392 + use Event::*; 393 + let mut nest = 0; 394 + while let Some((event, _range)) = self.events.next() { 395 + match event { 396 + Start(_) => nest += 1, 397 + End(_) => { 398 + if nest == 0 { 399 + break; 400 + } 401 + nest -= 1; 402 + } 403 + Html(_) => {} 404 + InlineHtml(text) | Code(text) | Text(text) => { 405 + // Don't use escape_html_body_text here. 406 + // The output of this function is used in the `alt` attribute. 407 + escape_html(&mut self.writer, &text)?; 408 + self.end_newline = text.ends_with('\n'); 409 + } 410 + InlineMath(text) => { 411 + self.write("$")?; 412 + escape_html(&mut self.writer, &text)?; 413 + self.write("$")?; 414 + } 415 + DisplayMath(text) => { 416 + self.write("$$")?; 417 + escape_html(&mut self.writer, &text)?; 418 + self.write("$$")?; 419 + } 420 + SoftBreak | HardBreak | Rule => { 421 + self.write(" ")?; 422 + } 423 + FootnoteReference(name) => { 424 + let len = self.numbers.len() + 1; 425 + let number = *self.numbers.entry(name.into_string()).or_insert(len); 426 + write!(&mut self.writer, "[{}]", number)?; 427 + } 428 + TaskListMarker(true) => self.write("[x]")?, 429 + TaskListMarker(false) => self.write("[ ]")?, 430 + WeaverBlock(_) => {} 431 + } 432 + } 433 + Ok(()) 434 + } 435 + 436 + fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), W::Error> { 437 + use Event::*; 438 + 439 + tracing::debug!( 440 + "Event: {:?}, range: {:?}", 441 + match &event { 442 + Start(tag) => format!("Start({:?})", tag), 443 + End(tag) => format!("End({:?})", tag), 444 + Text(t) => format!("Text({:?})", &t[..t.len().min(20)]), 445 + _ => format!("{:?}", event), 446 + }, 447 + range 448 + ); 449 + match event { 450 + Start(tag) => self.start_tag(tag, range)?, 451 + End(tag) => self.end_tag(tag, range)?, 452 + Text(text) => { 453 + // If buffering code, append to buffer instead of writing 454 + if let Some((_, ref mut buffer)) = self.code_buffer { 455 + buffer.push_str(&text); 456 + 457 + // Track byte range for code block content 458 + if let Some(ref mut code_range) = self.code_buffer_byte_range { 459 + // Extend existing range 460 + code_range.end = range.end; 461 + } else { 462 + // First text in code block - start tracking 463 + self.code_buffer_byte_range = Some(range.clone()); 464 + } 465 + } else if !self.in_non_writing_block { 466 + // Escape HTML and count chars in one pass 467 + let char_start = self.last_char_offset; 468 + let text_char_len = 469 + escape_html_body_text_with_char_count(&mut self.writer, &text)?; 470 + let char_end = char_start + text_char_len; 471 + 472 + tracing::debug!( 473 + "Text event: range={:?}, chars={}..{}, text={:?}", 474 + range, 475 + char_start, 476 + char_end, 477 + &text[..text.len().min(40)] 478 + ); 479 + 480 + // Text becomes a text node child of the current container 481 + if text_char_len > 0 { 482 + self.current_node_child_count += 1; 483 + } 484 + 485 + // Record offset mapping 486 + self.record_mapping(range.clone(), char_start..char_end); 487 + 488 + // Update char offset tracking 489 + self.last_char_offset = char_end; 490 + self.end_newline = text.ends_with('\n'); 491 + } 492 + } 493 + Code(text) => { 494 + // Emit opening backtick 495 + if range.start < range.end { 496 + let raw_text = &self.source[range.clone()]; 497 + if raw_text.starts_with('`') { 498 + self.write("<span class=\"md-syntax-inline\">`</span>")?; 499 + } 500 + } 501 + 502 + self.write("<code>")?; 503 + 504 + // Track offset mapping for code content 505 + let char_start = self.last_char_offset; 506 + let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; 507 + let char_end = char_start + text_char_len; 508 + 509 + // Record offset mapping (code content is visible) 510 + self.record_mapping(range.clone(), char_start..char_end); 511 + self.last_char_offset = char_end; 512 + 513 + self.write("</code>")?; 514 + 515 + // Emit closing backtick 516 + if range.start < range.end { 517 + let raw_text = &self.source[range]; 518 + if raw_text.ends_with('`') { 519 + self.write("<span class=\"md-syntax-inline\">`</span>")?; 520 + } 521 + } 522 + } 523 + InlineMath(text) => { 524 + // Emit opening $ 525 + if range.start < range.end { 526 + let raw_text = &self.source[range.clone()]; 527 + if raw_text.starts_with('$') { 528 + self.write("<span class=\"md-syntax-inline\">$</span>")?; 529 + } 530 + } 531 + 532 + self.write(r#"<span class="math math-inline">"#)?; 533 + escape_html(&mut self.writer, &text)?; 534 + self.write("</span>")?; 535 + 536 + // Emit closing $ 537 + if range.start < range.end { 538 + let raw_text = &self.source[range]; 539 + if raw_text.ends_with('$') { 540 + self.write("<span class=\"md-syntax-inline\">$</span>")?; 541 + } 542 + } 543 + } 544 + DisplayMath(text) => { 545 + // Emit opening $$ 546 + if range.start < range.end { 547 + let raw_text = &self.source[range.clone()]; 548 + if raw_text.starts_with("$$") { 549 + self.write("<span class=\"md-syntax-inline\">$$</span>")?; 550 + } 551 + } 552 + 553 + self.write(r#"<span class="math math-display">"#)?; 554 + escape_html(&mut self.writer, &text)?; 555 + self.write("</span>")?; 556 + 557 + // Emit closing $$ 558 + if range.start < range.end { 559 + let raw_text = &self.source[range]; 560 + if raw_text.ends_with("$$") { 561 + self.write("<span class=\"md-syntax-inline\">$$</span>")?; 562 + } 563 + } 564 + } 565 + Html(html) | InlineHtml(html) => { 566 + // Track offset mapping for raw HTML 567 + let char_start = self.last_char_offset; 568 + let html_char_len = html.chars().count(); 569 + let char_end = char_start + html_char_len; 570 + 571 + self.write(&html)?; 572 + 573 + // Record mapping for inline HTML 574 + self.record_mapping(range.clone(), char_start..char_end); 575 + self.last_char_offset = char_end; 576 + } 577 + SoftBreak => self.write_newline()?, 578 + HardBreak => { 579 + // Emit the two spaces as visible (dimmed) text, then <br> 580 + let gap = &self.source[range.clone()]; 581 + if gap.ends_with('\n') { 582 + let spaces = &gap[..gap.len() - 1]; // everything except the \n 583 + let char_start = byte_to_char(self.source, range.start); 584 + let spaces_char_len = spaces.chars().count(); 585 + 586 + // Emit and map the visible spaces 587 + self.write("<span class=\"md-syntax-inline\">")?; 588 + escape_html(&mut self.writer, spaces)?; 589 + self.write("</span>")?; 590 + 591 + // Count this span as a child 592 + self.current_node_child_count += 1; 593 + 594 + self.record_mapping( 595 + range.start..range.start + spaces.len(), 596 + char_start..char_start + spaces_char_len, 597 + ); 598 + 599 + // Now the actual line break <br> 600 + self.write("<br />")?; 601 + 602 + // Count the <br> as a child 603 + self.current_node_child_count += 1; 604 + 605 + // Map the newline to an element-based position (after the <br>) 606 + // The binary search is end-inclusive, so cursor at position N+1 607 + // will match a mapping with range N..N+1 608 + if let Some(ref node_id) = self.current_node_id { 609 + let newline_char_offset = char_start + spaces_char_len; 610 + let mapping = OffsetMapping { 611 + byte_range: range.start + spaces.len()..range.end, 612 + char_range: newline_char_offset..newline_char_offset + 1, 613 + node_id: node_id.clone(), 614 + char_offset_in_node: 0, 615 + child_index: Some(self.current_node_child_count), 616 + utf16_len: 0, 617 + }; 618 + self.offset_maps.push(mapping); 619 + } 620 + 621 + self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n 622 + } else { 623 + // Fallback: just <br> 624 + self.write("<br />")?; 625 + } 626 + } 627 + Rule => { 628 + if !self.end_newline { 629 + self.write("\n")?; 630 + } 631 + 632 + // Emit syntax span before the rendered element 633 + if range.start < range.end { 634 + let raw_text = &self.source[range]; 635 + let trimmed = raw_text.trim(); 636 + if !trimmed.is_empty() { 637 + self.write("<span class=\"md-syntax-block\">")?; 638 + escape_html(&mut self.writer, trimmed)?; 639 + self.write("</span>\n")?; 640 + } 641 + } 642 + 643 + // Wrap <hr /> in toggle-block for future cursor-based toggling 644 + self.write("<div class=\"toggle-block\"><hr /></div>\n")?; 645 + } 646 + FootnoteReference(name) => { 647 + let len = self.numbers.len() + 1; 648 + self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 649 + escape_html(&mut self.writer, &name)?; 650 + self.write("\">")?; 651 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 652 + write!(&mut self.writer, "{}", number)?; 653 + self.write("</a></sup>")?; 654 + } 655 + TaskListMarker(checked) => { 656 + // Emit the [ ] or [x] syntax 657 + if range.start < range.end { 658 + let raw_text = &self.source[range]; 659 + if let Some(bracket_pos) = raw_text.find('[') { 660 + let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); 661 + let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; 662 + self.write("<span class=\"md-syntax-inline\">")?; 663 + escape_html(&mut self.writer, syntax)?; 664 + self.write("</span> ")?; 665 + } 666 + } 667 + 668 + if checked { 669 + self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; 670 + } else { 671 + self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 672 + } 673 + } 674 + WeaverBlock(_) => {} 675 + } 676 + Ok(()) 677 + } 678 + 679 + fn start_tag(&mut self, tag: Tag<'_>, range: Range<usize>) -> Result<(), W::Error> { 680 + // Check if this is a block-level tag that should have syntax inside 681 + let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); 682 + 683 + // For inline tags, emit syntax before tag 684 + if !is_block_tag && range.start < range.end { 685 + let raw_text = &self.source[range.clone()]; 686 + let opening_syntax = match &tag { 687 + Tag::Strong => { 688 + if raw_text.starts_with("**") { 689 + Some("**") 690 + } else if raw_text.starts_with("__") { 691 + Some("__") 692 + } else { 693 + None 694 + } 695 + } 696 + Tag::Emphasis => { 697 + if raw_text.starts_with("*") { 698 + Some("*") 699 + } else if raw_text.starts_with("_") { 700 + Some("_") 701 + } else { 702 + None 703 + } 704 + } 705 + Tag::Strikethrough => { 706 + if raw_text.starts_with("~~") { 707 + Some("~~") 708 + } else { 709 + None 710 + } 711 + } 712 + Tag::Link { .. } => { 713 + if raw_text.starts_with('[') { 714 + Some("[") 715 + } else { 716 + None 717 + } 718 + } 719 + _ => None, 720 + }; 721 + 722 + if let Some(syntax) = opening_syntax { 723 + let class = match classify_syntax(syntax) { 724 + SyntaxClass::Inline => "md-syntax-inline", 725 + SyntaxClass::Block => "md-syntax-block", 726 + }; 727 + self.write("<span class=\"")?; 728 + self.write(class)?; 729 + self.write("\">")?; 730 + escape_html(&mut self.writer, syntax)?; 731 + self.write("</span>")?; 732 + } 733 + } 734 + 735 + // Emit the opening tag 736 + match tag { 737 + Tag::HtmlBlock => Ok(()), 738 + Tag::Paragraph => { 739 + let node_id = self.gen_node_id(); 740 + if self.end_newline { 741 + write!(&mut self.writer, "<p id=\"{}\">", node_id)?; 742 + } else { 743 + write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?; 744 + } 745 + self.begin_node(node_id.clone()); 746 + 747 + // Map the start position of the paragraph (before any content) 748 + // This allows cursor to be placed at the very beginning 749 + let para_start_char = self.last_char_offset; 750 + let mapping = OffsetMapping { 751 + byte_range: range.start..range.start, 752 + char_range: para_start_char..para_start_char, 753 + node_id, 754 + char_offset_in_node: 0, 755 + child_index: Some(0), // position before first child 756 + utf16_len: 0, 757 + }; 758 + self.offset_maps.push(mapping); 759 + 760 + // Emit > syntax if we're inside a blockquote 761 + if let Some(bq_range) = self.pending_blockquote_range.take() { 762 + if bq_range.start < bq_range.end { 763 + let raw_text = &self.source[bq_range]; 764 + if let Some(gt_pos) = raw_text.find('>') { 765 + // Extract > [!NOTE] or just > 766 + let after_gt = &raw_text[gt_pos + 1..]; 767 + let syntax_end = if after_gt.trim_start().starts_with("[!") { 768 + // Find the closing ] 769 + if let Some(close_bracket) = after_gt.find(']') { 770 + gt_pos + 1 + close_bracket + 1 771 + } else { 772 + gt_pos + 1 773 + } 774 + } else { 775 + // Just > and maybe a space 776 + (gt_pos + 2).min(raw_text.len()) 777 + }; 778 + 779 + let syntax = &raw_text[gt_pos..syntax_end]; 780 + self.write("<span class=\"md-syntax-block\">")?; 781 + escape_html(&mut self.writer, syntax)?; 782 + self.write("</span> ")?; // Add space after 783 + } 784 + } 785 + } 786 + Ok(()) 787 + } 788 + Tag::Heading { 789 + level, 790 + id, 791 + classes, 792 + attrs, 793 + } => { 794 + if !self.end_newline { 795 + self.write("\n")?; 796 + } 797 + 798 + // Generate node ID for offset tracking 799 + let node_id = self.gen_node_id(); 800 + 801 + self.write("<")?; 802 + write!(&mut self.writer, "{}", level)?; 803 + 804 + // Add our tracking ID as data attribute (preserve user's id if present) 805 + self.write(" data-node-id=\"")?; 806 + self.write(&node_id)?; 807 + self.write("\"")?; 808 + 809 + if let Some(id) = id { 810 + self.write(" id=\"")?; 811 + escape_html(&mut self.writer, &id)?; 812 + self.write("\"")?; 813 + } 814 + if !classes.is_empty() { 815 + self.write(" class=\"")?; 816 + for (i, class) in classes.iter().enumerate() { 817 + if i > 0 { 818 + self.write(" ")?; 819 + } 820 + escape_html(&mut self.writer, class)?; 821 + } 822 + self.write("\"")?; 823 + } 824 + for (attr, value) in attrs { 825 + self.write(" ")?; 826 + escape_html(&mut self.writer, &attr)?; 827 + if let Some(val) = value { 828 + self.write("=\"")?; 829 + escape_html(&mut self.writer, &val)?; 830 + self.write("\"")?; 831 + } else { 832 + self.write("=\"\"")?; 833 + } 834 + } 835 + self.write(">")?; 836 + 837 + // Begin node tracking for offset mapping 838 + self.begin_node(node_id); 839 + 840 + // Emit # syntax inside the heading tag 841 + if range.start < range.end { 842 + let raw_text = &self.source[range]; 843 + let count = level as usize; 844 + let pattern = "#".repeat(count); 845 + 846 + // Find where the # actually starts (might have leading whitespace) 847 + if let Some(hash_pos) = raw_text.find(&pattern) { 848 + // Extract "# " or "## " etc 849 + let syntax_start = hash_pos; 850 + let syntax_end = (hash_pos + count + 1).min(raw_text.len()); 851 + let syntax = &raw_text[syntax_start..syntax_end]; 852 + 853 + self.write("<span class=\"md-syntax-block\">")?; 854 + escape_html(&mut self.writer, syntax)?; 855 + self.write("</span>")?; 856 + } 857 + } 858 + Ok(()) 859 + } 860 + Tag::Table(alignments) => { 861 + if self.render_tables_as_markdown { 862 + // Store start offset and skip HTML rendering 863 + self.table_start_offset = Some(range.start); 864 + self.in_non_writing_block = true; // Suppress content output 865 + Ok(()) 866 + } else { 867 + self.table_alignments = alignments; 868 + self.write("<table>") 869 + } 870 + } 871 + Tag::TableHead => { 872 + if self.render_tables_as_markdown { 873 + Ok(()) // Skip HTML rendering 874 + } else { 875 + self.table_state = TableState::Head; 876 + self.table_cell_index = 0; 877 + self.write("<thead><tr>") 878 + } 879 + } 880 + Tag::TableRow => { 881 + if self.render_tables_as_markdown { 882 + Ok(()) // Skip HTML rendering 883 + } else { 884 + self.table_cell_index = 0; 885 + self.write("<tr>") 886 + } 887 + } 888 + Tag::TableCell => { 889 + if self.render_tables_as_markdown { 890 + Ok(()) // Skip HTML rendering 891 + } else { 892 + match self.table_state { 893 + TableState::Head => self.write("<th")?, 894 + TableState::Body => self.write("<td")?, 895 + } 896 + match self.table_alignments.get(self.table_cell_index) { 897 + Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 898 + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 899 + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 900 + _ => self.write(">"), 901 + } 902 + } 903 + } 904 + Tag::BlockQuote(kind) => { 905 + let class_str = match kind { 906 + None => "", 907 + Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", 908 + Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", 909 + Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", 910 + Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", 911 + Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", 912 + }; 913 + if self.end_newline { 914 + write!(&mut self.writer, "<blockquote{}>\n", class_str)?; 915 + } else { 916 + write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?; 917 + } 918 + 919 + // Store range for emitting > inside the next paragraph 920 + self.pending_blockquote_range = Some(range); 921 + Ok(()) 922 + } 923 + Tag::CodeBlock(info) => { 924 + if !self.end_newline { 925 + self.write_newline()?; 926 + } 927 + 928 + // Generate node ID for code block 929 + let node_id = self.gen_node_id(); 930 + 931 + match info { 932 + CodeBlockKind::Fenced(info) => { 933 + // Emit opening ```language 934 + if range.start < range.end { 935 + let raw_text = &self.source[range]; 936 + if let Some(fence_pos) = raw_text.find("```") { 937 + let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 938 + let syntax = &raw_text[fence_pos..fence_end]; 939 + self.write("<span class=\"md-syntax-block\">")?; 940 + escape_html(&mut self.writer, syntax)?; 941 + self.write("</span>\n")?; 942 + } 943 + } 944 + 945 + let lang = info.split(' ').next().unwrap(); 946 + let lang_opt = if lang.is_empty() { 947 + None 948 + } else { 949 + Some(lang.to_string()) 950 + }; 951 + // Start buffering 952 + self.code_buffer = Some((lang_opt, String::new())); 953 + 954 + // Begin node tracking for offset mapping 955 + self.begin_node(node_id); 956 + Ok(()) 957 + } 958 + CodeBlockKind::Indented => { 959 + // Ignore indented code blocks (as per executive decision) 960 + self.code_buffer = Some((None, String::new())); 961 + 962 + // Begin node tracking for offset mapping 963 + self.begin_node(node_id); 964 + Ok(()) 965 + } 966 + } 967 + } 968 + Tag::List(Some(1)) => { 969 + if self.end_newline { 970 + self.write("<ol>\n") 971 + } else { 972 + self.write("\n<ol>\n") 973 + } 974 + } 975 + Tag::List(Some(start)) => { 976 + if self.end_newline { 977 + self.write("<ol start=\"")?; 978 + } else { 979 + self.write("\n<ol start=\"")?; 980 + } 981 + write!(&mut self.writer, "{}", start)?; 982 + self.write("\">\n") 983 + } 984 + Tag::List(None) => { 985 + if self.end_newline { 986 + self.write("<ul>\n") 987 + } else { 988 + self.write("\n<ul>\n") 989 + } 990 + } 991 + Tag::Item => { 992 + // Generate node ID for list item 993 + let node_id = self.gen_node_id(); 994 + 995 + if self.end_newline { 996 + write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?; 997 + } else { 998 + write!(&mut self.writer, "\n<li data-node-id=\"{}\">", node_id)?; 999 + } 1000 + 1001 + // Begin node tracking 1002 + self.begin_node(node_id); 1003 + 1004 + // Emit list marker syntax inside the <li> tag 1005 + if range.start < range.end { 1006 + let raw_text = &self.source[range]; 1007 + 1008 + // Try to find the list marker (-, *, or digit.) 1009 + let trimmed = raw_text.trim_start(); 1010 + if let Some(marker) = trimmed.chars().next() { 1011 + if marker == '-' || marker == '*' { 1012 + // Unordered list: extract "- " or "* " 1013 + let marker_end = trimmed 1014 + .find(|c: char| c != '-' && c != '*') 1015 + .map(|pos| pos + 1) 1016 + .unwrap_or(1); 1017 + let syntax = &trimmed[..marker_end.min(trimmed.len())]; 1018 + self.write("<span class=\"md-syntax-block\">")?; 1019 + escape_html(&mut self.writer, syntax)?; 1020 + self.write("</span>")?; 1021 + } else if marker.is_ascii_digit() { 1022 + // Ordered list: extract "1. " or similar 1023 + if let Some(dot_pos) = trimmed.find('.') { 1024 + let syntax_end = (dot_pos + 2).min(trimmed.len()); 1025 + let syntax = &trimmed[..syntax_end].trim_end(); 1026 + self.write("<span class=\"md-syntax-block\">")?; 1027 + escape_html(&mut self.writer, syntax)?; 1028 + self.write("</span>")?; 1029 + } 1030 + } 1031 + } 1032 + } 1033 + Ok(()) 1034 + } 1035 + Tag::DefinitionList => { 1036 + if self.end_newline { 1037 + self.write("<dl>\n") 1038 + } else { 1039 + self.write("\n<dl>\n") 1040 + } 1041 + } 1042 + Tag::DefinitionListTitle => { 1043 + let node_id = self.gen_node_id(); 1044 + 1045 + if self.end_newline { 1046 + write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?; 1047 + } else { 1048 + write!(&mut self.writer, "\n<dt data-node-id=\"{}\">", node_id)?; 1049 + } 1050 + 1051 + self.begin_node(node_id); 1052 + Ok(()) 1053 + } 1054 + Tag::DefinitionListDefinition => { 1055 + let node_id = self.gen_node_id(); 1056 + 1057 + if self.end_newline { 1058 + write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?; 1059 + } else { 1060 + write!(&mut self.writer, "\n<dd data-node-id=\"{}\">", node_id)?; 1061 + } 1062 + 1063 + self.begin_node(node_id); 1064 + Ok(()) 1065 + } 1066 + Tag::Subscript => self.write("<sub>"), 1067 + Tag::Superscript => self.write("<sup>"), 1068 + Tag::Emphasis => self.write("<em>"), 1069 + Tag::Strong => self.write("<strong>"), 1070 + Tag::Strikethrough => self.write("<s>"), 1071 + Tag::Link { 1072 + link_type: LinkType::Email, 1073 + dest_url, 1074 + title, 1075 + .. 1076 + } => { 1077 + self.write("<a href=\"mailto:")?; 1078 + escape_href(&mut self.writer, &dest_url)?; 1079 + if !title.is_empty() { 1080 + self.write("\" title=\"")?; 1081 + escape_html(&mut self.writer, &title)?; 1082 + } 1083 + self.write("\">") 1084 + } 1085 + Tag::Link { 1086 + dest_url, title, .. 1087 + } => { 1088 + self.write("<a href=\"")?; 1089 + escape_href(&mut self.writer, &dest_url)?; 1090 + if !title.is_empty() { 1091 + self.write("\" title=\"")?; 1092 + escape_html(&mut self.writer, &title)?; 1093 + } 1094 + self.write("\">") 1095 + } 1096 + Tag::Image { 1097 + dest_url, 1098 + title, 1099 + attrs, 1100 + .. 1101 + } => { 1102 + // Emit opening ![ 1103 + if range.start < range.end { 1104 + let raw_text = &self.source[range.clone()]; 1105 + if raw_text.starts_with("![") { 1106 + self.write("<span class=\"md-syntax-inline\">![</span>")?; 1107 + } 1108 + } 1109 + 1110 + self.write("<img src=\"")?; 1111 + escape_href(&mut self.writer, &dest_url)?; 1112 + self.write("\" alt=\"")?; 1113 + // Consume text events for alt attribute 1114 + self.raw_text()?; 1115 + self.write("\"")?; 1116 + if !title.is_empty() { 1117 + self.write(" title=\"")?; 1118 + escape_html(&mut self.writer, &title)?; 1119 + self.write("\"")?; 1120 + } 1121 + if let Some(attrs) = attrs { 1122 + if !attrs.classes.is_empty() { 1123 + self.write(" class=\"")?; 1124 + for (i, class) in attrs.classes.iter().enumerate() { 1125 + if i > 0 { 1126 + self.write(" ")?; 1127 + } 1128 + escape_html(&mut self.writer, class)?; 1129 + } 1130 + self.write("\"")?; 1131 + } 1132 + for (attr, value) in &attrs.attrs { 1133 + self.write(" ")?; 1134 + escape_html(&mut self.writer, attr)?; 1135 + self.write("=\"")?; 1136 + escape_html(&mut self.writer, value)?; 1137 + self.write("\"")?; 1138 + } 1139 + } 1140 + self.write(" />")?; 1141 + 1142 + // Emit closing ](url) 1143 + if range.start < range.end { 1144 + let raw_text = &self.source[range]; 1145 + if let Some(paren_pos) = raw_text.rfind("](") { 1146 + let syntax = &raw_text[paren_pos..]; 1147 + self.write("<span class=\"md-syntax-inline\">")?; 1148 + escape_html(&mut self.writer, syntax)?; 1149 + self.write("</span>")?; 1150 + } 1151 + } 1152 + Ok(()) 1153 + } 1154 + Tag::Embed { 1155 + embed_type, 1156 + dest_url, 1157 + title, 1158 + id, 1159 + attrs, 1160 + } => self.write_embed(embed_type, dest_url, title, id, attrs), 1161 + Tag::WeaverBlock(_, _) => { 1162 + self.in_non_writing_block = true; 1163 + Ok(()) 1164 + } 1165 + Tag::FootnoteDefinition(name) => { 1166 + if self.end_newline { 1167 + self.write("<div class=\"footnote-definition\" id=\"")?; 1168 + } else { 1169 + self.write("\n<div class=\"footnote-definition\" id=\"")?; 1170 + } 1171 + escape_html(&mut self.writer, &name)?; 1172 + self.write("\"><sup class=\"footnote-definition-label\">")?; 1173 + let len = self.numbers.len() + 1; 1174 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 1175 + write!(&mut self.writer, "{}", number)?; 1176 + self.write("</sup>") 1177 + } 1178 + Tag::MetadataBlock(_) => { 1179 + self.in_non_writing_block = true; 1180 + Ok(()) 1181 + } 1182 + } 1183 + } 1184 + 1185 + fn end_tag( 1186 + &mut self, 1187 + tag: markdown_weaver::TagEnd, 1188 + range: Range<usize>, 1189 + ) -> Result<(), W::Error> { 1190 + use markdown_weaver::TagEnd; 1191 + 1192 + // Emit tag HTML first 1193 + let result = match tag { 1194 + TagEnd::HtmlBlock => Ok(()), 1195 + TagEnd::Paragraph => { 1196 + self.end_node(); 1197 + self.write("</p>\n") 1198 + } 1199 + TagEnd::Heading(level) => { 1200 + self.end_node(); 1201 + self.write("</")?; 1202 + write!(&mut self.writer, "{}", level)?; 1203 + self.write(">\n") 1204 + } 1205 + TagEnd::Table => { 1206 + if self.render_tables_as_markdown { 1207 + // Emit the raw markdown table 1208 + if let Some(start) = self.table_start_offset.take() { 1209 + let table_text = &self.source[start..range.end]; 1210 + self.in_non_writing_block = false; 1211 + 1212 + // Wrap in a pre or div for styling 1213 + self.write("<pre class=\"table-markdown\">")?; 1214 + escape_html(&mut self.writer, table_text)?; 1215 + self.write("</pre>\n")?; 1216 + } 1217 + Ok(()) 1218 + } else { 1219 + self.write("</tbody></table>\n") 1220 + } 1221 + } 1222 + TagEnd::TableHead => { 1223 + if self.render_tables_as_markdown { 1224 + Ok(()) // Skip HTML rendering 1225 + } else { 1226 + self.write("</tr></thead><tbody>\n")?; 1227 + self.table_state = TableState::Body; 1228 + Ok(()) 1229 + } 1230 + } 1231 + TagEnd::TableRow => { 1232 + if self.render_tables_as_markdown { 1233 + Ok(()) // Skip HTML rendering 1234 + } else { 1235 + self.write("</tr>\n") 1236 + } 1237 + } 1238 + TagEnd::TableCell => { 1239 + if self.render_tables_as_markdown { 1240 + Ok(()) // Skip HTML rendering 1241 + } else { 1242 + match self.table_state { 1243 + TableState::Head => self.write("</th>")?, 1244 + TableState::Body => self.write("</td>")?, 1245 + } 1246 + self.table_cell_index += 1; 1247 + Ok(()) 1248 + } 1249 + } 1250 + TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 1251 + TagEnd::CodeBlock => { 1252 + use std::sync::LazyLock; 1253 + use syntect::parsing::SyntaxSet; 1254 + static SYNTAX_SET: LazyLock<SyntaxSet> = 1255 + LazyLock::new(|| SyntaxSet::load_defaults_newlines()); 1256 + 1257 + if let Some((lang, buffer)) = self.code_buffer.take() { 1258 + // Create offset mapping for code block content if we tracked a range 1259 + if let Some(code_byte_range) = self.code_buffer_byte_range.take() { 1260 + // Calculate char range from the tracked byte range 1261 + let char_start = byte_to_char(self.source, code_byte_range.start); 1262 + let char_end = byte_to_char(self.source, code_byte_range.end); 1263 + let char_range = char_start..char_end; 1264 + 1265 + // Record mapping before writing HTML 1266 + // (current_node_id should be set by start_tag for CodeBlock) 1267 + self.record_mapping(code_byte_range, char_range); 1268 + } 1269 + 1270 + if let Some(ref lang_str) = lang { 1271 + // Use a temporary String buffer for syntect 1272 + let mut temp_output = String::new(); 1273 + match weaver_renderer::code_pretty::highlight( 1274 + &SYNTAX_SET, 1275 + Some(lang_str), 1276 + &buffer, 1277 + &mut temp_output, 1278 + ) { 1279 + Ok(_) => { 1280 + self.write(&temp_output)?; 1281 + } 1282 + Err(_) => { 1283 + // Fallback to plain code block 1284 + self.write("<pre><code class=\"language-")?; 1285 + escape_html(&mut self.writer, lang_str)?; 1286 + self.write("\">")?; 1287 + escape_html_body_text(&mut self.writer, &buffer)?; 1288 + self.write("</code></pre>\n")?; 1289 + } 1290 + } 1291 + } else { 1292 + self.write("<pre><code>")?; 1293 + escape_html_body_text(&mut self.writer, &buffer)?; 1294 + self.write("</code></pre>\n")?; 1295 + } 1296 + 1297 + // End node tracking 1298 + self.end_node(); 1299 + } else { 1300 + self.write("</code></pre>\n")?; 1301 + } 1302 + 1303 + // Emit closing ``` 1304 + if range.start < range.end { 1305 + let raw_text = &self.source[range.clone()]; 1306 + if let Some(fence_line) = raw_text.lines().last() { 1307 + if fence_line.trim() == "```" { 1308 + self.write("<span class=\"md-syntax-block\">```</span>")?; 1309 + } 1310 + } 1311 + } 1312 + 1313 + Ok(()) 1314 + } 1315 + TagEnd::List(true) => self.write("</ol>\n"), 1316 + TagEnd::List(false) => self.write("</ul>\n"), 1317 + TagEnd::Item => { 1318 + self.end_node(); 1319 + self.write("</li>\n") 1320 + } 1321 + TagEnd::DefinitionList => self.write("</dl>\n"), 1322 + TagEnd::DefinitionListTitle => { 1323 + self.end_node(); 1324 + self.write("</dt>\n") 1325 + } 1326 + TagEnd::DefinitionListDefinition => { 1327 + self.end_node(); 1328 + self.write("</dd>\n") 1329 + } 1330 + TagEnd::Emphasis => self.write("</em>"), 1331 + TagEnd::Superscript => self.write("</sup>"), 1332 + TagEnd::Subscript => self.write("</sub>"), 1333 + TagEnd::Strong => self.write("</strong>"), 1334 + TagEnd::Strikethrough => self.write("</s>"), 1335 + TagEnd::Link => self.write("</a>"), 1336 + TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 1337 + TagEnd::Embed => Ok(()), 1338 + TagEnd::WeaverBlock(_) => { 1339 + self.in_non_writing_block = false; 1340 + Ok(()) 1341 + } 1342 + TagEnd::FootnoteDefinition => self.write("</div>\n"), 1343 + TagEnd::MetadataBlock(_) => { 1344 + self.in_non_writing_block = false; 1345 + Ok(()) 1346 + } 1347 + }; 1348 + 1349 + result?; 1350 + 1351 + // Extract and emit closing syntax based on tag type 1352 + if range.start < range.end { 1353 + let raw_text = &self.source[range]; 1354 + let closing_syntax = match &tag { 1355 + TagEnd::Strong => { 1356 + if raw_text.ends_with("**") { 1357 + Some("**") 1358 + } else if raw_text.ends_with("__") { 1359 + Some("__") 1360 + } else { 1361 + None 1362 + } 1363 + } 1364 + TagEnd::Emphasis => { 1365 + if raw_text.ends_with("*") { 1366 + Some("*") 1367 + } else if raw_text.ends_with("_") { 1368 + Some("_") 1369 + } else { 1370 + None 1371 + } 1372 + } 1373 + TagEnd::Strikethrough => { 1374 + if raw_text.ends_with("~~") { 1375 + Some("~~") 1376 + } else { 1377 + None 1378 + } 1379 + } 1380 + TagEnd::Link => { 1381 + // Extract ](url) part 1382 + if let Some(idx) = raw_text.rfind("](") { 1383 + Some(&raw_text[idx..]) 1384 + } else { 1385 + None 1386 + } 1387 + } 1388 + TagEnd::CodeBlock => { 1389 + if raw_text.ends_with("```") { 1390 + raw_text.lines().last() 1391 + } else { 1392 + None 1393 + } 1394 + } 1395 + _ => None, 1396 + }; 1397 + 1398 + if let Some(syntax) = closing_syntax { 1399 + let class = match classify_syntax(syntax) { 1400 + SyntaxClass::Inline => "md-syntax-inline", 1401 + SyntaxClass::Block => "md-syntax-block", 1402 + }; 1403 + self.write("<span class=\"")?; 1404 + self.write(class)?; 1405 + self.write("\">")?; 1406 + escape_html(&mut self.writer, syntax)?; 1407 + self.write("</span>")?; 1408 + } 1409 + } 1410 + 1411 + Ok(()) 1412 + } 1413 + } 1414 + 1415 + impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider> 1416 + EditorWriter<'a, I, W, E> 1417 + { 1418 + fn write_embed( 1419 + &mut self, 1420 + embed_type: EmbedType, 1421 + dest_url: CowStr<'_>, 1422 + title: CowStr<'_>, 1423 + id: CowStr<'_>, 1424 + attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 1425 + ) -> Result<(), W::Error> { 1426 + // Try to get content from attributes first 1427 + let content_from_attrs = if let Some(ref attrs) = attrs { 1428 + attrs 1429 + .attrs 1430 + .iter() 1431 + .find(|(k, _)| k.as_ref() == "content") 1432 + .map(|(_, v)| v.as_ref().to_string()) 1433 + } else { 1434 + None 1435 + }; 1436 + 1437 + // If no content in attrs, try provider 1438 + let content = if let Some(content) = content_from_attrs { 1439 + Some(content) 1440 + } else if let Some(ref provider) = self.embed_provider { 1441 + let tag = Tag::Embed { 1442 + embed_type, 1443 + dest_url: dest_url.clone(), 1444 + title: title.clone(), 1445 + id: id.clone(), 1446 + attrs: attrs.clone(), 1447 + }; 1448 + provider.get_embed_content(&tag) 1449 + } else { 1450 + None 1451 + }; 1452 + 1453 + if let Some(html_content) = content { 1454 + // Write the pre-rendered content directly 1455 + self.write(&html_content)?; 1456 + self.write_newline()?; 1457 + } else { 1458 + // Fallback: render as iframe 1459 + self.write("<iframe src=\"")?; 1460 + escape_href(&mut self.writer, &dest_url)?; 1461 + self.write("\" title=\"")?; 1462 + escape_html(&mut self.writer, &title)?; 1463 + if !id.is_empty() { 1464 + self.write("\" id=\"")?; 1465 + escape_html(&mut self.writer, &id)?; 1466 + } 1467 + self.write("\"")?; 1468 + 1469 + if let Some(attrs) = attrs { 1470 + if !attrs.classes.is_empty() { 1471 + self.write(" class=\"")?; 1472 + for (i, class) in attrs.classes.iter().enumerate() { 1473 + if i > 0 { 1474 + self.write(" ")?; 1475 + } 1476 + escape_html(&mut self.writer, class)?; 1477 + } 1478 + self.write("\"")?; 1479 + } 1480 + for (attr, value) in &attrs.attrs { 1481 + // Skip the content attr in HTML output 1482 + if attr.as_ref() != "content" { 1483 + self.write(" ")?; 1484 + escape_html(&mut self.writer, attr)?; 1485 + self.write("=\"")?; 1486 + escape_html(&mut self.writer, value)?; 1487 + self.write("\"")?; 1488 + } 1489 + } 1490 + } 1491 + self.write("></iframe>")?; 1492 + } 1493 + Ok(()) 1494 + } 1495 + }
+1
crates/weaver-app/src/components/mod.rs
··· 128 128 pub mod button; 129 129 pub mod dialog; 130 130 pub mod input; 131 + pub mod editor;
+3 -1
crates/weaver-app/src/main.rs
··· 17 17 use std::sync::{Arc, LazyLock}; 18 18 #[allow(unused)] 19 19 use views::{ 20 - Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordIndex, RecordPage, 20 + Callback, Editor, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordIndex, RecordPage, 21 21 }; 22 22 23 23 use crate::{ ··· 54 54 #[layout(Navbar)] 55 55 #[route("/")] 56 56 Home {}, 57 + #[route("/editor")] 58 + Editor {}, 57 59 #[layout(ErrorLayout)] 58 60 #[nest("/record")] 59 61 #[layout(RecordIndex)]
+17
crates/weaver-app/src/views/editor.rs
··· 1 + //! Editor view - wraps the MarkdownEditor component for the /editor route. 2 + 3 + use dioxus::prelude::*; 4 + use crate::components::editor::MarkdownEditor; 5 + 6 + /// Editor page view. 7 + /// 8 + /// Displays the markdown editor at the /editor route for testing during development. 9 + /// Eventually this will be integrated into the notebook editing workflow. 10 + #[component] 11 + pub fn Editor() -> Element { 12 + rsx! { 13 + div { class: "editor-page", 14 + MarkdownEditor { initial_content: None } 15 + } 16 + } 17 + }
+1
crates/weaver-app/src/views/mod.rs
··· 27 27 pub use callback::Callback; 28 28 29 29 mod editor; 30 + pub use editor::Editor;