diff --git a/Cargo.lock b/Cargo.lock index 025212c..268d70b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4800,6 +4800,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jumprope" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829c74fe88dda0d2a5425b022b44921574a65c4eb78e6e39a61b40eb416a4ef8" +dependencies = [ + "rand 0.8.5", + "str_indices", +] + [[package]] name = "k256" version = "0.13.4" @@ -5168,7 +5178,6 @@ dependencies = [ [[package]] name = "markdown-weaver" version = "0.13.0" -source = "git+https://github.com/rsform/markdown-weaver#46f3eee6c93118da84a5de3d25fef642735aedd2" dependencies = [ "bitflags 2.10.0", "getopts", @@ -5181,7 +5190,6 @@ dependencies = [ [[package]] name = "markdown-weaver-escape" version = "0.11.0" -source = "git+https://github.com/rsform/markdown-weaver#46f3eee6c93118da84a5de3d25fef642735aedd2" [[package]] name = "markup5ever" @@ -7838,6 +7846,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "string_cache" version = "0.8.9" @@ -9426,6 +9440,7 @@ dependencies = [ "dioxus-primitives", "dotenvy", "gloo-storage", + "gloo-timers", "http", "humansize", "jacquard", @@ -9433,8 +9448,10 @@ dependencies = [ "jacquard-identity", "jacquard-lexicon", "js-sys", + "jumprope", "lol_alloc", "markdown-weaver", + "markdown-weaver-escape", "mime-sniffer", "mini-moka 0.11.0", "n0-future", @@ -9443,6 +9460,7 @@ dependencies = [ "serde_html_form", "serde_ipld_dagcbor", "serde_json", + "syntect", "time", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 5fd67d2..712c4e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,8 +28,10 @@ thiserror = "2.0" syntect = { version = "5.2.0", default-features = false } n0-future = "=0.1.3" tracing = { version = "0.1.41", default-features = false, features = ["std"] } -markdown-weaver = { git = "https://github.com/rsform/markdown-weaver" } -markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } +# markdown-weaver = { git = "https://github.com/rsform/markdown-weaver" } +# markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } +markdown-weaver = { path = "../markdown-weaver/markdown-weaver" } +markdown-weaver-escape = { path = "../markdown-weaver/markdown-weaver-escape" } # jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", default-features = false, features = ["derive", "api_bluesky", "tracing"] } # jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["cache"] } diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index b9259d3..981184a 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -42,13 +42,15 @@ base64 = "0.22" http = "1.3" reqwest = { version = "0.12", default-features = false, features = ["json"] } dioxus-free-icons = { version = "0.9", features = ["font-awesome-brands"] } - +syntect = { workspace = true, default-features = false, features = ["default-fancy"]} # diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } # diesel_migrations = { version = "2.3", features = ["sqlite"] } tokio = { version = "1.28", features = ["sync"] } serde_html_form = "0.2.8" tracing.workspace = true serde_ipld_dagcbor = { version = "0.6" } +jumprope = { version = "1.1", features = ["wchar_conversion"] } +markdown-weaver-escape = { workspace = true } [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] webbrowser = "1.0.6" @@ -63,9 +65,10 @@ mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d0 chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement"] } +web-sys = { version = "0.3", features = ["ServiceWorkerContainer", "ServiceWorker", "ServiceWorkerRegistration", "RegistrationOptions", "Window", "Navigator", "MessageEvent", "console", "Document", "Element", "HtmlImageElement", "Selection", "Range", "Node", "HtmlElement", "TreeWalker", "NodeFilter"] } js-sys = "0.3" gloo-storage = "0.3" +gloo-timers = "0.3" lol_alloc = "0.4.1" [build-dependencies] diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css new file mode 100644 index 0000000..9be046d --- /dev/null +++ b/crates/weaver-app/assets/styling/editor.css @@ -0,0 +1,101 @@ +/* Markdown Editor Styling - using Rose Pine theme variables */ + +.editor-page { + width: 100%; + height: 100%; + background: var(--color-base); + overflow: hidden; +} + +.markdown-editor-container { + display: flex; + flex-direction: row; + height: 100%; + max-width: 1200px; + margin: 0 auto; + font-family: var(--font-body); + background: var(--color-base); + color: var(--color-text); +} + +.editor-content-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.editor-content { + flex: 1; + padding: 20px; + overflow-y: auto; + outline: none; + line-height: var(--spacing-line-height); + font-size: 16px; + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.editor-content:focus { + background: var(--color-surface); +} + +.editor-toolbar { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--color-base); + flex-shrink: 0; + min-width: 60px; +} + +.toolbar-button { + padding: 8px 12px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + border-radius: 4px; + cursor: pointer; + font-weight: 600; + text-align: center; + transition: background 0.2s ease; +} + +.toolbar-button:hover { + background: var(--color-overlay); +} + +.toolbar-separator { + height: 1px; + background: var(--color-border); + margin: 4px 0; +} + +.editor-debug { + padding: 8px; + background: var(--color-base); + font-family: var(--font-mono); + font-size: 12px; + flex-shrink: 0; + color: var(--color-muted); +} + +/* Markdown syntax characters - inline (**, *, ~~, `, etc) */ +.md-syntax-inline { + color: var(--color-muted); + opacity: 0.6; + user-select: none; +} + +/* Markdown syntax characters - block level (#, >, -, etc) */ +.md-syntax-block { + color: var(--color-muted); + opacity: 0.7; + user-select: none; + font-weight: normal; +} + +/* Future: contextual hiding based on cursor position */ +/* .cursor-active .md-syntax-inline { display: none; } */ diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs new file mode 100644 index 0000000..99e24e2 --- /dev/null +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -0,0 +1,156 @@ +//! Cursor position restoration in the DOM. +//! +//! After re-rendering HTML, we need to restore the cursor to its original +//! position in the source text. This involves: +//! 1. Finding the offset mapping for the cursor's char position +//! 2. Getting the DOM element by node ID +//! 3. Walking text nodes to find the UTF-16 offset within the element +//! 4. Setting cursor with web_sys Selection API + +use super::offset_map::{find_mapping_for_char, OffsetMapping}; +use jumprope::JumpRopeBuf; + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +use wasm_bindgen::JsCast; + +/// Restore cursor position in the DOM after re-render. +/// +/// # Arguments +/// - `rope`: The document content (for length bounds checking) +/// - `char_offset`: Cursor position as char offset in rope +/// - `offset_map`: Mappings from source to DOM positions +/// - `editor_id`: DOM ID of the contenteditable element +/// +/// # Algorithm +/// 1. Find offset mapping containing char_offset +/// 2. Get DOM node by mapping.node_id +/// 3. Walk text nodes to find UTF-16 position +/// 4. Set cursor with Selection API +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn restore_cursor_position( + rope: &JumpRopeBuf, + char_offset: usize, + offset_map: &[OffsetMapping], + editor_id: &str, +) -> Result<(), wasm_bindgen::JsValue> { + // Bounds check + let max_offset = rope.len_chars(); + if char_offset > max_offset { + return Err(format!("cursor offset {} > document length {}", char_offset, max_offset).into()); + } + + // Empty document - no cursor to restore + if offset_map.is_empty() || max_offset == 0 { + return Ok(()); + } + + // Find mapping for this cursor position + let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset) + .ok_or("no mapping found for cursor offset")?; + + // If cursor is in invisible content, snap to next visible position + // For now, we'll still use the mapping but this is a future enhancement + if should_snap { + tracing::debug!("cursor in invisible content at offset {}", char_offset); + } + + // Get window and document + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + + // Get the container element by node ID + let container = document + .get_element_by_id(&mapping.node_id) + .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; + + // Set selection using Range API + let selection = window + .get_selection()? + .ok_or("no selection object")?; + let range = document.create_range()?; + + // Check if this is an element-based position (e.g., after
) + if let Some(child_index) = mapping.child_index { + // Position cursor at child index in the element + range.set_start(&container, child_index as u32)?; + } else { + // Position cursor in text content + let container_element = container.dyn_into::()?; + let offset_in_range = char_offset - mapping.char_range.start; + let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; + let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?; + range.set_start(&text_node, node_offset as u32)?; + } + + range.collapse_with_to_start(true); + + selection.remove_all_ranges()?; + selection.add_range(&range)?; + + Ok(()) +} + +/// Find text node at given UTF-16 offset within element. +/// +/// Walks all text nodes in the container, accumulating their UTF-16 lengths +/// until we find the node containing the target offset. +/// +/// Returns (text_node, offset_within_node). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn find_text_node_at_offset( + container: &web_sys::HtmlElement, + target_utf16_offset: usize, +) -> Result<(web_sys::Node, usize), wasm_bindgen::JsValue> { + let document = web_sys::window() + .ok_or("no window")? + .document() + .ok_or("no document")?; + + // Create tree walker to find text nodes + // SHOW_TEXT = 4 (from DOM spec) + let walker = document.create_tree_walker_with_what_to_show( + container, + 4, + )?; + + let mut accumulated_utf16 = 0; + let mut last_node: Option = None; + + while let Some(node) = walker.next_node()? { + last_node = Some(node.clone()); + + if let Some(text) = node.text_content() { + let text_len = text.encode_utf16().count(); + + // Found the node containing target offset + if accumulated_utf16 + text_len >= target_utf16_offset { + let offset_in_node = target_utf16_offset - accumulated_utf16; + return Ok((node, offset_in_node)); + } + + accumulated_utf16 += text_len; + } + } + + // Fallback: return last node at its end + // This handles cursor at end of document + if let Some(node) = last_node { + if let Some(text) = node.text_content() { + let text_len = text.encode_utf16().count(); + return Ok((node, text_len)); + } + } + + Err("no text node found in container".into()) +} + +/// Non-WASM stub for testing +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn restore_cursor_position( + _rope: &JumpRopeBuf, + _char_offset: usize, + _offset_map: &[OffsetMapping], + _editor_id: &str, +) -> Result<(), String> { + Ok(()) +} diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs new file mode 100644 index 0000000..efa460f --- /dev/null +++ b/crates/weaver-app/src/components/editor/document.rs @@ -0,0 +1,85 @@ +//! Core data structures for the markdown editor. + +use jumprope::JumpRopeBuf; + +/// Single source of truth for editor state. +/// +/// Contains the document text, cursor position, selection, and IME composition state. +#[derive(Clone, Debug)] +pub struct EditorDocument { + /// The rope storing document text (uses char offsets, not bytes). + /// Uses JumpRopeBuf to batch consecutive edits for performance. + pub rope: JumpRopeBuf, + + /// Current cursor position (char offset) + pub cursor: CursorState, + + /// Active selection if any + pub selection: Option, + + /// IME composition state (for Phase 3) + pub composition: Option, +} + +/// Cursor state including position and affinity. +#[derive(Clone, Debug, Copy)] +pub struct CursorState { + /// Character offset in rope (NOT byte offset!) + pub offset: usize, + + /// Prefer left/right when at boundary (for vertical cursor movement) + pub affinity: Affinity, +} + +/// Cursor affinity for vertical movement. +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub enum Affinity { + Before, + After, +} + +/// Text selection with anchor and head positions. +#[derive(Clone, Debug, Copy)] +pub struct Selection { + /// Where selection started + pub anchor: usize, + /// Where cursor is now + pub head: usize, +} + +/// IME composition state (for international text input). +#[derive(Clone, Debug)] +pub struct CompositionState { + pub start_offset: usize, + pub text: String, +} + +impl EditorDocument { + /// Create a new editor document with the given content. + pub fn new(content: String) -> Self { + Self { + rope: JumpRopeBuf::from(content.as_str()), + cursor: CursorState { + offset: 0, + affinity: Affinity::Before, + }, + selection: None, + composition: None, + } + } + + /// Convert the document to a string. + pub fn to_string(&self) -> String { + self.rope.to_string() + } + + /// Get the length of the document in characters. + pub fn len_chars(&self) -> usize { + self.rope.len_chars() + } + + /// Check if the document is empty. + pub fn is_empty(&self) -> bool { + self.rope.len_chars() == 0 + } +} diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs new file mode 100644 index 0000000..a9a62b8 --- /dev/null +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -0,0 +1,152 @@ +//! Formatting actions and utilities for applying markdown formatting. + +use super::document::EditorDocument; + +/// Formatting actions available in the editor. +#[derive(Clone, Debug, PartialEq)] +pub enum FormatAction { + Bold, + Italic, + Strikethrough, + Code, + Link, + Image, + Heading(u8), // 1-6 + BulletList, + NumberedList, + Quote, +} + +/// Find word boundaries around cursor position. +/// +/// Expands to whitespace boundaries. Used when applying formatting +/// without a selection. +pub fn find_word_boundaries(rope: &jumprope::JumpRopeBuf, offset: usize) -> (usize, usize) { + let rope = rope.borrow(); + let mut start = 0; + let mut end = rope.len_chars(); + + // Find start by scanning backwards + let mut char_pos = 0; + for substr in rope.slice_substrings(0..offset) { + for c in substr.chars() { + if c.is_whitespace() { + start = char_pos + 1; + } + char_pos += 1; + } + } + + // Find end by scanning forwards + char_pos = offset; + let byte_len = rope.len_bytes(); + for substr in rope.slice_substrings(offset..byte_len) { + for c in substr.chars() { + if c.is_whitespace() { + end = char_pos; + return (start, end); + } + char_pos += 1; + } + } + + (start, end) +} + +/// Apply formatting to document. +/// +/// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. +pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { + let (start, end) = if let Some(sel) = doc.selection { + // Use selection + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) + } else { + // Expand to word + find_word_boundaries(&doc.rope, doc.cursor.offset) + }; + + match action { + FormatAction::Bold => { + doc.rope.insert(end, "**"); + doc.rope.insert(start, "**"); + doc.cursor.offset = end + 4; + doc.selection = None; + } + FormatAction::Italic => { + doc.rope.insert(end, "*"); + doc.rope.insert(start, "*"); + doc.cursor.offset = end + 2; + doc.selection = None; + } + FormatAction::Strikethrough => { + doc.rope.insert(end, "~~"); + doc.rope.insert(start, "~~"); + doc.cursor.offset = end + 4; + doc.selection = None; + } + FormatAction::Code => { + doc.rope.insert(end, "`"); + doc.rope.insert(start, "`"); + doc.cursor.offset = end + 2; + doc.selection = None; + } + FormatAction::Link => { + // Insert [selected text](url) + doc.rope.insert(end, "](url)"); + doc.rope.insert(start, "["); + doc.cursor.offset = end + 8; // Position cursor after ](url) + doc.selection = None; + } + FormatAction::Image => { + // Insert ![alt text](url) + doc.rope.insert(end, "](url)"); + doc.rope.insert(start, "!["); + doc.cursor.offset = end + 9; + doc.selection = None; + } + FormatAction::Heading(level) => { + // Find start of current line + let line_start = find_line_start(&doc.rope, doc.cursor.offset); + let prefix = "#".repeat(level as usize) + " "; + doc.rope.insert(line_start, &prefix); + doc.cursor.offset += prefix.len(); + doc.selection = None; + } + FormatAction::BulletList => { + let line_start = find_line_start(&doc.rope, doc.cursor.offset); + doc.rope.insert(line_start, "- "); + doc.cursor.offset += 2; + doc.selection = None; + } + FormatAction::NumberedList => { + let line_start = find_line_start(&doc.rope, doc.cursor.offset); + doc.rope.insert(line_start, "1. "); + doc.cursor.offset += 3; + doc.selection = None; + } + FormatAction::Quote => { + let line_start = find_line_start(&doc.rope, doc.cursor.offset); + doc.rope.insert(line_start, "> "); + doc.cursor.offset += 2; + doc.selection = None; + } + } +} + +/// Find start of line containing offset (same as in mod.rs) +fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { + let mut char_pos = 0; + let mut last_newline_pos = None; + + let rope = rope.borrow(); + for substr in rope.slice_substrings(0..offset) { + for c in substr.chars() { + if c == '\n' { + last_newline_pos = Some(char_pos); + } + char_pos += 1; + } + } + + last_newline_pos.map(|pos| pos + 1).unwrap_or(0) +} diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs new file mode 100644 index 0000000..72b3809 --- /dev/null +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -0,0 +1,328 @@ +//! Markdown editor component with Obsidian-style formatting visibility. +//! +//! This module implements a WYSIWYG-like markdown editor where formatting +//! characters are hidden contextually based on cursor position, while still +//! editing plain markdown text under the hood. + +mod cursor; +mod document; +mod formatting; +mod offset_map; +mod offsets; +mod render; +mod rope_writer; +mod storage; +mod toolbar; +mod writer; + +pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; +pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; +pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; +pub use render::render_markdown_simple; +pub use rope_writer::RopeWriter; +pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; +pub use toolbar::EditorToolbar; + +use dioxus::prelude::*; + +/// Main markdown editor component. +/// +/// # Props +/// - `initial_content`: Optional initial markdown content +/// +/// # Features +/// - JumpRope-based text storage for efficient editing +/// - Event interception for full control over editing operations +/// - Toolbar formatting buttons +/// - LocalStorage auto-save with debouncing +/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) +/// +/// # Phase 1 Limitations +/// - Cursor jumps to end after each keystroke (acceptable for MVP) +/// - All formatting characters visible (no hiding based on cursor position) +/// - No proper grapheme cluster handling +/// - No IME composition support +/// - No undo/redo +/// - No selection with Shift+Arrow +/// - No mouse selection +#[component] +pub fn MarkdownEditor(initial_content: Option) -> Element { + // Try to restore from localStorage + let restored = use_memo(move || { + storage::load_from_storage() + .map(|s| s.content) + .or_else(|| initial_content.clone()) + .unwrap_or_default() + }); + + let mut document = use_signal(|| EditorDocument::new(restored())); + let editor_id = "markdown-editor"; + + // Render markdown to HTML with offset mappings + let render_result = use_memo(move || render::render_markdown_simple(&document().to_string())); + let rendered_html = use_memo(move || render_result.read().html.clone()); + let offset_map = use_memo(move || render_result.read().offset_map.clone()); + + // Auto-save with debounce + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + let doc = document(); + + // Save after 500ms of no typing + let timer = gloo_timers::callback::Timeout::new(500, move || { + let _ = storage::save_to_storage(&doc.to_string(), doc.cursor.offset); + }); + timer.forget(); + }); + + // Restore cursor after re-render + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + use wasm_bindgen::prelude::*; + use wasm_bindgen::JsCast; + + let cursor_offset = document().cursor.offset; + let rope = document().rope.clone(); + let map = offset_map.read().clone(); + + // Use requestAnimationFrame to wait for browser paint + let window = web_sys::window().expect("no window"); + + let closure = Closure::once(move || { + if let Err(e) = cursor::restore_cursor_position(&rope, cursor_offset, &map, editor_id) { + tracing::warn!("Cursor restoration failed: {:?}", e); + } + }); + + let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); + closure.forget(); + }); + + rsx! { + Stylesheet { href: asset!("/assets/styling/editor.css") } + div { class: "markdown-editor-container", + div { class: "editor-content-wrapper", + // Debug panel + div { class: "editor-debug", + "Cursor: {document().cursor.offset}, " + "Chars: {document().len_chars()}" + } + div { + id: "{editor_id}", + class: "editor-content", + contenteditable: "true", + dangerous_inner_html: "{rendered_html}", + + onkeydown: move |evt| { + evt.prevent_default(); + handle_keydown(evt, &mut document); + }, + + onpaste: move |evt| { + evt.prevent_default(); + handle_paste(evt, &mut document); + }, + + // Phase 1: Accept that cursor position will jump + // Phase 2: Restore cursor properly + } + + + } + + EditorToolbar { + on_format: move |action| { + document.with_mut(|doc| { + formatting::apply_formatting(doc, action); + }); + } + } + } + } +} + +/// Handle paste events and insert text at cursor +fn handle_paste(evt: Event, document: &mut Signal) { + // Downcast to web_sys event to get clipboard data + #[cfg(target_arch = "wasm32")] + if let Some(web_evt) = evt.data().downcast::() { + if let Some(data_transfer) = web_evt.clipboard_data() { + if let Ok(text) = data_transfer.get_data("text/plain") { + document.with_mut(|doc| { + // Delete selection if present + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + + // Insert pasted text + doc.rope.insert(doc.cursor.offset, &text); + doc.cursor.offset += text.chars().count(); + }); + } + } + } +} + +/// Handle keyboard events and update document state +fn handle_keydown(evt: Event, document: &mut Signal) { + use dioxus::prelude::keyboard_types::Key; + + let key = evt.key(); + let mods = evt.modifiers(); + + document.with_mut(|doc| { + match key { + Key::Character(ch) => { + // Keyboard shortcuts first + if mods.ctrl() { + match ch.as_str() { + "b" => { + formatting::apply_formatting(doc, FormatAction::Bold); + return; + } + "i" => { + formatting::apply_formatting(doc, FormatAction::Italic); + return; + } + _ => {} + } + } + + // Insert character at cursor + if doc.selection.is_some() { + // Delete selection first + let sel = doc.selection.unwrap(); + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + + doc.rope.insert(doc.cursor.offset, &ch); + doc.cursor.offset += ch.chars().count(); + } + + Key::Backspace => { + if let Some(sel) = doc.selection { + // Delete selection + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } else if doc.cursor.offset > 0 { + // Delete previous char + let prev = doc.cursor.offset - 1; + doc.rope.remove(prev..doc.cursor.offset); + doc.cursor.offset = prev; + } + } + + Key::Delete => { + if let Some(sel) = doc.selection { + // Delete selection + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } else if doc.cursor.offset < doc.len_chars() { + // Delete next char + doc.rope.remove(doc.cursor.offset..doc.cursor.offset + 1); + } + } + + Key::ArrowLeft => { + if mods.ctrl() { + // Word boundary (implement later) + if doc.cursor.offset > 0 { + doc.cursor.offset -= 1; + } + } else if doc.cursor.offset > 0 { + doc.cursor.offset -= 1; + } + doc.selection = None; + } + + Key::ArrowRight => { + if mods.ctrl() { + // Word boundary (implement later) + if doc.cursor.offset < doc.len_chars() { + doc.cursor.offset += 1; + } + } else if doc.cursor.offset < doc.len_chars() { + doc.cursor.offset += 1; + } + doc.selection = None; + } + + Key::Enter => { + if doc.selection.is_some() { + let sel = doc.selection.unwrap(); + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + doc.rope.remove(start..end); + doc.cursor.offset = start; + doc.selection = None; + } + // Insert two spaces + newline for hard line break + doc.rope.insert(doc.cursor.offset, " \n"); + doc.cursor.offset += 3; + } + + Key::Home => { + let line_start = find_line_start(&doc.rope, doc.cursor.offset); + doc.cursor.offset = line_start; + doc.selection = None; + } + + Key::End => { + let line_end = find_line_end(&doc.rope, doc.cursor.offset); + doc.cursor.offset = line_end; + doc.selection = None; + } + + _ => {} + } + }); +} + +/// Find start of line containing offset +fn find_line_start(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { + // Search backwards from cursor for newline + let mut char_pos = 0; + let mut last_newline_pos = None; + + let rope = rope.borrow(); + for substr in rope.slice_substrings(0..offset) { + // TODO: make more efficient + for c in substr.chars() { + if c == '\n' { + last_newline_pos = Some(char_pos); + } + char_pos += 1; + } + } + + last_newline_pos.map(|pos| pos + 1).unwrap_or(0) +} + +/// Find end of line containing offset +fn find_line_end(rope: &jumprope::JumpRopeBuf, offset: usize) -> usize { + // Search forwards from cursor for newline + let mut char_pos = offset; + + let rope = rope.borrow(); + let byte_len = rope.len_bytes() - 1; + for substr in rope.slice_substrings(offset..byte_len) { + // TODO: make more efficient + for c in substr.chars() { + if c == '\n' { + return char_pos; + } + char_pos += 1; + } + } + + rope.len_chars() +} diff --git a/crates/weaver-app/src/components/editor/offset_map.rs b/crates/weaver-app/src/components/editor/offset_map.rs new file mode 100644 index 0000000..6c6a4b0 --- /dev/null +++ b/crates/weaver-app/src/components/editor/offset_map.rs @@ -0,0 +1,275 @@ +//! Offset mapping between source text and rendered DOM. +//! +//! When rendering markdown to HTML, some characters disappear (table pipes) +//! and content gets split across nodes (syntax highlighting). Offset maps +//! track how source byte positions map to DOM node positions. + +use std::ops::Range; + +/// Result of rendering markdown with offset tracking. +#[derive(Debug, Clone, PartialEq)] +pub struct RenderResult { + /// Rendered HTML string + pub html: String, + + /// Mappings from source bytes to DOM positions + pub offset_map: Vec, +} + +/// Maps a source range to a position in the rendered DOM. +/// +/// # Example +/// +/// Source: `| foo | bar |` +/// Bytes: 0 2-5 7-10 12 +/// Chars: 0 2-5 7-10 12 (ASCII, so same) +/// +/// Rendered: +/// ```html +/// +/// +///
foobar
+/// ``` +/// +/// Mappings: +/// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible +/// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible +/// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible +/// - etc. +#[derive(Debug, Clone, PartialEq)] +pub struct OffsetMapping { + /// Source byte range (UTF-8 bytes, from parser) + pub byte_range: Range, + + /// Source char range (Unicode scalar values, for rope indexing) + pub char_range: Range, + + /// DOM node ID containing this content + /// For invisible content, this is the nearest visible container + pub node_id: String, + + /// Position within the node + /// - If child_index is Some: cursor at that child index in the element + /// - If child_index is None: UTF-16 offset in text content + pub char_offset_in_node: usize, + + /// If Some, position cursor at this child index in the element (not in text) + /// Used for positions after
or at empty lines + pub child_index: Option, + + /// Length of this mapping in UTF-16 chars in DOM + /// If 0, these source bytes aren't rendered (table pipes, etc) + pub utf16_len: usize, +} + +impl OffsetMapping { + /// Check if this mapping contains the given byte offset + pub fn contains_byte(&self, byte_offset: usize) -> bool { + self.byte_range.contains(&byte_offset) + } + + /// Check if this mapping contains the given char offset + pub fn contains_char(&self, char_offset: usize) -> bool { + self.char_range.contains(&char_offset) + } + + /// Check if this mapping represents invisible content + pub fn is_invisible(&self) -> bool { + self.utf16_len == 0 + } +} + +/// Find the offset mapping containing the given byte offset. +/// +/// Returns the mapping and whether the cursor should snap to the next +/// visible position (for invisible content). +pub fn find_mapping_for_byte( + offset_map: &[OffsetMapping], + byte_offset: usize, +) -> Option<(&OffsetMapping, bool)> { + // Binary search for the mapping + // Note: We allow cursor at the end boundary of a mapping (cursor after text) + let idx = offset_map + .binary_search_by(|mapping| { + if mapping.byte_range.end < byte_offset { + std::cmp::Ordering::Less + } else if mapping.byte_range.start > byte_offset { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }) + .ok()?; + + let mapping = &offset_map[idx]; + let should_snap = mapping.is_invisible(); + + Some((mapping, should_snap)) +} + +/// Find the offset mapping containing the given char offset. +/// +/// This is the primary lookup method for cursor restoration, since +/// cursor positions are tracked as char offsets in the rope. +/// +/// Returns the mapping and whether the cursor should snap to the next +/// visible position (for invisible content). +pub fn find_mapping_for_char( + offset_map: &[OffsetMapping], + char_offset: usize, +) -> Option<(&OffsetMapping, bool)> { + // Binary search for the mapping + // Note: We allow cursor at the end boundary of a mapping (cursor after text) + // This makes ranges END-INCLUSIVE for cursor positioning + let idx = offset_map + .binary_search_by(|mapping| { + if mapping.char_range.end < char_offset { + // Cursor is after this mapping + std::cmp::Ordering::Less + } else if mapping.char_range.start > char_offset { + // Cursor is before this mapping + std::cmp::Ordering::Greater + } else { + // Cursor is within [start, end] OR exactly at end (inclusive) + // This handles cursor at position N matching range N-1..N + std::cmp::Ordering::Equal + } + }) + .ok()?; + + let mapping = &offset_map[idx]; + let should_snap = mapping.is_invisible(); + + Some((mapping, should_snap)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_mapping_by_byte() { + let mappings = vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, + }, + OffsetMapping { + byte_range: 5..7, + char_range: 5..7, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible + }, + ]; + + // Byte 0 (invisible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); + assert_eq!(mapping.byte_range, 0..2); + assert!(should_snap); + + // Byte 3 (visible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); + assert_eq!(mapping.byte_range, 2..5); + assert!(!should_snap); + + // Byte 6 (invisible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); + assert_eq!(mapping.byte_range, 5..7); + assert!(should_snap); + } + + #[test] + fn test_find_mapping_by_char() { + let mappings = vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, + }, + OffsetMapping { + byte_range: 5..7, + char_range: 5..7, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible + }, + ]; + + // Char 0 (invisible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); + assert_eq!(mapping.char_range, 0..2); + assert!(should_snap); + + // Char 3 (visible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); + assert_eq!(mapping.char_range, 2..5); + assert!(!should_snap); + + // Char 6 (invisible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); + assert_eq!(mapping.char_range, 5..7); + assert!(should_snap); + } + + #[test] + fn test_contains_byte() { + let mapping = OffsetMapping { + byte_range: 10..20, + char_range: 10..20, + node_id: "test".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }; + + assert!(!mapping.contains_byte(9)); + assert!(mapping.contains_byte(10)); + assert!(mapping.contains_byte(15)); + assert!(mapping.contains_byte(19)); + assert!(!mapping.contains_byte(20)); + } + + #[test] + fn test_contains_char() { + let mapping = OffsetMapping { + byte_range: 10..20, + char_range: 8..15, // emoji example: fewer chars than bytes + node_id: "test".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }; + + assert!(!mapping.contains_char(7)); + assert!(mapping.contains_char(8)); + assert!(mapping.contains_char(12)); + assert!(mapping.contains_char(14)); + assert!(!mapping.contains_char(15)); + } +} diff --git a/crates/weaver-app/src/components/editor/offsets.rs b/crates/weaver-app/src/components/editor/offsets.rs new file mode 100644 index 0000000..2882e50 --- /dev/null +++ b/crates/weaver-app/src/components/editor/offsets.rs @@ -0,0 +1,134 @@ +//! Offset conversion utilities for converting between different offset systems. +//! +//! The editor deals with multiple offset systems: +//! 1. **JumpRope**: Unicode scalar values (Rust `char` count) +//! 2. **markdown-weaver**: UTF-8 byte offsets +//! 3. **Rust strings**: UTF-8 byte indexing +//! 4. **JavaScript DOM**: UTF-16 code units (Phase 2+) +//! +//! # Performance Notes +//! +//! **Prefer JumpRope's built-in methods:** +//! - `rope.len_chars()` - O(1) character count +//! - `rope.len_bytes()` - O(1) byte count +//! - `rope.len_wchars()` - O(1) UTF-16 code unit count (Phase 2 with wchar_conversion) +//! +//! **Only use these conversion functions when:** +//! - Converting markdown-weaver byte offsets to char offsets +//! - Converting char offsets to byte offsets for markdown parsing +//! +//! For Phase 2+, use JumpRope's O(log n) UTF-16 conversions via the helpers below: +//! - `char_to_utf16()` - O(log n) +//! - `utf16_to_char()` - O(log n) + +/// Convert JumpRope char offset to UTF-8 byte offset. +/// +/// This is O(n) but acceptable for Phase 1 since we only render once per keystroke. +/// For Phase 2+, we can optimize by caching or using string-offsets crate. +/// +/// # Example +/// ``` +/// let text = "Hello 🐻‍❄️ World"; +/// // "Hello " = 6 chars, 6 bytes +/// // "🐻‍❄️" = 4 chars, 13 bytes +/// // Total at char 6 = byte 6 +/// assert_eq!(char_to_byte(text, 6), 6); +/// // Total at char 10 (after emoji) = byte 19 +/// assert_eq!(char_to_byte(text, 10), 19); +/// ``` +pub fn char_to_byte(text: &str, char_offset: usize) -> usize { + text.char_indices() + .nth(char_offset) + .map(|(byte_idx, _)| byte_idx) + .unwrap_or(text.len()) +} + +/// Convert UTF-8 byte offset to JumpRope char offset. +/// +/// Used when we need to map markdown-weaver byte offsets back to rope positions. +/// +/// # Example +/// ``` +/// let text = "Hello 🐻‍❄️ World"; +/// assert_eq!(byte_to_char(text, 6), 6); +/// assert_eq!(byte_to_char(text, 19), 10); +/// ``` +pub fn byte_to_char(text: &str, byte_offset: usize) -> usize { + text.char_indices() + .take_while(|(idx, _)| *idx < byte_offset) + .count() +} + +/// Convert JumpRope char offset to UTF-16 code units (for DOM Selection API). +/// +/// O(log n) - uses JumpRope's internal index. +/// +/// # Example +/// ``` +/// let rope = JumpRopeBuf::from("🐻‍❄️"); +/// // Polar bear is 4 chars, 5 UTF-16 code units +/// assert_eq!(char_to_utf16(&rope, 0), 0); +/// assert_eq!(char_to_utf16(&rope, 4), 5); +/// ``` +pub fn char_to_utf16(rope: &jumprope::JumpRopeBuf, char_offset: usize) -> usize { + rope.borrow().chars_to_wchars(char_offset) +} + +/// Convert UTF-16 code units (from DOM) to JumpRope char offset. +/// +/// O(log n) - uses JumpRope's internal index. +/// +/// # Example +/// ``` +/// let rope = JumpRopeBuf::from("🐻‍❄️"); +/// assert_eq!(utf16_to_char(&rope, 0), 0); +/// assert_eq!(utf16_to_char(&rope, 5), 4); +/// ``` +pub fn utf16_to_char(rope: &jumprope::JumpRopeBuf, utf16_offset: usize) -> usize { + rope.borrow().wchars_to_chars(utf16_offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ascii() { + let text = "hello"; + assert_eq!(char_to_byte(text, 0), 0); + assert_eq!(char_to_byte(text, 2), 2); + assert_eq!(byte_to_char(text, 0), 0); + assert_eq!(byte_to_char(text, 2), 2); + } + + #[test] + fn test_emoji() { + // Polar bear: 4 chars, 13 bytes + let text = "🐻‍❄️"; + assert_eq!(text.chars().count(), 4); + assert_eq!(text.len(), 13); + + assert_eq!(char_to_byte(text, 0), 0); + assert_eq!(char_to_byte(text, 4), 13); + + assert_eq!(byte_to_char(text, 0), 0); + assert_eq!(byte_to_char(text, 13), 4); + } + + #[test] + fn test_mixed() { + let text = "Hello 🐻‍❄️ World"; + // "Hello " = 6 chars, 6 bytes + // "🐻‍❄️" = 4 chars, 13 bytes + // " World" = 6 chars, 6 bytes + // Total: 16 chars, 25 bytes + + assert_eq!(text.chars().count(), 16); + assert_eq!(text.len(), 25); + + // Char 6 is start of emoji (byte 6) + assert_eq!(char_to_byte(text, 6), 6); + // Char 10 is after emoji (byte 19) + assert_eq!(char_to_byte(text, 10), 19); + } +} diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs new file mode 100644 index 0000000..f6af268 --- /dev/null +++ b/crates/weaver-app/src/components/editor/render.rs @@ -0,0 +1,51 @@ +//! Markdown rendering for the editor. +//! +//! Phase 2: Full-document rendering with formatting characters visible as styled spans. +//! Future: Incremental paragraph rendering and contextual formatting visibility. +//! +//! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. + +use markdown_weaver::Parser; +use super::offset_map::RenderResult; +use super::writer::EditorWriter; + +/// Render markdown to HTML with visible formatting characters and offset mappings. +/// +/// This function performs a full re-render of the document on every change. +/// Formatting characters (**, *, #, etc) are wrapped in styled spans for visibility. +/// +/// Uses EditorWriter which processes offset_iter events to detect consumed +/// formatting characters and emit them as `` elements. +/// +/// Returns both the rendered HTML and offset mappings for cursor restoration. +/// +/// # Phase 2 features +/// - Formatting characters visible (wrapped in .md-syntax-inline and .md-syntax-block) +/// - Offset map generation for cursor restoration +/// - Full document re-render (fast enough for current needs) +/// +/// # Future improvements +/// - Paragraph-level incremental rendering +/// - Contextual formatting hiding based on cursor position +pub fn render_markdown_simple(source: &str) -> RenderResult { + use jumprope::JumpRopeBuf; + + let source_rope = JumpRopeBuf::from(source); + let parser = Parser::new_ext(source, weaver_renderer::default_md_options()) + .into_offset_iter(); + let mut output = String::new(); + + match EditorWriter::<_, _, ()>::new(source, &source_rope, parser, &mut output).run() { + Ok(offset_map) => RenderResult { + html: output, + offset_map, + }, + Err(_) => { + // Fallback to empty result on error + RenderResult { + html: String::new(), + offset_map: Vec::new(), + } + } + } +} diff --git a/crates/weaver-app/src/components/editor/rope_writer.rs b/crates/weaver-app/src/components/editor/rope_writer.rs new file mode 100644 index 0000000..e0ee68e --- /dev/null +++ b/crates/weaver-app/src/components/editor/rope_writer.rs @@ -0,0 +1,81 @@ +//! StrWrite wrapper for JumpRopeBuf to enable efficient HTML rendering. + +use jumprope::JumpRopeBuf; +use markdown_weaver_escape::StrWrite; + +/// Wrapper around JumpRopeBuf that implements StrWrite. +/// +/// This allows rendering HTML directly into a rope structure, enabling: +/// - O(log n) insertions instead of O(n) string reallocation +/// - Efficient splicing for incremental rendering +/// - Fast paragraph replacement in cached output +pub struct RopeWriter { + rope: JumpRopeBuf, +} + +impl RopeWriter { + pub fn new() -> Self { + Self { + rope: JumpRopeBuf::new(), + } + } + + pub fn from_rope(rope: JumpRopeBuf) -> Self { + Self { rope } + } + + pub fn into_rope(self) -> JumpRopeBuf { + self.rope + } + + pub fn as_rope(&self) -> &JumpRopeBuf { + &self.rope + } + + pub fn to_string(&self) -> String { + self.rope.to_string() + } +} + +impl Default for RopeWriter { + fn default() -> Self { + Self::new() + } +} + +impl StrWrite for RopeWriter { + type Error = std::convert::Infallible; + + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { + let offset = self.rope.len_chars(); + self.rope.insert(offset, s); + Ok(()) + } + + fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> Result<(), Self::Error> { + let mut temp = String::new(); + std::fmt::Write::write_fmt(&mut temp, args).unwrap(); + self.write_str(&temp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rope_writer_basic() { + let mut writer = RopeWriter::new(); + writer.write_str("hello ").unwrap(); + writer.write_str("world").unwrap(); + assert_eq!(writer.to_string(), "hello world"); + } + + #[test] + fn test_rope_writer_fmt() { + use std::fmt::Write; + let mut writer = RopeWriter::new(); + write!(&mut writer, "number: {}", 42).unwrap(); + assert_eq!(writer.to_string(), "number: 42"); + } +} diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs new file mode 100644 index 0000000..2c64d63 --- /dev/null +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -0,0 +1,58 @@ +//! LocalStorage persistence for the editor. +//! +//! Only available on WASM targets. + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +use gloo_storage::{LocalStorage, Storage}; +use serde::{Deserialize, Serialize}; + +/// Editor snapshot for persistence. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct EditorSnapshot { + pub content: String, + pub cursor_offset: usize, +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +const STORAGE_KEY: &str = "weaver_editor_draft"; + +/// Save editor state to LocalStorage (WASM only). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn save_to_storage( + content: &str, + cursor_offset: usize, +) -> Result<(), gloo_storage::errors::StorageError> { + let snapshot = EditorSnapshot { + content: content.to_string(), + cursor_offset, + }; + LocalStorage::set(STORAGE_KEY, &snapshot) +} + +/// Load editor state from LocalStorage (WASM only). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn load_from_storage() -> Option { + LocalStorage::get(STORAGE_KEY).ok() +} + +/// Clear editor state from LocalStorage (WASM only). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +#[allow(dead_code)] +pub fn clear_storage() { + LocalStorage::delete(STORAGE_KEY); +} + +// Stub implementations for non-WASM targets +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn save_to_storage(_content: &str, _cursor_offset: usize) -> Result<(), String> { + Ok(()) +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn load_from_storage() -> Option { + None +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +#[allow(dead_code)] +pub fn clear_storage() {} diff --git a/crates/weaver-app/src/components/editor/toolbar.rs b/crates/weaver-app/src/components/editor/toolbar.rs new file mode 100644 index 0000000..7ff0435 --- /dev/null +++ b/crates/weaver-app/src/components/editor/toolbar.rs @@ -0,0 +1,96 @@ +//! Editor toolbar component with formatting buttons. + +use super::formatting::FormatAction; +use dioxus::prelude::*; + +/// Editor toolbar with formatting buttons. +/// +/// Provides buttons for common markdown formatting operations. +#[component] +pub fn EditorToolbar(on_format: EventHandler) -> Element { + rsx! { + div { class: "editor-toolbar", + button { + class: "toolbar-button", + title: "Bold (Ctrl+B)", + onclick: move |_| on_format.call(FormatAction::Bold), + "B" + } + button { + class: "toolbar-button", + title: "Italic (Ctrl+I)", + onclick: move |_| on_format.call(FormatAction::Italic), + "I" + } + button { + class: "toolbar-button", + title: "Strikethrough", + onclick: move |_| on_format.call(FormatAction::Strikethrough), + "S" + } + button { + class: "toolbar-button", + title: "Code", + onclick: move |_| on_format.call(FormatAction::Code), + "<>" + } + + span { class: "toolbar-separator" } + + button { + class: "toolbar-button", + title: "Heading 1", + onclick: move |_| on_format.call(FormatAction::Heading(1)), + "H1" + } + button { + class: "toolbar-button", + title: "Heading 2", + onclick: move |_| on_format.call(FormatAction::Heading(2)), + "H2" + } + button { + class: "toolbar-button", + title: "Heading 3", + onclick: move |_| on_format.call(FormatAction::Heading(3)), + "H3" + } + + span { class: "toolbar-separator" } + + button { + class: "toolbar-button", + title: "Bullet List", + onclick: move |_| on_format.call(FormatAction::BulletList), + "•" + } + button { + class: "toolbar-button", + title: "Numbered List", + onclick: move |_| on_format.call(FormatAction::NumberedList), + "1." + } + button { + class: "toolbar-button", + title: "Quote", + onclick: move |_| on_format.call(FormatAction::Quote), + "❝" + } + + span { class: "toolbar-separator" } + + button { + class: "toolbar-button", + title: "Link", + onclick: move |_| on_format.call(FormatAction::Link), + "🔗" + } + button { + class: "toolbar-button", + title: "Image", + onclick: move |_| on_format.call(FormatAction::Image), + "🖼" + } + } + } +} diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs new file mode 100644 index 0000000..e854a39 --- /dev/null +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -0,0 +1,1495 @@ +//! HTML writer for markdown editor with visible formatting characters. +//! +//! Based on ClientWriter from weaver-renderer, but modified to preserve +//! formatting characters (**, *, #, etc) wrapped in styled spans. +//! +//! Uses Parser::into_offset_iter() to track gaps between events, which +//! represent consumed formatting characters. + +use super::offset_map::{OffsetMapping, RenderResult}; +use super::offsets::{byte_to_char, char_to_byte}; +use jumprope::JumpRopeBuf; +use markdown_weaver::{ + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, +}; +use markdown_weaver_escape::{ + StrWrite, escape_href, escape_html, escape_html_body_text, + escape_html_body_text_with_char_count, +}; +use std::collections::HashMap; +use std::ops::Range; + +/// Classification of markdown syntax characters +#[derive(Debug, Clone, Copy, PartialEq)] +enum SyntaxClass { + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) + Inline, + /// Block formatting: #, >, -, *, 1., ```, --- + Block, +} + +/// Classify syntax text as inline or block level +fn classify_syntax(text: &str) -> SyntaxClass { + let trimmed = text.trim_start(); + + // Check for block-level markers + if trimmed.starts_with('#') + || trimmed.starts_with('>') + || trimmed.starts_with("```") + || trimmed.starts_with("---") + || (trimmed.starts_with('-') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || (trimmed.starts_with('*') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || trimmed + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + && trimmed.contains('.') + { + SyntaxClass::Block + } else { + SyntaxClass::Inline + } +} + +/// Synchronous callback for injecting embed content +/// +/// Takes the embed tag and returns optional HTML content to inject. +pub trait EmbedContentProvider { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option; +} + +impl EmbedContentProvider for () { + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { + None + } +} + +/// HTML writer that preserves markdown formatting characters. +/// +/// This writer processes offset-iter events to detect gaps (consumed formatting) +/// and emits them as styled spans for visibility in the editor. +pub struct EditorWriter<'a, I: Iterator, Range)>, W: StrWrite, E = ()> { + source: &'a str, + source_rope: &'a JumpRopeBuf, + events: I, + writer: W, + last_byte_offset: usize, + last_char_offset: usize, + + end_newline: bool, + in_non_writing_block: bool, + + table_state: TableState, + table_alignments: Vec, + table_cell_index: usize, + + numbers: HashMap, + + embed_provider: Option, + + code_buffer: Option<(Option, String)>, // (lang, content) + code_buffer_byte_range: Option>, // byte range of buffered code content + pending_blockquote_range: Option>, // range for emitting > inside next paragraph + + // Table rendering mode + render_tables_as_markdown: bool, + table_start_offset: Option, // track start of table for markdown rendering + + // Offset mapping tracking + offset_maps: Vec, + next_node_id: usize, + current_node_id: Option, // node ID for current text container + current_node_char_offset: usize, // UTF-16 offset within current node + current_node_child_count: usize, // number of child elements/text nodes in current container + + _phantom: std::marker::PhantomData<&'a ()>, +} + +#[derive(Debug, Clone, Copy)] +enum TableState { + Head, + Body, +} + +impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedContentProvider> + EditorWriter<'a, I, W, E> +{ + pub fn new(source: &'a str, source_rope: &'a JumpRopeBuf, events: I, writer: W) -> Self { + Self { + source, + source_rope, + events, + writer, + last_byte_offset: 0, + last_char_offset: 0, + end_newline: true, + in_non_writing_block: false, + table_state: TableState::Head, + table_alignments: vec![], + table_cell_index: 0, + numbers: HashMap::new(), + embed_provider: None, + code_buffer: None, + code_buffer_byte_range: None, + pending_blockquote_range: None, + render_tables_as_markdown: true, // Default to markdown rendering + table_start_offset: None, + offset_maps: Vec::new(), + next_node_id: 0, + current_node_id: None, + current_node_char_offset: 0, + current_node_child_count: 0, + _phantom: std::marker::PhantomData, + } + } + + /// Add an embed content provider + pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> { + EditorWriter { + source: self.source, + source_rope: self.source_rope, + events: self.events, + writer: self.writer, + last_byte_offset: self.last_byte_offset, + last_char_offset: self.last_char_offset, + end_newline: self.end_newline, + in_non_writing_block: self.in_non_writing_block, + table_state: self.table_state, + table_alignments: self.table_alignments, + table_cell_index: self.table_cell_index, + numbers: self.numbers, + embed_provider: Some(provider), + code_buffer: self.code_buffer, + code_buffer_byte_range: self.code_buffer_byte_range, + pending_blockquote_range: self.pending_blockquote_range, + render_tables_as_markdown: self.render_tables_as_markdown, + table_start_offset: self.table_start_offset, + offset_maps: self.offset_maps, + next_node_id: self.next_node_id, + current_node_id: self.current_node_id, + current_node_char_offset: self.current_node_char_offset, + current_node_child_count: self.current_node_child_count, + _phantom: std::marker::PhantomData, + } + } + #[inline] + fn write_newline(&mut self) -> Result<(), W::Error> { + self.end_newline = true; + self.writer.write_str("\n") + } + + #[inline] + fn write(&mut self, s: &str) -> Result<(), W::Error> { + self.writer.write_str(s)?; + if !s.is_empty() { + self.end_newline = s.ends_with('\n'); + } + Ok(()) + } + + /// Emit syntax span for a given range and record offset mapping + fn emit_syntax(&mut self, range: Range) -> Result<(), W::Error> { + if range.start < range.end { + let syntax = &self.source[range.clone()]; + if !syntax.is_empty() { + let class = match classify_syntax(syntax) { + SyntaxClass::Inline => "md-syntax-inline", + SyntaxClass::Block => "md-syntax-block", + }; + + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + + tracing::debug!( + "emit_syntax: range={:?}, chars={}..{}, syntax={:?}", + range, + char_start, + char_start + syntax_char_len, + syntax + ); + + // If we're outside any node, create a wrapper span for tracking + let created_node = if self.current_node_id.is_none() { + let node_id = self.gen_node_id(); + write!( + &mut self.writer, + "", + node_id, class + )?; + self.begin_node(node_id); + true + } else { + self.write("")?; + false + }; + + escape_html(&mut self.writer, syntax)?; + self.write("")?; + + // Record offset mapping for this syntax + self.record_mapping(range.clone(), char_start..char_start + syntax_char_len); + self.last_char_offset = char_start + syntax_char_len; + self.last_byte_offset = range.end; // Mark bytes as processed + + // Close wrapper if we created one + if created_node { + self.write("")?; + self.end_node(); + } + } + } + Ok(()) + } + + /// Emit any gap between last position and next offset + fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), W::Error> { + // Skip gap emission if we're inside a table being rendered as markdown + if self.table_start_offset.is_some() && self.render_tables_as_markdown { + return Ok(()); + } + + if next_offset > self.last_byte_offset { + self.emit_syntax(self.last_byte_offset..next_offset)?; + } + Ok(()) + } + + /// Generate a unique node ID + fn gen_node_id(&mut self) -> String { + let id = format!("n{}", self.next_node_id); + self.next_node_id += 1; + id + } + + /// Start tracking a new text container node + fn begin_node(&mut self, node_id: String) { + self.current_node_id = Some(node_id); + self.current_node_char_offset = 0; + self.current_node_child_count = 0; + } + + /// Stop tracking current node + fn end_node(&mut self) { + self.current_node_id = None; + self.current_node_char_offset = 0; + self.current_node_child_count = 0; + } + + /// Record an offset mapping for the given byte and char ranges. + /// + /// Computes UTF-16 length efficiently using the rope's internal indexing. + fn record_mapping(&mut self, byte_range: Range, char_range: Range) { + if let Some(ref node_id) = self.current_node_id { + // Use rope to convert char offsets to UTF-16 (wchar) offsets - O(log n) + let rope = self.source_rope.borrow(); + let wchar_start = rope.chars_to_wchars(char_range.start); + let wchar_end = rope.chars_to_wchars(char_range.end); + let utf16_len = wchar_end - wchar_start; + + let mapping = OffsetMapping { + byte_range, + char_range, + node_id: node_id.clone(), + char_offset_in_node: self.current_node_char_offset, + child_index: None, // text-based position + utf16_len, + }; + self.offset_maps.push(mapping); + self.current_node_char_offset += utf16_len; + } + } + + /// Process markdown events and write HTML. + /// + /// Returns the offset mappings. The HTML is written to the writer + /// passed in the constructor. + pub fn run(mut self) -> Result, W::Error> { + while let Some((event, range)) = self.events.next() { + // For End events, emit any trailing content within the event's range + // BEFORE calling end_tag (which calls end_node and clears current_node_id) + if matches!(&event, Event::End(_)) { + // Emit gap from last_byte_offset to range.end + // (emit_syntax handles char offset tracking) + self.emit_gap_before(range.end)?; + } else { + // For other events, emit any gap before range.start + // (emit_syntax handles char offset tracking) + self.emit_gap_before(range.start)?; + } + + // Process the event (passing range for tag syntax) + self.process_event(event, range.clone())?; + + // Update tracking + self.last_byte_offset = range.end; + } + + // Emit any trailing syntax + self.emit_gap_before(self.source.len())?; + + // Handle unmapped trailing content (stripped by parser) + // This includes trailing spaces that markdown ignores + let doc_byte_len = self.source.len(); + let doc_char_len = self.source_rope.len_chars(); + + if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { + tracing::debug!( + "Unmapped trailing content: bytes {}..{}, chars {}..{}", + self.last_byte_offset, + doc_byte_len, + self.last_char_offset, + doc_char_len + ); + + // Emit the trailing content as visible syntax + if self.last_byte_offset < doc_byte_len { + let trailing = &self.source[self.last_byte_offset..]; + if !trailing.is_empty() { + let char_start = self.last_char_offset; + let trailing_char_len = trailing.chars().count(); + + self.write("")?; + escape_html(&mut self.writer, trailing)?; + self.write("")?; + + // Record mapping if we have a node + if let Some(ref node_id) = self.current_node_id { + let mapping = OffsetMapping { + byte_range: self.last_byte_offset..doc_byte_len, + char_range: char_start..char_start + trailing_char_len, + node_id: node_id.clone(), + char_offset_in_node: self.current_node_char_offset, + child_index: None, + utf16_len: trailing_char_len, // visible + }; + self.offset_maps.push(mapping); + self.current_node_char_offset += trailing_char_len; + } + + self.last_char_offset = char_start + trailing_char_len; + } + } + } + + Ok(self.offset_maps) + } + + // Consume raw text events until end tag, for alt attributes + fn raw_text(&mut self) -> Result<(), W::Error> { + use Event::*; + let mut nest = 0; + while let Some((event, _range)) = self.events.next() { + match event { + Start(_) => nest += 1, + End(_) => { + if nest == 0 { + break; + } + nest -= 1; + } + Html(_) => {} + InlineHtml(text) | Code(text) | Text(text) => { + // Don't use escape_html_body_text here. + // The output of this function is used in the `alt` attribute. + escape_html(&mut self.writer, &text)?; + self.end_newline = text.ends_with('\n'); + } + InlineMath(text) => { + self.write("$")?; + escape_html(&mut self.writer, &text)?; + self.write("$")?; + } + DisplayMath(text) => { + self.write("$$")?; + escape_html(&mut self.writer, &text)?; + self.write("$$")?; + } + SoftBreak | HardBreak | Rule => { + self.write(" ")?; + } + FootnoteReference(name) => { + let len = self.numbers.len() + 1; + let number = *self.numbers.entry(name.into_string()).or_insert(len); + write!(&mut self.writer, "[{}]", number)?; + } + TaskListMarker(true) => self.write("[x]")?, + TaskListMarker(false) => self.write("[ ]")?, + WeaverBlock(_) => {} + } + } + Ok(()) + } + + fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), W::Error> { + use Event::*; + + tracing::debug!( + "Event: {:?}, range: {:?}", + match &event { + Start(tag) => format!("Start({:?})", tag), + End(tag) => format!("End({:?})", tag), + Text(t) => format!("Text({:?})", &t[..t.len().min(20)]), + _ => format!("{:?}", event), + }, + range + ); + match event { + Start(tag) => self.start_tag(tag, range)?, + End(tag) => self.end_tag(tag, range)?, + Text(text) => { + // If buffering code, append to buffer instead of writing + if let Some((_, ref mut buffer)) = self.code_buffer { + buffer.push_str(&text); + + // Track byte range for code block content + if let Some(ref mut code_range) = self.code_buffer_byte_range { + // Extend existing range + code_range.end = range.end; + } else { + // First text in code block - start tracking + self.code_buffer_byte_range = Some(range.clone()); + } + } else if !self.in_non_writing_block { + // Escape HTML and count chars in one pass + let char_start = self.last_char_offset; + let text_char_len = + escape_html_body_text_with_char_count(&mut self.writer, &text)?; + let char_end = char_start + text_char_len; + + tracing::debug!( + "Text event: range={:?}, chars={}..{}, text={:?}", + range, + char_start, + char_end, + &text[..text.len().min(40)] + ); + + // Text becomes a text node child of the current container + if text_char_len > 0 { + self.current_node_child_count += 1; + } + + // Record offset mapping + self.record_mapping(range.clone(), char_start..char_end); + + // Update char offset tracking + self.last_char_offset = char_end; + self.end_newline = text.ends_with('\n'); + } + } + Code(text) => { + // Emit opening backtick + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if raw_text.starts_with('`') { + self.write("`")?; + } + } + + self.write("")?; + + // Track offset mapping for code content + let char_start = self.last_char_offset; + let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?; + let char_end = char_start + text_char_len; + + // Record offset mapping (code content is visible) + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + + self.write("")?; + + // Emit closing backtick + if range.start < range.end { + let raw_text = &self.source[range]; + if raw_text.ends_with('`') { + self.write("`")?; + } + } + } + InlineMath(text) => { + // Emit opening $ + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if raw_text.starts_with('$') { + self.write("$")?; + } + } + + self.write(r#""#)?; + escape_html(&mut self.writer, &text)?; + self.write("")?; + + // Emit closing $ + if range.start < range.end { + let raw_text = &self.source[range]; + if raw_text.ends_with('$') { + self.write("$")?; + } + } + } + DisplayMath(text) => { + // Emit opening $$ + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if raw_text.starts_with("$$") { + self.write("$$")?; + } + } + + self.write(r#""#)?; + escape_html(&mut self.writer, &text)?; + self.write("")?; + + // Emit closing $$ + if range.start < range.end { + let raw_text = &self.source[range]; + if raw_text.ends_with("$$") { + self.write("$$")?; + } + } + } + Html(html) | InlineHtml(html) => { + // Track offset mapping for raw HTML + let char_start = self.last_char_offset; + let html_char_len = html.chars().count(); + let char_end = char_start + html_char_len; + + self.write(&html)?; + + // Record mapping for inline HTML + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + } + SoftBreak => self.write_newline()?, + HardBreak => { + // Emit the two spaces as visible (dimmed) text, then
+ let gap = &self.source[range.clone()]; + if gap.ends_with('\n') { + let spaces = &gap[..gap.len() - 1]; // everything except the \n + let char_start = byte_to_char(self.source, range.start); + let spaces_char_len = spaces.chars().count(); + + // Emit and map the visible spaces + self.write("")?; + escape_html(&mut self.writer, spaces)?; + self.write("")?; + + // Count this span as a child + self.current_node_child_count += 1; + + self.record_mapping( + range.start..range.start + spaces.len(), + char_start..char_start + spaces_char_len, + ); + + // Now the actual line break
+ self.write("
")?; + + // Count the
as a child + self.current_node_child_count += 1; + + // Map the newline to an element-based position (after the
) + // The binary search is end-inclusive, so cursor at position N+1 + // will match a mapping with range N..N+1 + if let Some(ref node_id) = self.current_node_id { + let newline_char_offset = char_start + spaces_char_len; + let mapping = OffsetMapping { + byte_range: range.start + spaces.len()..range.end, + char_range: newline_char_offset..newline_char_offset + 1, + node_id: node_id.clone(), + char_offset_in_node: 0, + child_index: Some(self.current_node_child_count), + utf16_len: 0, + }; + self.offset_maps.push(mapping); + } + + self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n + } else { + // Fallback: just
+ self.write("
")?; + } + } + Rule => { + if !self.end_newline { + self.write("\n")?; + } + + // Emit syntax span before the rendered element + if range.start < range.end { + let raw_text = &self.source[range]; + let trimmed = raw_text.trim(); + if !trimmed.is_empty() { + self.write("")?; + escape_html(&mut self.writer, trimmed)?; + self.write("\n")?; + } + } + + // Wrap
in toggle-block for future cursor-based toggling + self.write("

\n")?; + } + FootnoteReference(name) => { + let len = self.numbers.len() + 1; + self.write("")?; + let number = *self.numbers.entry(name.to_string()).or_insert(len); + write!(&mut self.writer, "{}", number)?; + self.write("")?; + } + TaskListMarker(checked) => { + // Emit the [ ] or [x] syntax + if range.start < range.end { + let raw_text = &self.source[range]; + if let Some(bracket_pos) = raw_text.find('[') { + let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); + let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write(" ")?; + } + } + + if checked { + self.write("\n")?; + } else { + self.write("\n")?; + } + } + WeaverBlock(_) => {} + } + Ok(()) + } + + fn start_tag(&mut self, tag: Tag<'_>, range: Range) -> Result<(), W::Error> { + // Check if this is a block-level tag that should have syntax inside + let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); + + // For inline tags, emit syntax before tag + if !is_block_tag && range.start < range.end { + let raw_text = &self.source[range.clone()]; + let opening_syntax = match &tag { + Tag::Strong => { + if raw_text.starts_with("**") { + Some("**") + } else if raw_text.starts_with("__") { + Some("__") + } else { + None + } + } + Tag::Emphasis => { + if raw_text.starts_with("*") { + Some("*") + } else if raw_text.starts_with("_") { + Some("_") + } else { + None + } + } + Tag::Strikethrough => { + if raw_text.starts_with("~~") { + Some("~~") + } else { + None + } + } + Tag::Link { .. } => { + if raw_text.starts_with('[') { + Some("[") + } else { + None + } + } + _ => None, + }; + + if let Some(syntax) = opening_syntax { + let class = match classify_syntax(syntax) { + SyntaxClass::Inline => "md-syntax-inline", + SyntaxClass::Block => "md-syntax-block", + }; + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + } + } + + // Emit the opening tag + match tag { + Tag::HtmlBlock => Ok(()), + Tag::Paragraph => { + let node_id = self.gen_node_id(); + if self.end_newline { + write!(&mut self.writer, "

", node_id)?; + } else { + write!(&mut self.writer, "\n

", node_id)?; + } + self.begin_node(node_id.clone()); + + // Map the start position of the paragraph (before any content) + // This allows cursor to be placed at the very beginning + let para_start_char = self.last_char_offset; + let mapping = OffsetMapping { + byte_range: range.start..range.start, + char_range: para_start_char..para_start_char, + node_id, + char_offset_in_node: 0, + child_index: Some(0), // position before first child + utf16_len: 0, + }; + self.offset_maps.push(mapping); + + // Emit > syntax if we're inside a blockquote + if let Some(bq_range) = self.pending_blockquote_range.take() { + if bq_range.start < bq_range.end { + let raw_text = &self.source[bq_range]; + if let Some(gt_pos) = raw_text.find('>') { + // Extract > [!NOTE] or just > + let after_gt = &raw_text[gt_pos + 1..]; + let syntax_end = if after_gt.trim_start().starts_with("[!") { + // Find the closing ] + if let Some(close_bracket) = after_gt.find(']') { + gt_pos + 1 + close_bracket + 1 + } else { + gt_pos + 1 + } + } else { + // Just > and maybe a space + (gt_pos + 2).min(raw_text.len()) + }; + + let syntax = &raw_text[gt_pos..syntax_end]; + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write(" ")?; // Add space after + } + } + } + Ok(()) + } + Tag::Heading { + level, + id, + classes, + attrs, + } => { + if !self.end_newline { + self.write("\n")?; + } + + // Generate node ID for offset tracking + let node_id = self.gen_node_id(); + + self.write("<")?; + write!(&mut self.writer, "{}", level)?; + + // Add our tracking ID as data attribute (preserve user's id if present) + self.write(" data-node-id=\"")?; + self.write(&node_id)?; + self.write("\"")?; + + if let Some(id) = id { + self.write(" id=\"")?; + escape_html(&mut self.writer, &id)?; + self.write("\"")?; + } + if !classes.is_empty() { + self.write(" class=\"")?; + for (i, class) in classes.iter().enumerate() { + if i > 0 { + self.write(" ")?; + } + escape_html(&mut self.writer, class)?; + } + self.write("\"")?; + } + for (attr, value) in attrs { + self.write(" ")?; + escape_html(&mut self.writer, &attr)?; + if let Some(val) = value { + self.write("=\"")?; + escape_html(&mut self.writer, &val)?; + self.write("\"")?; + } else { + self.write("=\"\"")?; + } + } + self.write(">")?; + + // Begin node tracking for offset mapping + self.begin_node(node_id); + + // Emit # syntax inside the heading tag + if range.start < range.end { + let raw_text = &self.source[range]; + let count = level as usize; + let pattern = "#".repeat(count); + + // Find where the # actually starts (might have leading whitespace) + if let Some(hash_pos) = raw_text.find(&pattern) { + // Extract "# " or "## " etc + let syntax_start = hash_pos; + let syntax_end = (hash_pos + count + 1).min(raw_text.len()); + let syntax = &raw_text[syntax_start..syntax_end]; + + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + } + } + Ok(()) + } + Tag::Table(alignments) => { + if self.render_tables_as_markdown { + // Store start offset and skip HTML rendering + self.table_start_offset = Some(range.start); + self.in_non_writing_block = true; // Suppress content output + Ok(()) + } else { + self.table_alignments = alignments; + self.write("") + } + } + Tag::TableHead => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table_state = TableState::Head; + self.table_cell_index = 0; + self.write("") + } + } + Tag::TableRow => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table_cell_index = 0; + self.write("") + } + } + Tag::TableCell => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + match self.table_state { + TableState::Head => self.write(" self.write(" self.write(" style=\"text-align: left\">"), + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), + _ => self.write(">"), + } + } + } + Tag::BlockQuote(kind) => { + let class_str = match kind { + None => "", + Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", + Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", + Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", + Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", + Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", + }; + if self.end_newline { + write!(&mut self.writer, "\n", class_str)?; + } else { + write!(&mut self.writer, "\n\n", class_str)?; + } + + // Store range for emitting > inside the next paragraph + self.pending_blockquote_range = Some(range); + Ok(()) + } + Tag::CodeBlock(info) => { + if !self.end_newline { + self.write_newline()?; + } + + // Generate node ID for code block + let node_id = self.gen_node_id(); + + match info { + CodeBlockKind::Fenced(info) => { + // Emit opening ```language + if range.start < range.end { + let raw_text = &self.source[range]; + if let Some(fence_pos) = raw_text.find("```") { + let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); + let syntax = &raw_text[fence_pos..fence_end]; + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write("\n")?; + } + } + + let lang = info.split(' ').next().unwrap(); + let lang_opt = if lang.is_empty() { + None + } else { + Some(lang.to_string()) + }; + // Start buffering + self.code_buffer = Some((lang_opt, String::new())); + + // Begin node tracking for offset mapping + self.begin_node(node_id); + Ok(()) + } + CodeBlockKind::Indented => { + // Ignore indented code blocks (as per executive decision) + self.code_buffer = Some((None, String::new())); + + // Begin node tracking for offset mapping + self.begin_node(node_id); + Ok(()) + } + } + } + Tag::List(Some(1)) => { + if self.end_newline { + self.write("
    \n") + } else { + self.write("\n
      \n") + } + } + Tag::List(Some(start)) => { + if self.end_newline { + self.write("
        \n") + } + Tag::List(None) => { + if self.end_newline { + self.write("
\n") + } + } + TagEnd::TableHead => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("\n")?; + self.table_state = TableState::Body; + Ok(()) + } + } + TagEnd::TableRow => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("\n") + } + } + TagEnd::TableCell => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + match self.table_state { + TableState::Head => self.write("")?, + TableState::Body => self.write("")?, + } + self.table_cell_index += 1; + Ok(()) + } + } + TagEnd::BlockQuote(_) => self.write("\n"), + TagEnd::CodeBlock => { + use std::sync::LazyLock; + use syntect::parsing::SyntaxSet; + static SYNTAX_SET: LazyLock = + LazyLock::new(|| SyntaxSet::load_defaults_newlines()); + + if let Some((lang, buffer)) = self.code_buffer.take() { + // Create offset mapping for code block content if we tracked a range + if let Some(code_byte_range) = self.code_buffer_byte_range.take() { + // Calculate char range from the tracked byte range + let char_start = byte_to_char(self.source, code_byte_range.start); + let char_end = byte_to_char(self.source, code_byte_range.end); + let char_range = char_start..char_end; + + // Record mapping before writing HTML + // (current_node_id should be set by start_tag for CodeBlock) + self.record_mapping(code_byte_range, char_range); + } + + if let Some(ref lang_str) = lang { + // Use a temporary String buffer for syntect + let mut temp_output = String::new(); + match weaver_renderer::code_pretty::highlight( + &SYNTAX_SET, + Some(lang_str), + &buffer, + &mut temp_output, + ) { + Ok(_) => { + self.write(&temp_output)?; + } + Err(_) => { + // Fallback to plain code block + self.write("

")?;
+                                escape_html_body_text(&mut self.writer, &buffer)?;
+                                self.write("
\n")?; + } + } + } else { + self.write("
")?;
+                        escape_html_body_text(&mut self.writer, &buffer)?;
+                        self.write("
\n")?; + } + + // End node tracking + self.end_node(); + } else { + self.write("\n")?; + } + + // Emit closing ``` + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if let Some(fence_line) = raw_text.lines().last() { + if fence_line.trim() == "```" { + self.write("```")?; + } + } + } + + Ok(()) + } + TagEnd::List(true) => self.write("\n"), + TagEnd::List(false) => self.write("\n"), + TagEnd::Item => { + self.end_node(); + self.write("\n") + } + TagEnd::DefinitionList => self.write("\n"), + TagEnd::DefinitionListTitle => { + self.end_node(); + self.write("\n") + } + TagEnd::DefinitionListDefinition => { + self.end_node(); + self.write("\n") + } + TagEnd::Emphasis => self.write("
"), + TagEnd::Superscript => self.write(""), + TagEnd::Subscript => self.write(""), + TagEnd::Strong => self.write(""), + TagEnd::Strikethrough => self.write(""), + TagEnd::Link => self.write(""), + TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event + TagEnd::Embed => Ok(()), + TagEnd::WeaverBlock(_) => { + self.in_non_writing_block = false; + Ok(()) + } + TagEnd::FootnoteDefinition => self.write("\n"), + TagEnd::MetadataBlock(_) => { + self.in_non_writing_block = false; + Ok(()) + } + }; + + result?; + + // Extract and emit closing syntax based on tag type + if range.start < range.end { + let raw_text = &self.source[range]; + let closing_syntax = match &tag { + TagEnd::Strong => { + if raw_text.ends_with("**") { + Some("**") + } else if raw_text.ends_with("__") { + Some("__") + } else { + None + } + } + TagEnd::Emphasis => { + if raw_text.ends_with("*") { + Some("*") + } else if raw_text.ends_with("_") { + Some("_") + } else { + None + } + } + TagEnd::Strikethrough => { + if raw_text.ends_with("~~") { + Some("~~") + } else { + None + } + } + TagEnd::Link => { + // Extract ](url) part + if let Some(idx) = raw_text.rfind("](") { + Some(&raw_text[idx..]) + } else { + None + } + } + TagEnd::CodeBlock => { + if raw_text.ends_with("```") { + raw_text.lines().last() + } else { + None + } + } + _ => None, + }; + + if let Some(syntax) = closing_syntax { + let class = match classify_syntax(syntax) { + SyntaxClass::Inline => "md-syntax-inline", + SyntaxClass::Block => "md-syntax-block", + }; + self.write("")?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + } + } + + Ok(()) + } +} + +impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedContentProvider> + EditorWriter<'a, I, W, E> +{ + fn write_embed( + &mut self, + embed_type: EmbedType, + dest_url: CowStr<'_>, + title: CowStr<'_>, + id: CowStr<'_>, + attrs: Option>, + ) -> Result<(), W::Error> { + // Try to get content from attributes first + let content_from_attrs = if let Some(ref attrs) = attrs { + attrs + .attrs + .iter() + .find(|(k, _)| k.as_ref() == "content") + .map(|(_, v)| v.as_ref().to_string()) + } else { + None + }; + + // If no content in attrs, try provider + let content = if let Some(content) = content_from_attrs { + Some(content) + } else if let Some(ref provider) = self.embed_provider { + let tag = Tag::Embed { + embed_type, + dest_url: dest_url.clone(), + title: title.clone(), + id: id.clone(), + attrs: attrs.clone(), + }; + provider.get_embed_content(&tag) + } else { + None + }; + + if let Some(html_content) = content { + // Write the pre-rendered content directly + self.write(&html_content)?; + self.write_newline()?; + } else { + // Fallback: render as iframe + self.write("")?; + } + Ok(()) + } +} diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 247b3da..950e888 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -128,3 +128,4 @@ pub mod accordion; pub mod button; pub mod dialog; pub mod input; +pub mod editor; diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index caa58c1..6d326b0 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -17,7 +17,7 @@ use jacquard::{ use std::sync::{Arc, LazyLock}; #[allow(unused)] use views::{ - Callback, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordIndex, RecordPage, + Callback, Editor, Home, Navbar, Notebook, NotebookIndex, NotebookPage, RecordIndex, RecordPage, }; use crate::{ @@ -54,6 +54,8 @@ enum Route { #[layout(Navbar)] #[route("/")] Home {}, + #[route("/editor")] + Editor {}, #[layout(ErrorLayout)] #[nest("/record")] #[layout(RecordIndex)] diff --git a/crates/weaver-app/src/views/editor.rs b/crates/weaver-app/src/views/editor.rs index e69de29..cf437a4 100644 --- a/crates/weaver-app/src/views/editor.rs +++ b/crates/weaver-app/src/views/editor.rs @@ -0,0 +1,17 @@ +//! Editor view - wraps the MarkdownEditor component for the /editor route. + +use dioxus::prelude::*; +use crate::components::editor::MarkdownEditor; + +/// Editor page view. +/// +/// Displays the markdown editor at the /editor route for testing during development. +/// Eventually this will be integrated into the notebook editing workflow. +#[component] +pub fn Editor() -> Element { + rsx! { + div { class: "editor-page", + MarkdownEditor { initial_content: None } + } + } +} diff --git a/crates/weaver-app/src/views/mod.rs b/crates/weaver-app/src/views/mod.rs index 4e3503f..43dca23 100644 --- a/crates/weaver-app/src/views/mod.rs +++ b/crates/weaver-app/src/views/mod.rs @@ -27,3 +27,4 @@ mod callback; pub use callback::Callback; mod editor; +pub use editor::Editor;