From 25836bdbc2e7309ee6aeb3dcf127c01f28bc6109 Mon Sep 17 00:00:00 2001 From: Orual Date: Wed, 26 Nov 2025 20:24:02 -0500 Subject: [PATCH] notebook entry stuff! --- crates/weaver-app/assets/styling/editor.css | 325 +++- .../src/components/accordion/mod.rs | 2 +- .../weaver-app/src/components/avatar/mod.rs | 2 +- .../weaver-app/src/components/button/mod.rs | 2 +- .../weaver-app/src/components/dialog/mod.rs | 2 +- .../src/components/editor/component.rs | 658 +++++++ .../src/components/editor/cursor.rs | 26 +- .../src/components/editor/document.rs | 393 +++- .../src/components/editor/dom_sync.rs | 286 +++ .../src/components/editor/formatting.rs | 2 +- .../weaver-app/src/components/editor/input.rs | 639 +++++++ .../src/components/editor/log_buffer.rs | 6 +- .../weaver-app/src/components/editor/mod.rs | 1589 +---------------- .../src/components/editor/paragraph.rs | 1 - .../src/components/editor/platform.rs | 5 +- .../src/components/editor/publish.rs | 305 ++++ .../src/components/editor/render.rs | 6 +- .../src/components/editor/report.rs | 4 +- .../src/components/editor/storage.rs | 125 +- .../weaver-app/src/components/editor/tests.rs | 63 +- .../src/components/editor/visibility.rs | 150 +- crates/weaver-app/src/components/input/mod.rs | 2 +- crates/weaver-app/src/components/mod.rs | 2 +- crates/weaver-app/src/main.rs | 2 +- crates/weaver-app/src/views/editor.rs | 10 +- 25 files changed, 2884 insertions(+), 1723 deletions(-) create mode 100644 crates/weaver-app/src/components/editor/component.rs create mode 100644 crates/weaver-app/src/components/editor/dom_sync.rs create mode 100644 crates/weaver-app/src/components/editor/input.rs create mode 100644 crates/weaver-app/src/components/editor/publish.rs diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index d1fd20a..f37c14c 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -8,20 +8,143 @@ } .markdown-editor-container { - display: flex; - flex-direction: row; + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto 1fr; height: 100%; max-width: 1200px; margin: 0 auto; + padding: 0 20px; font-family: var(--font-body); background: var(--color-base); color: var(--color-text); + gap: 0 8px; +} + +/* Title bar */ +.editor-title-bar { + grid-column: 1; + padding: 16px 0 8px 0; +} + +.editor-title-bar .title-input { + width: 100%; + padding: 8px 0; + border: none; + border-bottom: 2px solid var(--color-border); + background: transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 24px; + font-weight: 500; + outline: none; +} + +.editor-title-bar .title-input:focus { + border-bottom-color: var(--color-primary); +} + +.editor-title-bar .title-input::placeholder { + color: var(--color-muted); +} + +/* Meta row - path, tags, and publish inline */ +.editor-meta-row { + grid-column: 1; + display: flex; + gap: 24px; + padding: 8px 0 16px 0; + align-items: flex-end; +} + +.editor-meta-row label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.meta-path { + flex-shrink: 0; +} + +.meta-path .path-input { + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-mono); + font-size: 13px; + min-width: 150px; +} + +.meta-path .path-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.meta-tags { + flex: 1; +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--color-overlay); + border-radius: 12px; + font-size: 13px; + color: var(--color-text); } +.tag-remove { + background: none; + border: none; + color: var(--color-muted); + cursor: pointer; + padding: 0 2px; + font-size: 14px; + line-height: 1; +} + +.tag-remove:hover { + color: var(--color-love); +} + +.tag-input { + padding: 4px 8px; + border: 1px dashed var(--color-border); + border-radius: 12px; + background: transparent; + color: var(--color-text); + font-family: var(--font-body); + font-size: 13px; + min-width: 80px; +} + +.tag-input:focus { + outline: none; + border-color: var(--color-primary); + border-style: solid; +} + +/* Editor content wrapper */ .editor-content-wrapper { + grid-column: 1; display: flex; flex-direction: column; - flex: 1; min-height: 0; } @@ -30,6 +153,7 @@ padding: 20px; overflow-y: auto; outline: none; + min-height: 700px; line-height: var(--spacing-line-height); font-size: 16px; background: var(--color-surface); @@ -42,12 +166,14 @@ } .editor-toolbar { + grid-column: 2; + grid-row: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px; background: var(--color-base); - flex-shrink: 0; + align-self: start; min-width: 60px; } @@ -75,10 +201,14 @@ .editor-debug { padding: 8px; + padding-right: 0; background: var(--color-base); font-family: var(--font-mono); font-size: 12px; - flex-shrink: 0; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; color: var(--color-muted); } @@ -119,10 +249,7 @@ li:has(.md-syntax-block:not(.hidden)) { /* Editor page header with report button */ .editor-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 6rem; + padding-left: 6rem; background: var(--color-base); } @@ -139,7 +266,6 @@ li:has(.md-syntax-block:not(.hidden)) { border-radius: 4px; color: var(--color-text); cursor: pointer; - margin-right: 3.5rem; font-size: 0.9rem; font-family: var(--font-body); } @@ -260,3 +386,182 @@ li:has(.md-syntax-block:not(.hidden)) { .report-submit:hover { opacity: 0.9; } + +/* Publish button and dialog - matches report dialog theming */ +.publish-button { + padding: 0.5rem 1rem; + background: var(--color-primary); + border: none; + border-radius: 4px; + color: var(--color-base); + cursor: pointer; + font-weight: 500; + font-family: var(--font-body); + margin-left: auto; + flex-shrink: 0; +} + +.publish-button:hover:not(:disabled) { + opacity: 0.9; +} + +.publish-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.publish-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.publish-dialog { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1.5rem; + max-width: 500px; + width: 90%; + color: var(--color-text); +} + +.publish-dialog h2 { + margin: 0 0 1rem 0; + color: var(--color-emphasis); +} + +.publish-field { + margin-bottom: 1rem; +} + +.publish-field label { + display: block; + margin-bottom: 0.5rem; + font-size: 12px; + font-weight: 500; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.publish-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + box-sizing: border-box; +} + +.publish-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.publish-preview { + background: var(--color-base); + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.publish-preview p { + margin: 0.25rem 0; + color: var(--color-subtle); +} + +.publish-error { + background: var(--color-love); + color: var(--color-base); + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.publish-success { + text-align: center; + padding: 1rem 0; +} + +.publish-success p { + color: var(--color-foam); + margin-bottom: 1rem; +} + +.publish-success a { + color: var(--color-primary); + text-decoration: none; +} + +.publish-success a:hover { + text-decoration: underline; +} + +.publish-done { + margin-top: 1rem; + padding: 0.5rem 1.5rem; + background: var(--color-primary); + border: none; + border-radius: 4px; + color: var(--color-base); + cursor: pointer; + font-family: var(--font-body); +} + +.publish-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +.publish-cancel { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text); + cursor: pointer; + font-family: var(--font-body); +} + +.publish-cancel:hover:not(:disabled) { + background: var(--color-overlay); +} + +.publish-cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.publish-submit { + padding: 0.5rem 1rem; + background: var(--color-primary); + border: none; + border-radius: 4px; + color: var(--color-base); + cursor: pointer; + font-weight: 500; + font-family: var(--font-body); +} + +.publish-submit:hover:not(:disabled) { + opacity: 0.9; +} + +.publish-submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/crates/weaver-app/src/components/accordion/mod.rs b/crates/weaver-app/src/components/accordion/mod.rs index 9a8ae55..2590c01 100644 --- a/crates/weaver-app/src/components/accordion/mod.rs +++ b/crates/weaver-app/src/components/accordion/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/crates/weaver-app/src/components/avatar/mod.rs b/crates/weaver-app/src/components/avatar/mod.rs index 9a8ae55..2590c01 100644 --- a/crates/weaver-app/src/components/avatar/mod.rs +++ b/crates/weaver-app/src/components/avatar/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/crates/weaver-app/src/components/button/mod.rs b/crates/weaver-app/src/components/button/mod.rs index 9a8ae55..2590c01 100644 --- a/crates/weaver-app/src/components/button/mod.rs +++ b/crates/weaver-app/src/components/button/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/crates/weaver-app/src/components/dialog/mod.rs b/crates/weaver-app/src/components/dialog/mod.rs index 9a8ae55..2590c01 100644 --- a/crates/weaver-app/src/components/dialog/mod.rs +++ b/crates/weaver-app/src/components/dialog/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs new file mode 100644 index 0000000..402fb84 --- /dev/null +++ b/crates/weaver-app/src/components/editor/component.rs @@ -0,0 +1,658 @@ +//! The main MarkdownEditor component. + +use dioxus::prelude::*; + +use crate::components::editor::ReportButton; + +use super::document::{CompositionState, EditorDocument}; +use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; +use super::formatting; +use super::input::{ + get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, +}; +use super::paragraph::ParagraphRender; +use super::platform; +use super::publish::PublishButton; +use super::render; +use super::storage; +use super::toolbar::EditorToolbar; +use super::visibility::update_syntax_visibility; +use super::writer::SyntaxSpanInfo; + +/// Main markdown editor component. +/// +/// # Props +/// - `initial_content`: Optional initial markdown content +/// +/// # Features +/// - Loro CRDT-based text storage with undo/redo support +/// - 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) +#[component] +pub fn MarkdownEditor(initial_content: Option) -> Element { + // Try to restore from localStorage (includes CRDT state for undo history) + // Use "current" as the default draft key for now + let draft_key = "current"; + let mut document = use_signal(move || { + storage::load_from_storage(draft_key) + .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) + }); + let editor_id = "markdown-editor"; + + // Cache for incremental paragraph rendering + let mut render_cache = use_signal(|| render::RenderCache::default()); + + // Render paragraphs with incremental caching + let paragraphs = use_memo(move || { + let doc = document(); + let cache = render_cache.peek(); + let edit = doc.last_edit.as_ref(); + + let (paras, new_cache) = + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); + + // Update cache for next render (write-only via spawn to avoid reactive loop) + dioxus::prelude::spawn(async move { + render_cache.set(new_cache); + }); + + paras + }); + + // Flatten offset maps from all paragraphs + let offset_map = use_memo(move || { + paragraphs() + .iter() + .flat_map(|p| p.offset_map.iter().cloned()) + .collect::>() + }); + + // Flatten syntax spans from all paragraphs + let syntax_spans = use_memo(move || { + paragraphs() + .iter() + .flat_map(|p| p.syntax_spans.iter().cloned()) + .collect::>() + }); + + // Cache paragraphs for change detection AND for event handlers to access + let mut cached_paragraphs = use_signal(|| Vec::::new()); + + // Update DOM when paragraphs change (incremental rendering) + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + tracing::debug!("DOM update effect triggered"); + + // Read document once to avoid multiple borrows + let doc = document(); + + tracing::debug!( + composition_active = doc.composition.is_some(), + cursor = doc.cursor.offset, + "DOM update: checking state" + ); + + // Skip DOM updates during IME composition - browser controls the preview + if doc.composition.is_some() { + tracing::debug!("skipping DOM update during composition"); + return; + } + + tracing::debug!( + cursor = doc.cursor.offset, + len = doc.len_chars(), + "DOM update proceeding (not in composition)" + ); + + let cursor_offset = doc.cursor.offset; + let selection = doc.selection; + drop(doc); // Release borrow before other operations + + let new_paras = paragraphs(); + let map = offset_map(); + let spans = syntax_spans(); + + // Use peek() to avoid creating reactive dependency on cached_paragraphs + let prev = cached_paragraphs.peek().clone(); + + let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); + + // Only restore cursor if we actually re-rendered the paragraph it's in + if cursor_para_updated { + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + + // Use requestAnimationFrame to wait for browser paint + if let Some(window) = web_sys::window() { + let closure = Closure::once(move || { + if let Err(e) = + super::cursor::restore_cursor_position(cursor_offset, &map, editor_id) + { + tracing::warn!("Cursor restoration failed: {:?}", e); + } + }); + + let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); + closure.forget(); + } + } + + // Store for next comparison AND for event handlers (write-only, no reactive read) + cached_paragraphs.set(new_paras.clone()); + + // Update syntax visibility after DOM changes + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); + }); + + // Track last saved frontiers to detect changes (peek-only, no subscriptions) + let mut last_saved_frontiers: Signal> = use_signal(|| None); + + // Auto-save with periodic check (no reactive dependency to avoid loops) + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + // Check every 500ms if there are unsaved changes + let interval = gloo_timers::callback::Interval::new(500, move || { + // Peek both signals without creating reactive dependencies + let current_frontiers = document.peek().state_frontiers(); + + // Only save if frontiers changed (document was edited) + let needs_save = { + let last_frontiers = last_saved_frontiers.peek(); + match &*last_frontiers { + None => true, // First save + Some(last) => ¤t_frontiers != last, + } + }; // drop last_frontiers borrow here + + if needs_save { + document.with_mut(|doc| { + doc.sync_loro_cursor(); + let _ = storage::save_to_storage(doc, draft_key, None); + }); + + // Update last saved frontiers + last_saved_frontiers.set(Some(current_frontiers)); + } + }); + interval.forget(); + }); + + // Set up beforeinput listener for iOS/Android virtual keyboard quirks + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + use_effect(move || { + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + + let plat = platform::platform(); + + // Only needed on mobile + if !plat.mobile { + return; + } + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let dom_document = match window.document() { + Some(d) => d, + None => return, + }; + let editor = match dom_document.get_element_by_id(editor_id) { + Some(e) => e, + None => return, + }; + + let mut document_signal = document; + let cached_paras = cached_paragraphs; + + let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { + let input_type = evt.input_type(); + tracing::debug!(input_type = %input_type, "beforeinput"); + + let plat = platform::platform(); + + // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak + // without proper keydown events. Handle them here. + if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { + tracing::debug!("iOS: intercepting {} via beforeinput", input_type); + evt.prevent_default(); + + // Handle as Enter key + document_signal.with_mut(|doc| { + if let Some(sel) = doc.selection.take() { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + } + + if input_type == "insertLineBreak" { + // Soft break (like Shift+Enter) + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); + doc.cursor.offset += 3; + } else { + // Paragraph break + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); + doc.cursor.offset += 2; + } + }); + } + + // Android workaround: When swipe keyboard picks a suggestion, + // DOM mutations fire before selection moves. We detect this pattern + // and defer cursor sync. + if plat.android && input_type == "insertText" { + // Check if this might be a suggestion pick (has data that looks like a word) + if let Some(data) = evt.data() { + if data.contains(' ') || data.len() > 3 { + tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); + // Defer cursor sync by 20ms to let selection settle + let paras = cached_paras; + let doc_sig = document_signal; + let window = web_sys::window(); + if let Some(window) = window { + let closure = Closure::once(move || { + let paras = paras(); + sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, ¶s); + }); + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( + closure.as_ref().unchecked_ref(), + 20, + ); + closure.forget(); + } + } + } + } + }) as Box); + + let _ = editor + .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); + closure.forget(); + }); + + // Local state for adding new tags + let mut new_tag = use_signal(String::new); + + rsx! { + Stylesheet { href: asset!("/assets/styling/editor.css") } + div { class: "markdown-editor-container", + // Title bar + div { class: "editor-title-bar", + input { + r#type: "text", + class: "title-input", + placeholder: "Entry title...", + value: "{document().title()}", + oninput: move |e| { + document.with_mut(|doc| doc.set_title(&e.value())); + }, + } + } + + // Meta row - path, tags, publish + div { class: "editor-meta-row", + div { class: "meta-path", + label { "Path" } + input { + r#type: "text", + class: "path-input", + placeholder: "url-slug", + value: "{document().path()}", + oninput: move |e| { + document.with_mut(|doc| doc.set_path(&e.value())); + }, + } + } + + div { class: "meta-tags", + label { "Tags" } + div { class: "tags-container", + for tag in document().tags() { + span { + class: "tag-chip", + "{tag}" + button { + class: "tag-remove", + onclick: { + let tag_to_remove = tag.clone(); + move |_| { + document.with_mut(|doc| doc.remove_tag(&tag_to_remove)); + } + }, + "×" + } + } + } + input { + r#type: "text", + class: "tag-input", + placeholder: "Add tag...", + value: "{new_tag}", + oninput: move |e| new_tag.set(e.value()), + onkeydown: move |e| { + use dioxus::prelude::keyboard_types::Key; + if e.key() == Key::Enter && !new_tag().trim().is_empty() { + e.prevent_default(); + let tag = new_tag().trim().to_string(); + document.with_mut(|doc| doc.add_tag(&tag)); + new_tag.set(String::new()); + } + }, + } + } + } + + PublishButton { + document: document, + draft_key: draft_key.to_string(), + } + } + + // Editor content + div { class: "editor-content-wrapper", + div { + id: "{editor_id}", + class: "editor-content", + contenteditable: "true", + + onkeydown: move |evt| { + use dioxus::prelude::keyboard_types::Key; + use std::time::Duration; + + let plat = platform::platform(); + let mods = evt.modifiers(); + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); + + // During IME composition: + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) + // - Allow Escape to cancel composition + // - Block text input (let browser handle composition preview) + if document.peek().composition.is_some() { + if evt.key() == Key::Escape { + tracing::debug!("Escape pressed - cancelling composition"); + document.with_mut(|doc| { + doc.composition = None; + }); + return; + } + + // Allow modifier shortcuts through during composition + if !has_modifier { + tracing::debug!( + key = ?evt.key(), + "keydown during composition - delegating to browser" + ); + return; + } + // Fall through to handle the shortcut + } + + // Safari workaround: After Japanese IME composition ends, both + // compositionend and keydown fire for Enter. Ignore keydown + // within 500ms of composition end to prevent double-newline. + if plat.safari && evt.key() == Key::Enter { + if let Some(ended_at) = document.peek().composition_ended_at { + if ended_at.elapsed() < Duration::from_millis(500) { + tracing::debug!( + "Safari: ignoring Enter within 500ms of compositionend" + ); + return; + } + } + } + + // Android workaround: Chrome Android gets confused by Enter during/after + // composition. Defer Enter handling to onkeypress instead. + if plat.android && evt.key() == Key::Enter { + tracing::debug!("Android: deferring Enter to keypress"); + return; + } + + // Only prevent default for operations that modify content + // Let browser handle arrow keys, Home/End naturally + if should_intercept_key(&evt) { + evt.prevent_default(); + handle_keydown(evt, &mut document); + } + }, + + onkeyup: move |evt| { + use dioxus::prelude::keyboard_types::Key; + + // Navigation keys (with or without Shift for selection) + let navigation = matches!( + evt.key(), + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | + Key::Home | Key::End | Key::PageUp | Key::PageDown + ); + + // Cmd/Ctrl+A for select all + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) + && matches!(evt.key(), Key::Character(ref c) if c == "a"); + + if navigation || select_all { + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + } + }, + + onselect: move |_evt| { + tracing::debug!("onselect fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onselectstart: move |_evt| { + tracing::debug!("onselectstart fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onselectionchange: move |_evt| { + tracing::debug!("onselectionchange fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + onclick: move |_evt| { + tracing::debug!("onclick fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut document, editor_id, ¶s); + let doc = document(); + let spans = syntax_spans(); + update_syntax_visibility( + doc.cursor.offset, + doc.selection.as_ref(), + &spans, + ¶s, + ); + }, + + // Android workaround: Handle Enter in keypress instead of keydown. + // Chrome Android fires confused composition events on Enter in keydown, + // but keypress fires after composition state settles. + onkeypress: move |evt| { + use dioxus::prelude::keyboard_types::Key; + + let plat = platform::platform(); + if plat.android && evt.key() == Key::Enter { + tracing::debug!("Android: handling Enter in keypress"); + evt.prevent_default(); + handle_keydown(evt, &mut document); + } + }, + + onpaste: move |evt| { + handle_paste(evt, &mut document); + }, + + oncut: move |evt| { + handle_cut(evt, &mut document); + }, + + oncopy: move |evt| { + handle_copy(evt, &document); + }, + + onblur: move |_| { + // Cancel any in-progress IME composition on focus loss + let had_composition = document.peek().composition.is_some(); + if had_composition { + tracing::debug!("onblur: clearing active composition"); + } + document.with_mut(|doc| { + doc.composition = None; + }); + }, + + oncompositionstart: move |evt: CompositionEvent| { + let data = evt.data().data(); + tracing::debug!( + data = %data, + "compositionstart" + ); + document.with_mut(|doc| { + // Delete selection if present (composition replaces it) + if let Some(sel) = doc.selection.take() { + let (start, end) = + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + tracing::debug!( + start, + end, + "compositionstart: deleting selection" + ); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + } + + tracing::debug!( + cursor = doc.cursor.offset, + "compositionstart: setting composition state" + ); + doc.composition = Some(CompositionState { + start_offset: doc.cursor.offset, + text: data, + }); + }); + }, + + oncompositionupdate: move |evt: CompositionEvent| { + let data = evt.data().data(); + tracing::debug!( + data = %data, + "compositionupdate" + ); + document.with_mut(|doc| { + if let Some(ref mut comp) = doc.composition { + comp.text = data; + } else { + tracing::debug!("compositionupdate without active composition state"); + } + }); + }, + + oncompositionend: move |evt: CompositionEvent| { + let final_text = evt.data().data(); + tracing::debug!( + data = %final_text, + "compositionend" + ); + document.with_mut(|doc| { + // Record when composition ended for Safari timing workaround + doc.composition_ended_at = Some(web_time::Instant::now()); + + if let Some(comp) = doc.composition.take() { + tracing::debug!( + start_offset = comp.start_offset, + final_text = %final_text, + chars = final_text.chars().count(), + "compositionend: inserting text" + ); + + if !final_text.is_empty() { + let mut delete_start = comp.start_offset; + while delete_start > 0 { + match get_char_at(doc.loro_text(), delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + _ => break, + } + } + + let zw_count = doc.cursor.offset - delete_start; + if zw_count > 0 { + // Splice: delete zero-width chars and insert new char in one op + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); + doc.cursor.offset = delete_start + final_text.chars().count(); + } else if doc.cursor.offset == doc.len_chars() { + // Fast path: append at end + let _ = doc.push_tracked(&final_text); + doc.cursor.offset = comp.start_offset + final_text.chars().count(); + } else { + let _ = doc.insert_tracked(doc.cursor.offset, &final_text); + doc.cursor.offset = comp.start_offset + final_text.chars().count(); + } + } + } else { + tracing::debug!("compositionend without active composition state"); + } + }); + }, + } + + // Debug panel snug below editor + div { class: "editor-debug", + div { "Cursor: {document().cursor.offset}, Chars: {document().len_chars()}" }, + ReportButton { + email: "editor-bugs@weaver.sh".to_string(), + editor_id: "markdown-editor".to_string(), + } + } + } + + // Toolbar in grid column 2, row 3 + EditorToolbar { + on_format: move |action| { + document.with_mut(|doc| { + formatting::apply_formatting(doc, action); + }); + } + } + + } + } +} diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index c9af5f4..29d50a1 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -7,7 +7,7 @@ //! 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 super::offset_map::{OffsetMapping, find_mapping_for_char}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use wasm_bindgen::JsCast; @@ -36,9 +36,17 @@ pub fn restore_cursor_position( } // Bounds check using offset map - let max_offset = offset_map.iter().map(|m| m.char_range.end).max().unwrap_or(0); + let max_offset = offset_map + .iter() + .map(|m| m.char_range.end) + .max() + .unwrap_or(0); if char_offset > max_offset { - tracing::warn!("cursor offset {} > max mapping offset {}", char_offset, max_offset); + tracing::warn!( + "cursor offset {} > max mapping offset {}", + char_offset, + max_offset + ); // Don't error, just skip restoration - this can happen during edits return Ok(()); } @@ -70,9 +78,7 @@ pub fn restore_cursor_position( .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 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
) @@ -84,7 +90,8 @@ pub fn restore_cursor_position( 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)?; + let (text_node, node_offset) = + find_text_node_at_offset(&container_element, target_utf16_offset)?; range.set_start(&text_node, node_offset as u32)?; } @@ -114,10 +121,7 @@ fn find_text_node_at_offset( // 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 walker = document.create_tree_walker_with_what_to_show(container, 4)?; let mut accumulated_utf16 = 0; let mut last_node: Option = None; diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 301ba42..7797330 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -1,26 +1,61 @@ //! Core data structures for the markdown editor. //! //! Uses Loro CRDT for text storage with built-in undo/redo support. +//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. use loro::{ - ExportMode, LoroDoc, LoroResult, LoroText, UndoManager, + ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, cursor::{Cursor, Side}, }; +use jacquard::IntoStatic; +use jacquard::from_json_value; +use jacquard::types::string::AtUri; +use weaver_api::sh_weaver::embed::images::Image; + +/// Helper for working with editor images. +/// Constructed from LoroMap data, NOT serialized directly. +/// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field. +#[derive(Clone, Debug)] +pub struct EditorImage { + /// The lexicon Image type (deserialized via from_json_value) + pub image: Image<'static>, + /// AT-URI of the PublishedBlob record (for cleanup on publish/delete) + /// None for existing images that are already in an entry record. + pub published_blob_uri: Option>, +} + /// Single source of truth for editor state. /// /// Contains the document text (backed by Loro CRDT), cursor position, -/// selection, and IME composition state. +/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` +/// schema with CRDT containers for each field. #[derive(Debug)] pub struct EditorDocument { /// The Loro document containing all editor state. - /// Using full LoroDoc (not just LoroText) to support future - /// expansion to blobs, metadata, etc. doc: LoroDoc, - /// Handle to the text container within the doc. - text: LoroText, + // --- Entry schema containers --- + /// Markdown content (maps to entry.content) + content: LoroText, + + /// Entry title (maps to entry.title) + title: LoroText, + /// URL path/slug (maps to entry.path) + path: LoroText, + + /// ISO datetime string (maps to entry.createdAt) + created_at: LoroText, + + /// Tags list (maps to entry.tags) + tags: LoroList, + + /// Embeds container (maps to entry.embeds) + /// Contains nested containers: images (LoroList), externals (LoroList), etc. + embeds: LoroMap, + + // --- Editor state --- /// Undo manager for the document. undo_mgr: UndoManager, @@ -111,10 +146,10 @@ impl EditorDocument { return true; } - let content = self.text.to_string(); + let content_str = self.content.to_string(); let mut last_newline_pos: Option = None; - for (i, c) in content.chars().take(pos).enumerate() { + for (i, c) in content_str.chars().take(pos).enumerate() { if c == '\n' { last_newline_pos = Some(i); } @@ -129,27 +164,47 @@ impl EditorDocument { } /// Create a new editor document with the given content. - pub fn new(content: String) -> Self { + /// Sets `created_at` to current time. + pub fn new(initial_content: String) -> Self { let doc = LoroDoc::new(); - let text = doc.get_text("content"); + + // Get all containers + let content = doc.get_text("content"); + let title = doc.get_text("title"); + let path = doc.get_text("path"); + let created_at = doc.get_text("created_at"); + let tags = doc.get_list("tags"); + let embeds = doc.get_map("embeds"); // Insert initial content if any - if !content.is_empty() { - text.insert(0, &content) + if !initial_content.is_empty() { + content + .insert(0, &initial_content) .expect("failed to insert initial content"); } + // Set created_at to current time (ISO 8601) + let now = Self::current_datetime_string(); + created_at + .insert(0, &now) + .expect("failed to set created_at"); + // Set up undo manager with merge interval for batching keystrokes let mut undo_mgr = UndoManager::new(&doc); undo_mgr.set_merge_interval(300); // 300ms merge window undo_mgr.set_max_undo_steps(100); // Create initial Loro cursor at position 0 - let loro_cursor = text.get_cursor(0, Side::default()); + let loro_cursor = content.get_cursor(0, Side::default()); Self { doc, - text, + content, + title, + path, + created_at, + tags, + embeds, undo_mgr, cursor: CursorState { offset: 0, @@ -163,42 +218,254 @@ impl EditorDocument { } } - /// Get the underlying LoroText for read operations. + /// Generate current datetime as ISO 8601 string. + #[cfg(target_family = "wasm")] + fn current_datetime_string() -> String { + js_sys::Date::new_0() + .to_iso_string() + .as_string() + .unwrap_or_default() + } + + #[cfg(not(target_family = "wasm"))] + fn current_datetime_string() -> String { + // Fallback for non-wasm (tests, etc.) + chrono::Utc::now().to_rfc3339() + } + + /// Get the underlying LoroText for read operations on content. pub fn loro_text(&self) -> &LoroText { - &self.text + &self.content } - /// Convert the document to a string. + // --- Content accessors --- + + /// Get the markdown content as a string. + pub fn content(&self) -> String { + self.content.to_string() + } + + /// Convert the document content to a string (alias for content()). pub fn to_string(&self) -> String { - self.text.to_string() + self.content.to_string() } - /// Get the length of the document in characters. + /// Get the length of the content in characters. pub fn len_chars(&self) -> usize { - self.text.len_unicode() + self.content.len_unicode() } - /// Get the length of the document in UTF-8 bytes. + /// Get the length of the content in UTF-8 bytes. pub fn len_bytes(&self) -> usize { - self.text.len_utf8() + self.content.len_utf8() } - /// Get the length of the document in UTF-16 code units. + /// Get the length of the content in UTF-16 code units. pub fn len_utf16(&self) -> usize { - self.text.len_utf16() + self.content.len_utf16() } - /// Check if the document is empty. + /// Check if the content is empty. pub fn is_empty(&self) -> bool { - self.text.len_unicode() == 0 + self.content.len_unicode() == 0 + } + + // --- Entry metadata accessors --- + + /// Get the entry title. + pub fn title(&self) -> String { + self.title.to_string() + } + + /// Set the entry title (replaces existing). + pub fn set_title(&mut self, new_title: &str) { + let current_len = self.title.len_unicode(); + if current_len > 0 { + self.title.delete(0, current_len).ok(); + } + self.title.insert(0, new_title).ok(); + } + + /// Get the URL path/slug. + pub fn path(&self) -> String { + self.path.to_string() + } + + /// Set the URL path/slug (replaces existing). + pub fn set_path(&mut self, new_path: &str) { + let current_len = self.path.len_unicode(); + if current_len > 0 { + self.path.delete(0, current_len).ok(); + } + self.path.insert(0, new_path).ok(); + } + + /// Get the created_at timestamp (ISO 8601 string). + pub fn created_at(&self) -> String { + self.created_at.to_string() + } + + /// Set the created_at timestamp (usually only called once on creation or when loading). + pub fn set_created_at(&mut self, datetime: &str) { + let current_len = self.created_at.len_unicode(); + if current_len > 0 { + self.created_at.delete(0, current_len).ok(); + } + self.created_at.insert(0, datetime).ok(); + } + + // --- Tags accessors --- + + /// Get all tags as a vector of strings. + pub fn tags(&self) -> Vec { + let len = self.tags.len(); + (0..len) + .filter_map(|i| match self.tags.get(i)? { + loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()), + _ => None, + }) + .collect() } - /// Insert text and record edit info for incremental rendering. + /// Add a tag (if not already present). + pub fn add_tag(&mut self, tag: &str) { + let existing = self.tags(); + if !existing.iter().any(|t| t == tag) { + self.tags.push(LoroValue::String(tag.into())).ok(); + } + } + + /// Remove a tag by value. + pub fn remove_tag(&mut self, tag: &str) { + let len = self.tags.len(); + for i in (0..len).rev() { + if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { + if s.as_str() == tag { + self.tags.delete(i, 1).ok(); + break; + } + } + } + } + + /// Clear all tags. + pub fn clear_tags(&mut self) { + let len = self.tags.len(); + if len > 0 { + self.tags.delete(0, len).ok(); + } + } + + // --- Images accessors --- + + /// Get the images LoroList from embeds, creating it if needed. + fn get_images_list(&self) -> LoroList { + self.embeds + .get_or_create_container("images", LoroList::new()) + .unwrap() + } + + /// Get all images as a Vec. + pub fn images(&self) -> Vec { + let images_list = self.get_images_list(); + let mut result = Vec::new(); + + for i in 0..images_list.len() { + if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) { + result.push(editor_image); + } + } + + result + } + + /// Convert a LoroValue at the given index to an EditorImage. + fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option { + let value = list.get(index)?; + + // Extract LoroValue from ValueOrContainer + let loro_value = value.as_value()?; + + // Convert LoroValue to serde_json::Value + let json = loro_value.to_json_value(); + + // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data + let image: Image<'static> = from_json_value::(json).ok()?; + + // Extract our tracking field from extra_data + let published_blob_uri = image + .extra_data + .as_ref() + .and_then(|m| m.get("publishedBlobUri")) + .and_then(|d| d.as_str()) + .and_then(|s| AtUri::new(s).ok()) + .map(|uri| uri.into_static()); + + Some(EditorImage { + image, + published_blob_uri, + }) + } + + /// Add an image to the embeds. + /// The Image is serialized to JSON with our publishedBlobUri added. + pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) { + // Serialize the Image to serde_json::Value + let mut json = serde_json::to_value(image).expect("Image serializes"); + + // Add our tracking field (not part of lexicon, stored in extra_data on deserialize) + if let Some(uri) = published_blob_uri { + json.as_object_mut() + .unwrap() + .insert("publishedBlobUri".into(), uri.as_str().into()); + } + + // Insert into the images list + let images_list = self.get_images_list(); + images_list.push(json).ok(); + } + + /// Remove an image by index. + pub fn remove_image(&mut self, index: usize) { + let images_list = self.get_images_list(); + if index < images_list.len() { + images_list.delete(index, 1).ok(); + } + } + + /// Get a single image by index. + pub fn get_image(&self, index: usize) -> Option { + let images_list = self.get_images_list(); + self.loro_value_to_editor_image(&images_list, index) + } + + /// Get the number of images. + pub fn images_len(&self) -> usize { + self.get_images_list().len() + } + + /// Update the alt text of an image at the given index. + pub fn update_image_alt(&mut self, index: usize, alt: &str) { + let images_list = self.get_images_list(); + if let Some(value) = images_list.get(index) { + if let Some(loro_value) = value.as_value() { + let mut json = loro_value.to_json_value(); + if let Some(obj) = json.as_object_mut() { + obj.insert("alt".into(), alt.into()); + // Replace the entire value at this index + images_list.delete(index, 1).ok(); + images_list.insert(index, json).ok(); + } + } + } + } + + /// Insert text into content and record edit info for incremental rendering. pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); - let len_before = self.text.len_unicode(); - let result = self.text.insert(pos, text); - let len_after = self.text.len_unicode(); + let len_before = self.content.len_unicode(); + let result = self.content.insert(pos, text); + let len_after = self.content.len_unicode(); self.last_edit = Some(EditInfo { edit_char_pos: pos, inserted_len: len_after.saturating_sub(len_before), @@ -210,12 +477,12 @@ impl EditorDocument { result } - /// Push text to end of document. Faster than insert for appending. + /// Push text to end of content. Faster than insert for appending. pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { - let pos = self.text.len_unicode(); + let pos = self.content.len_unicode(); let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); - let result = self.text.push_str(text); - let len_after = self.text.len_unicode(); + let result = self.content.push_str(text); + let len_after = self.content.len_unicode(); self.last_edit = Some(EditInfo { edit_char_pos: pos, inserted_len: text.chars().count(), @@ -227,34 +494,34 @@ impl EditorDocument { result } - /// Remove text range and record edit info for incremental rendering. + /// Remove text range from content and record edit info for incremental rendering. pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { - let content = self.text.to_string(); - let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); + let content_str = self.content.to_string(); + let contains_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); - let result = self.text.delete(start, len); + let result = self.content.delete(start, len); self.last_edit = Some(EditInfo { edit_char_pos: start, inserted_len: 0, deleted_len: len, contains_newline, in_block_syntax_zone, - doc_len_after: self.text.len_unicode(), + doc_len_after: self.content.len_unicode(), }); result } - /// Replace text (delete then insert) and record combined edit info. + /// Replace text in content (delete then insert) and record combined edit info. pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { - let content = self.text.to_string(); - let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); + let content_str = self.content.to_string(); + let delete_has_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); - let len_before = self.text.len_unicode(); + let len_before = self.content.len_unicode(); // Use splice for atomic replace - self.text.splice(start, len, text)?; - let len_after = self.text.len_unicode(); + self.content.splice(start, len, text)?; + let len_after = self.content.len_unicode(); // inserted_len = (len_after - len_before) + deleted_len // because: len_after = len_before - deleted + inserted @@ -312,16 +579,16 @@ impl EditorDocument { self.undo_mgr.can_redo() } - /// Get a slice of the document text. + /// Get a slice of the content text. /// Returns None if the range is invalid. pub fn slice(&self, start: usize, end: usize) -> Option { - self.text.slice(start, end).ok() + self.content.slice(start, end).ok() } /// Sync the Loro cursor to the current cursor.offset position. /// Call this after OUR edits where we know the new cursor position. pub fn sync_loro_cursor(&mut self) { - self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default()); + self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default()); } /// Update cursor.offset from the Loro cursor's tracked position. @@ -383,7 +650,13 @@ impl EditorDocument { } } - let text = doc.get_text("content"); + // Get all containers (they will contain data from the snapshot if import succeeded) + let content = doc.get_text("content"); + let title = doc.get_text("title"); + let path = doc.get_text("path"); + let created_at = doc.get_text("created_at"); + let tags = doc.get_list("tags"); + let embeds = doc.get_map("embeds"); // Set up undo manager - tracks operations from this point forward only let mut undo_mgr = UndoManager::new(&doc); @@ -391,7 +664,7 @@ impl EditorDocument { undo_mgr.set_max_undo_steps(100); // Try to restore cursor from Loro cursor, fall back to offset - let max_offset = text.len_unicode(); + let max_offset = content.len_unicode(); let cursor_offset = if let Some(ref lc) = loro_cursor { doc.get_cursor_pos(lc) .map(|r| r.current.pos) @@ -406,11 +679,17 @@ impl EditorDocument { }; // If no Loro cursor provided, create one at the restored position - let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default())); + let loro_cursor = + loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default())); Self { doc, - text, + content, + title, + path, + created_at, + tags, + embeds, undo_mgr, cursor, loro_cursor, @@ -427,11 +706,13 @@ impl EditorDocument { impl Clone for EditorDocument { fn clone(&self) -> Self { - // Create a new document with the same content - let content = self.to_string(); - let mut new_doc = Self::new(content); + // Use snapshot export/import for a complete clone including all containers + let snapshot = self.export_snapshot(); + let mut new_doc = + Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset); + + // Copy non-CRDT state new_doc.cursor = self.cursor; - // Recreate Loro cursor at the same position in the new doc new_doc.sync_loro_cursor(); new_doc.selection = self.selection; new_doc.composition = self.composition.clone(); diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs new file mode 100644 index 0000000..ac227af --- /dev/null +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -0,0 +1,286 @@ +//! DOM synchronization for the markdown editor. +//! +//! Handles syncing cursor/selection state between the browser DOM and our +//! internal document model, and updating paragraph DOM elements. + +use dioxus::prelude::*; + +use super::document::{EditorDocument, Selection}; +use super::paragraph::ParagraphRender; + +/// Sync internal cursor and selection state from browser DOM selection. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub fn sync_cursor_from_dom( + document: &mut Signal, + editor_id: &str, + paragraphs: &[ParagraphRender], +) { + use wasm_bindgen::JsCast; + + // Early return if paragraphs not yet populated (first render edge case) + if paragraphs.is_empty() { + return; + } + + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + + let dom_document = match window.document() { + Some(d) => d, + None => return, + }; + + let editor_element = match dom_document.get_element_by_id(editor_id) { + Some(e) => e, + None => return, + }; + + let selection = match window.get_selection() { + Ok(Some(sel)) => sel, + _ => return, + }; + + // Get both anchor (selection start) and focus (selection end) positions + let anchor_node = match selection.anchor_node() { + Some(node) => node, + None => return, + }; + let focus_node = match selection.focus_node() { + Some(node) => node, + None => return, + }; + let anchor_offset = selection.anchor_offset() as usize; + let focus_offset = selection.focus_offset() as usize; + + // Convert both DOM positions to rope offsets using cached paragraphs + let anchor_rope = dom_position_to_rope_offset( + &dom_document, + &editor_element, + &anchor_node, + anchor_offset, + paragraphs, + ); + let focus_rope = dom_position_to_rope_offset( + &dom_document, + &editor_element, + &focus_node, + focus_offset, + paragraphs, + ); + + document.with_mut(|doc| { + match (anchor_rope, focus_rope) { + (Some(anchor), Some(focus)) => { + doc.cursor.offset = focus; + if anchor != focus { + // There's an actual selection + doc.selection = Some(Selection { + anchor, + head: focus, + }); + } else { + // Collapsed selection (just cursor) + doc.selection = None; + } + } + _ => { + tracing::warn!("Could not map DOM selection to rope offsets"); + } + } + }); +} + +/// Convert a DOM position (node + offset) to a rope char offset using offset maps. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +fn dom_position_to_rope_offset( + dom_document: &web_sys::Document, + editor_element: &web_sys::Element, + node: &web_sys::Node, + offset_in_text_node: usize, + paragraphs: &[ParagraphRender], +) -> Option { + use wasm_bindgen::JsCast; + + // Find the containing element with a node ID (walk up from text node) + let mut current_node = node.clone(); + let node_id = loop { + if let Some(element) = current_node.dyn_ref::() { + if element == editor_element { + // Selection is on the editor container itself (e.g., Cmd+A select all) + // Return boundary position based on offset: + // offset 0 = start of editor, offset == child count = end of editor + let child_count = editor_element.child_element_count() as usize; + if offset_in_text_node == 0 { + return Some(0); // Start of document + } else if offset_in_text_node >= child_count { + // End of document - find last paragraph's end + return paragraphs.last().map(|p| p.char_range.end); + } + break None; + } + + let id = element + .get_attribute("id") + .or_else(|| element.get_attribute("data-node-id")); + + if let Some(id) = id { + if id.starts_with('n') && id[1..].parse::().is_ok() { + break Some(id); + } + } + } + + current_node = current_node.parent_node()?; + }; + + let node_id = node_id?; + + // Get the container element + let container = dom_document.get_element_by_id(&node_id).or_else(|| { + let selector = format!("[data-node-id='{}']", node_id); + dom_document.query_selector(&selector).ok().flatten() + })?; + + // Calculate UTF-16 offset from start of container to the position + let mut utf16_offset_in_container = 0; + + if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { + while let Ok(Some(text_node)) = walker.next_node() { + if &text_node == node { + utf16_offset_in_container += offset_in_text_node; + break; + } + + if let Some(text) = text_node.text_content() { + utf16_offset_in_container += text.encode_utf16().count(); + } + } + } + + // Look up in offset maps + for para in paragraphs { + for mapping in ¶.offset_map { + if mapping.node_id == node_id { + let mapping_start = mapping.char_offset_in_node; + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; + + if utf16_offset_in_container >= mapping_start + && utf16_offset_in_container <= mapping_end + { + let offset_in_mapping = utf16_offset_in_container - mapping_start; + return Some(mapping.char_range.start + offset_in_mapping); + } + } + } + } + + None +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub fn sync_cursor_from_dom( + _document: &mut Signal, + _editor_id: &str, + _paragraphs: &[ParagraphRender], +) { + // No-op on non-wasm +} + +/// Update paragraph DOM elements incrementally. +/// +/// Only modifies paragraphs that changed (by comparing source_hash). +/// Browser preserves cursor naturally in unchanged paragraphs. +/// +/// Returns true if the paragraph containing the cursor was updated. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub fn update_paragraph_dom( + editor_id: &str, + old_paragraphs: &[ParagraphRender], + new_paragraphs: &[ParagraphRender], + cursor_offset: usize, +) -> bool { + use wasm_bindgen::JsCast; + + let window = match web_sys::window() { + Some(w) => w, + None => return false, + }; + + let document = match window.document() { + Some(d) => d, + None => return false, + }; + + let editor = match document.get_element_by_id(editor_id) { + Some(e) => e, + None => return false, + }; + + // Find which paragraph contains cursor + // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) + // This handles typing at end of paragraph, which is the common case + // The empty paragraph at document end catches any trailing cursor positions + let cursor_para_idx = new_paragraphs + .iter() + .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); + + let mut cursor_para_updated = false; + + // Update or create paragraphs + for (idx, new_para) in new_paragraphs.iter().enumerate() { + let para_id = format!("para-{}", idx); + + if let Some(old_para) = old_paragraphs.get(idx) { + // Paragraph exists - check if changed + if new_para.source_hash != old_para.source_hash { + // Changed - clear and update innerHTML + // We clear first to ensure any browser-added content (from IME composition, + // contenteditable quirks, etc.) is fully removed before setting new content + if let Some(elem) = document.get_element_by_id(¶_id) { + elem.set_text_content(None); // Clear completely + elem.set_inner_html(&new_para.html); + } + + // Track if we updated the cursor's paragraph + if Some(idx) == cursor_para_idx { + cursor_para_updated = true; + } + } + // Unchanged - do nothing, browser preserves cursor + } else { + // New paragraph - create div + if let Ok(div) = document.create_element("div") { + div.set_id(¶_id); + div.set_inner_html(&new_para.html); + let _ = editor.append_child(&div); + } + + // Track if we created the cursor's paragraph + if Some(idx) == cursor_para_idx { + cursor_para_updated = true; + } + } + } + + // Remove extra paragraphs if document got shorter + for idx in new_paragraphs.len()..old_paragraphs.len() { + let para_id = format!("para-{}", idx); + if let Some(elem) = document.get_element_by_id(¶_id) { + let _ = elem.remove(); + } + } + + cursor_para_updated +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub fn update_paragraph_dom( + _editor_id: &str, + _old_paragraphs: &[ParagraphRender], + _new_paragraphs: &[ParagraphRender], + _cursor_offset: usize, +) -> bool { + false +} diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs index cfa7aec..88461ac 100644 --- a/crates/weaver-app/src/components/editor/formatting.rs +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -1,6 +1,6 @@ //! Formatting actions and utilities for applying markdown formatting. -use crate::components::editor::{ListContext, detect_list_context, find_line_end}; +use super::input::{ListContext, detect_list_context, find_line_end}; use super::document::EditorDocument; diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs new file mode 100644 index 0000000..96235a3 --- /dev/null +++ b/crates/weaver-app/src/components/editor/input.rs @@ -0,0 +1,639 @@ +//! Input handling for the markdown editor. +//! +//! Keyboard events, clipboard operations, and text manipulation. + +use dioxus::prelude::*; + +use super::document::EditorDocument; +use super::formatting::{self, FormatAction}; + +/// Check if we need to intercept this key event. +/// Returns true for content-modifying operations, false for navigation. +pub fn should_intercept_key(evt: &Event) -> bool { + use dioxus::prelude::keyboard_types::Key; + + let key = evt.key(); + let mods = evt.modifiers(); + + // Handle Ctrl/Cmd shortcuts + if mods.ctrl() || mods.meta() { + if let Key::Character(ch) = &key { + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) + match ch.as_str() { + "b" | "i" | "z" | "y" => return true, + "e" => return true, // Ctrl+E for HTML export/copy + _ => {} + } + } + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) + return false; + } + + // Intercept content modifications + matches!( + key, + Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab + ) +} + +/// Handle keyboard events and update document state. +pub 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; + } + "z" => { + if mods.shift() { + // Ctrl+Shift+Z = redo + if let Ok(true) = doc.redo() { + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + } else { + // Ctrl+Z = undo + if let Ok(true) = doc.undo() { + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + } + doc.selection = None; + return; + } + "y" => { + // Ctrl+Y = redo (alternative) + if let Ok(true) = doc.redo() { + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + doc.selection = None; + return; + } + "e" => { + // Ctrl+E = copy as HTML (export) + if let Some(sel) = doc.selection { + let (start, end) = + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + if let Some(markdown) = doc.slice(start, end) { + let clean_md = markdown + .replace('\u{200C}', "") + .replace('\u{200B}', ""); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = copy_as_html(&clean_md).await { + tracing::warn!("[COPY HTML] Failed: {:?}", e); + } + }); + } + } + } + return; + } + _ => {} + } + } + + // Insert character at cursor (replacing selection if any) + if let Some(sel) = doc.selection.take() { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); + doc.cursor.offset = start + ch.chars().count(); + } else { + // Clean up any preceding zero-width chars (gap scaffolding) + let mut delete_start = doc.cursor.offset; + while delete_start > 0 { + match get_char_at(doc.loro_text(), delete_start - 1) { + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, + _ => break, + } + } + + let zw_count = doc.cursor.offset - delete_start; + if zw_count > 0 { + // Splice: delete zero-width chars and insert new char in one op + let _ = doc.replace_tracked(delete_start, zw_count, &ch); + doc.cursor.offset = delete_start + ch.chars().count(); + } else if doc.cursor.offset == doc.len_chars() { + // Fast path: append at end + let _ = doc.push_tracked(&ch); + doc.cursor.offset += ch.chars().count(); + } else { + let _ = doc.insert_tracked(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)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + doc.selection = None; + } else if doc.cursor.offset > 0 { + // Check if we're about to delete a newline + let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); + + if prev_char == Some('\n') { + let newline_pos = doc.cursor.offset - 1; + let mut delete_start = newline_pos; + let mut delete_end = doc.cursor.offset; + + // Check if there's another newline before this one (empty paragraph) + // If so, delete both newlines to merge paragraphs + if newline_pos > 0 { + let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); + if prev_prev_char == Some('\n') { + // Empty paragraph case: delete both newlines + delete_start = newline_pos - 1; + } + } + + // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) + if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { + if ch == '\u{200C}' || ch == '\u{200B}' { + delete_end += 1; + } + } + + // Scan backwards through whitespace before the newline(s) + while delete_start > 0 { + let ch = get_char_at(doc.loro_text(), delete_start - 1); + match ch { + Some('\u{200C}') | Some('\u{200B}') => { + delete_start -= 1; + } + Some('\n') => break, // stop at another newline + _ => break, // stop at actual content + } + } + + // Delete from where we stopped to end (including any trailing zero-width) + let _ = doc + .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); + doc.cursor.offset = delete_start; + } else { + // Normal backspace - delete one char + let prev = doc.cursor.offset - 1; + let _ = doc.remove_tracked(prev, 1); + doc.cursor.offset = prev; + } + } + } + + Key::Delete => { + if let Some(sel) = doc.selection.take() { + // Delete selection + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + } else if doc.cursor.offset < doc.len_chars() { + // Delete next char + let _ = doc.remove_tracked(doc.cursor.offset, 1); + } + } + + // Arrow keys handled by browser, synced in onkeyup + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { + // Browser handles these naturally + } + + Key::Enter => { + if let Some(sel) = doc.selection.take() { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + } + + if mods.shift() { + // Shift+Enter: hard line break (soft break) + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); + doc.cursor.offset += 3; + } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { + // We're in a list item + if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { + // Empty item - exit list by removing marker and inserting paragraph break + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); + + // Delete the empty list item line INCLUDING its trailing newline + // line_end points to the newline, so +1 to include it + let delete_end = (line_end + 1).min(doc.len_chars()); + + // Use replace_tracked to atomically delete line and insert paragraph break + let _ = doc.replace_tracked( + line_start, + delete_end.saturating_sub(line_start), + "\n\n\u{200C}\n", + ); + doc.cursor.offset = line_start + 2; + } else { + // Non-empty item - continue list + let continuation = match ctx { + ListContext::Unordered { indent, marker } => { + format!("\n{}{} ", indent, marker) + } + ListContext::Ordered { indent, number } => { + format!("\n{}{}. ", indent, number + 1) + } + }; + let len = continuation.chars().count(); + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); + doc.cursor.offset += len; + } + } else { + // Not in a list - normal paragraph break + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); + doc.cursor.offset += 2; + } + } + + // Home/End handled by browser, synced in onkeyup + Key::Home | Key::End => { + // Browser handles these naturally + } + + _ => {} + } + + // Sync Loro cursor when edits affect paragraph boundaries + // This ensures cursor position is tracked correctly through structural changes + if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { + doc.sync_loro_cursor(); + } + }); +} + +/// Handle paste events and insert text at cursor. +pub fn handle_paste(evt: Event, document: &mut Signal) { + evt.prevent_default(); + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + // Try our custom type first (internal paste), fall back to text/plain + let text = data_transfer + .get_data("text/x-weaver-md") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| data_transfer.get_data("text/plain").ok()); + + if let Some(text) = text { + 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)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + doc.selection = None; + } + + // Insert pasted text + let _ = doc.insert_tracked(doc.cursor.offset, &text); + doc.cursor.offset += text.chars().count(); + }); + } + } + } else { + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); + } + } +} + +/// Handle cut events - extract text, write to clipboard, then delete. +pub fn handle_cut(evt: Event, document: &mut Signal) { + evt.prevent_default(); + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let cut_text = document.with_mut(|doc| { + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + // Extract text and strip zero-width chars + let selected_text = doc.slice(start, end).unwrap_or_default(); + let clean_text = selected_text + .replace('\u{200C}', "") + .replace('\u{200B}', ""); + + // Write to clipboard BEFORE deleting (sync fallback) + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { + tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); + } + } + + // Now delete + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.offset = start; + doc.selection = None; + + return Some(clean_text); + } + } + None + }); + + // Async: also write custom MIME type for internal paste detection + if let Some(text) = cut_text { + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = write_clipboard_with_custom_type(&text).await { + tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); + } + }); + } + } + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let _ = evt; // suppress unused warning + } +} + +/// Handle copy events - extract text, clean it up, write to clipboard. +pub fn handle_copy(evt: Event, document: &Signal) { + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use dioxus::web::WebEventExt; + use wasm_bindgen::JsCast; + + let base_evt = evt.as_web_event(); + if let Some(clipboard_evt) = base_evt.dyn_ref::() { + let doc = document.read(); + if let Some(sel) = doc.selection { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + if start != end { + // Extract text + let selected_text = doc.slice(start, end).unwrap_or_default(); + + // Strip zero-width chars used for gap handling + let clean_text = selected_text + .replace('\u{200C}', "") + .replace('\u{200B}', ""); + + // Sync fallback: write text/plain via DataTransfer + if let Some(data_transfer) = clipboard_evt.clipboard_data() { + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { + tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); + } + } + + // Async: also write custom MIME type for internal paste detection + let text_for_async = clean_text.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { + tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); + } + }); + + // Prevent browser's default copy (which would copy rendered HTML) + evt.prevent_default(); + } + } + } + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + let _ = (evt, document); // suppress unused warnings + } +} + +/// Copy markdown as rendered HTML to clipboard. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { + use js_sys::Array; + use wasm_bindgen::JsValue; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + + // Render markdown to HTML using ClientWriter + let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); + let mut html = String::new(); + weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( + parser.map(|(evt, _range)| evt), + &mut html, + ) + .run() + .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let clipboard = window.navigator().clipboard(); + + // Create blobs for both HTML and plain text (raw HTML for inspection) + let parts = Array::new(); + parts.push(&JsValue::from_str(&html)); + + let mut html_opts = BlobPropertyBag::new(); + html_opts.type_("text/html"); + let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; + + let mut text_opts = BlobPropertyBag::new(); + text_opts.type_("text/plain"); + let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; + + // Create ClipboardItem with both types + let item_data = js_sys::Object::new(); + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; + + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; + let items = Array::new(); + items.push(&clipboard_item); + + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; + tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); + Ok(()) +} + +/// Write text to clipboard with both text/plain and custom MIME type. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { + use js_sys::{Array, Object, Reflect}; + use wasm_bindgen::JsValue; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let navigator = window.navigator(); + let clipboard = navigator.clipboard(); + + // Create blobs for each MIME type + let text_parts = Array::new(); + text_parts.push(&JsValue::from_str(text)); + + let mut text_opts = BlobPropertyBag::new(); + text_opts.type_("text/plain"); + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; + + let mut custom_opts = BlobPropertyBag::new(); + custom_opts.type_("text/x-weaver-md"); + let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; + + // Create ClipboardItem with both types + let item_data = Object::new(); + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; + Reflect::set( + &item_data, + &JsValue::from_str("text/x-weaver-md"), + &custom_blob, + )?; + + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; + let items = Array::new(); + items.push(&clipboard_item); + + let promise = clipboard.write(&items); + wasm_bindgen_futures::JsFuture::from(promise).await?; + + Ok(()) +} + +/// Describes what kind of list item the cursor is in, if any. +#[derive(Debug, Clone)] +pub enum ListContext { + /// Unordered list with the given marker char ('-' or '*') and indentation. + Unordered { indent: String, marker: char }, + /// Ordered list with the current number and indentation. + Ordered { indent: String, number: usize }, +} + +/// Detect if cursor is in a list item and return context for continuation. +/// +/// Scans backwards to find start of current line, then checks for list marker. +pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option { + // Find start of current line + let line_start = find_line_start(text, cursor_offset); + + // Get the line content from start to cursor + let line_end = find_line_end(text, cursor_offset); + if line_start >= line_end { + return None; + } + + // Extract line text + let line = text.slice(line_start, line_end).ok()?; + + // Parse indentation + let indent: String = line + .chars() + .take_while(|c| *c == ' ' || *c == '\t') + .collect(); + let trimmed = &line[indent.len()..]; + + // Check for unordered list marker: "- " or "* " + if trimmed.starts_with("- ") { + return Some(ListContext::Unordered { + indent, + marker: '-', + }); + } + if trimmed.starts_with("* ") { + return Some(ListContext::Unordered { + indent, + marker: '*', + }); + } + + // Check for ordered list marker: "1. ", "2. ", "123. ", etc. + if let Some(dot_pos) = trimmed.find(". ") { + let num_part = &trimmed[..dot_pos]; + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { + if let Ok(number) = num_part.parse::() { + return Some(ListContext::Ordered { indent, number }); + } + } + } + + None +} + +/// Check if the current list item is empty (just the marker, no content after cursor). +/// +/// Used to determine whether Enter should continue the list or exit it. +pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { + let line_start = find_line_start(text, cursor_offset); + let line_end = find_line_end(text, cursor_offset); + + // Get line content + let line = match text.slice(line_start, line_end) { + Ok(s) => s, + Err(_) => return false, + }; + + // Calculate expected marker length + let marker_len = match ctx { + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " + ListContext::Ordered { indent, number } => { + indent.len() + number.to_string().len() + 2 // "1. " + } + }; + + // Item is empty if line length equals marker length (nothing after marker) + line.len() <= marker_len +} + +/// Get character at the given offset in LoroText. +pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option { + text.char_at(offset).ok() +} + +/// Find start of line containing offset. +pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { + if offset == 0 { + return 0; + } + // Only slice the portion before cursor + let prefix = match text.slice(0, offset) { + Ok(s) => s, + Err(_) => return 0, + }; + prefix + .chars() + .enumerate() + .filter(|(_, c)| *c == '\n') + .last() + .map(|(pos, _)| pos + 1) + .unwrap_or(0) +} + +/// Find end of line containing offset. +pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { + let char_len = text.len_unicode(); + if offset >= char_len { + return char_len; + } + // Only slice from cursor to end + let suffix = match text.slice(offset, char_len) { + Ok(s) => s, + Err(_) => return char_len, + }; + suffix + .chars() + .enumerate() + .find(|(_, c)| *c == '\n') + .map(|(i, _)| offset + i) + .unwrap_or(char_len) +} diff --git a/crates/weaver-app/src/components/editor/log_buffer.rs b/crates/weaver-app/src/components/editor/log_buffer.rs index ef102f2..5927f81 100644 --- a/crates/weaver-app/src/components/editor/log_buffer.rs +++ b/crates/weaver-app/src/components/editor/log_buffer.rs @@ -9,8 +9,8 @@ use std::fmt::Write as FmtWrite; use tracing::field::{Field, Visit}; use tracing::{Event, Level, Subscriber}; -use tracing_subscriber::layer::Context; use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; /// Maximum number of log entries to keep. const MAX_ENTRIES: usize = 100; @@ -36,7 +36,9 @@ impl Layer for LogCaptureLayer { let target = metadata.target(); // Only buffer debug+ logs from our modules - let is_our_module = CAPTURED_PREFIXES.iter().any(|prefix| target.starts_with(prefix)); + let is_our_module = CAPTURED_PREFIXES + .iter() + .any(|prefix| target.starts_with(prefix)); if !is_our_module || *level > BUFFER_MIN_LEVEL { return; } diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 2482715..87b737a 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -4,13 +4,17 @@ //! characters are hidden contextually based on cursor position, while still //! editing plain markdown text under the hood. +mod component; mod cursor; mod document; +mod dom_sync; mod formatting; +mod input; mod log_buffer; mod offset_map; mod paragraph; mod platform; +mod publish; mod render; mod report; mod storage; @@ -21,1567 +25,44 @@ mod writer; #[cfg(test)] mod tests; +// Main component +pub use component::MarkdownEditor; + +// Document types +#[allow(unused_imports)] pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; + +// Formatting +#[allow(unused_imports)] pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; + +// Rendering +#[allow(unused_imports)] pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; +#[allow(unused_imports)] pub use paragraph::ParagraphRender; +#[allow(unused_imports)] pub use render::{RenderCache, render_paragraphs_incremental}; -pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; -pub use toolbar::EditorToolbar; -pub use visibility::VisibilityState; +#[allow(unused_imports)] pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; -pub use report::ReportButton; -pub use log_buffer::LogCaptureLayer; - -use dioxus::prelude::*; - -use crate::components::record_view::CodeView; - -/// Main markdown editor component. -/// -/// # Props -/// - `initial_content`: Optional initial markdown content -/// -/// # Features -/// - Loro CRDT-based text storage with undo/redo support -/// - 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 (mostly resolved) -/// - Cursor jumps to end after each keystroke (acceptable for MVP) -/// - All formatting characters visible (no hiding based on cursor position) - RESOLVED -/// - No proper grapheme cluster handling -/// - No undo/redo - RESOLVED (Loro UndoManager) -/// - No selection with Shift+Arrow -/// - No mouse selection - RESOLVED -#[component] -pub fn MarkdownEditor(initial_content: Option) -> Element { - // Try to restore from localStorage (includes CRDT state for undo history) - let mut document = use_signal(move || { - storage::load_from_storage() - .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) - }); - let editor_id = "markdown-editor"; - - // Cache for incremental paragraph rendering - let mut render_cache = use_signal(|| render::RenderCache::default()); - - // Render paragraphs with incremental caching - let paragraphs = use_memo(move || { - let doc = document(); - let cache = render_cache.peek(); - let edit = doc.last_edit.as_ref(); - - let (paras, new_cache) = - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); - - // Update cache for next render (write-only via spawn to avoid reactive loop) - dioxus::prelude::spawn(async move { - render_cache.set(new_cache); - }); - - paras - }); - - // Flatten offset maps from all paragraphs - let offset_map = use_memo(move || { - paragraphs() - .iter() - .flat_map(|p| p.offset_map.iter().cloned()) - .collect::>() - }); - - // Flatten syntax spans from all paragraphs - let syntax_spans = use_memo(move || { - paragraphs() - .iter() - .flat_map(|p| p.syntax_spans.iter().cloned()) - .collect::>() - }); - - // Cache paragraphs for change detection AND for event handlers to access - let mut cached_paragraphs = use_signal(|| Vec::::new()); - - // Update DOM when paragraphs change (incremental rendering) - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - use_effect(move || { - tracing::debug!("DOM update effect triggered"); - - // Read document once to avoid multiple borrows - let doc = document(); - - tracing::debug!( - composition_active = doc.composition.is_some(), - cursor = doc.cursor.offset, - "DOM update: checking state" - ); - - // Skip DOM updates during IME composition - browser controls the preview - if doc.composition.is_some() { - tracing::debug!("skipping DOM update during composition"); - return; - } - - tracing::debug!( - cursor = doc.cursor.offset, - len = doc.len_chars(), - "DOM update proceeding (not in composition)" - ); - - let cursor_offset = doc.cursor.offset; - let selection = doc.selection; - drop(doc); // Release borrow before other operations - - let new_paras = paragraphs(); - let map = offset_map(); - let spans = syntax_spans(); - - // Use peek() to avoid creating reactive dependency on cached_paragraphs - let prev = cached_paragraphs.peek().clone(); - - let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); - - // Only restore cursor if we actually re-rendered the paragraph it's in - if cursor_para_updated { - use wasm_bindgen::JsCast; - use wasm_bindgen::prelude::*; - - // Use requestAnimationFrame to wait for browser paint - if let Some(window) = web_sys::window() { - let closure = Closure::once(move || { - if let Err(e) = cursor::restore_cursor_position(cursor_offset, &map, editor_id) - { - tracing::warn!("Cursor restoration failed: {:?}", e); - } - }); - - let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); - closure.forget(); - } - } - - // Store for next comparison AND for event handlers (write-only, no reactive read) - cached_paragraphs.set(new_paras.clone()); - - // Update syntax visibility after DOM changes - update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); - }); - - // Track last saved frontiers to detect changes (peek-only, no subscriptions) - let mut last_saved_frontiers: Signal> = use_signal(|| None); - - // Auto-save with periodic check (no reactive dependency to avoid loops) - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - use_effect(move || { - // Check every 500ms if there are unsaved changes - let interval = gloo_timers::callback::Interval::new(500, move || { - // Peek both signals without creating reactive dependencies - let current_frontiers = document.peek().state_frontiers(); - - // Only save if frontiers changed (document was edited) - let needs_save = { - let last_frontiers = last_saved_frontiers.peek(); - match &*last_frontiers { - None => true, // First save - Some(last) => ¤t_frontiers != last, - } - }; // drop last_frontiers borrow here - - if needs_save { - // Sync cursor and extract data for save - let (content, cursor_offset, loro_cursor, snapshot_bytes) = - document.with_mut(|doc| { - doc.sync_loro_cursor(); - ( - doc.to_string(), - doc.cursor.offset, - doc.loro_cursor().cloned(), - doc.export_snapshot(), - ) - }); - - use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set - let snapshot_b64 = if snapshot_bytes.is_empty() { - None - } else { - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &snapshot_bytes, - )) - }; - let snapshot = storage::EditorSnapshot { - content, - snapshot: snapshot_b64, - cursor: loro_cursor, - cursor_offset, - }; - let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot); - - // Update last saved frontiers - last_saved_frontiers.set(Some(current_frontiers)); - } - }); - interval.forget(); - }); - - // Set up beforeinput listener for iOS/Android virtual keyboard quirks - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - use_effect(move || { - use wasm_bindgen::JsCast; - use wasm_bindgen::prelude::*; - - let plat = platform::platform(); - - // Only needed on mobile - if !plat.mobile { - return; - } - - let window = match web_sys::window() { - Some(w) => w, - None => return, - }; - let dom_document = match window.document() { - Some(d) => d, - None => return, - }; - let editor = match dom_document.get_element_by_id(editor_id) { - Some(e) => e, - None => return, - }; - - let mut document_signal = document; - let cached_paras = cached_paragraphs; - - let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { - let input_type = evt.input_type(); - tracing::debug!(input_type = %input_type, "beforeinput"); - - let plat = platform::platform(); - - // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak - // without proper keydown events. Handle them here. - if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { - tracing::debug!("iOS: intercepting {} via beforeinput", input_type); - evt.prevent_default(); - - // Handle as Enter key - document_signal.with_mut(|doc| { - if let Some(sel) = doc.selection.take() { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - } - - if input_type == "insertLineBreak" { - // Soft break (like Shift+Enter) - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); - doc.cursor.offset += 3; - } else { - // Paragraph break - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); - doc.cursor.offset += 2; - } - }); - } - - // Android workaround: When swipe keyboard picks a suggestion, - // DOM mutations fire before selection moves. We detect this pattern - // and defer cursor sync. - if plat.android && input_type == "insertText" { - // Check if this might be a suggestion pick (has data that looks like a word) - if let Some(data) = evt.data() { - if data.contains(' ') || data.len() > 3 { - tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); - // Defer cursor sync by 20ms to let selection settle - let paras = cached_paras; - let doc_sig = document_signal; - let window = web_sys::window(); - if let Some(window) = window { - let closure = Closure::once(move || { - let paras = paras(); - sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, ¶s); - }); - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( - closure.as_ref().unchecked_ref(), - 20, - ); - closure.forget(); - } - } - } - } - }) as Box); - - let _ = editor - .add_event_listener_with_callback("beforeinput", 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", - // DOM populated via web-sys in use_effect for incremental updates - - onkeydown: move |evt| { - use dioxus::prelude::keyboard_types::Key; - use std::time::Duration; - - let plat = platform::platform(); - let mods = evt.modifiers(); - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); - - // During IME composition: - // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) - // - Allow Escape to cancel composition - // - Block text input (let browser handle composition preview) - if document.peek().composition.is_some() { - if evt.key() == Key::Escape { - tracing::debug!("Escape pressed - cancelling composition"); - document.with_mut(|doc| { - doc.composition = None; - }); - return; - } - - // Allow modifier shortcuts through during composition - if !has_modifier { - tracing::debug!( - key = ?evt.key(), - "keydown during composition - delegating to browser" - ); - return; - } - // Fall through to handle the shortcut - } - - // Safari workaround: After Japanese IME composition ends, both - // compositionend and keydown fire for Enter. Ignore keydown - // within 500ms of composition end to prevent double-newline. - if plat.safari && evt.key() == Key::Enter { - if let Some(ended_at) = document.peek().composition_ended_at { - if ended_at.elapsed() < Duration::from_millis(500) { - tracing::debug!( - "Safari: ignoring Enter within 500ms of compositionend" - ); - return; - } - } - } - - // Android workaround: Chrome Android gets confused by Enter during/after - // composition. Defer Enter handling to onkeypress instead. - if plat.android && evt.key() == Key::Enter { - tracing::debug!("Android: deferring Enter to keypress"); - return; - } - - // Only prevent default for operations that modify content - // Let browser handle arrow keys, Home/End naturally - if should_intercept_key(&evt) { - evt.prevent_default(); - handle_keydown(evt, &mut document); - } - }, - - onkeyup: move |evt| { - use dioxus::prelude::keyboard_types::Key; - - // Navigation keys (with or without Shift for selection) - let navigation = matches!( - evt.key(), - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | - Key::Home | Key::End | Key::PageUp | Key::PageDown - ); - - // Cmd/Ctrl+A for select all - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) - && matches!(evt.key(), Key::Character(ref c) if c == "a"); - - if navigation || select_all { - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - } - }, - - onselect: move |_evt| { - tracing::debug!("onselect fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - }, - - onselectstart: move |_evt| { - tracing::debug!("onselectstart fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - }, - - onselectionchange: move |_evt| { - tracing::debug!("onselectionchange fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - }, - - onclick: move |_evt| { - tracing::debug!("onclick fired"); - let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); - let spans = syntax_spans(); - update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), - &spans, - ¶s, - ); - }, - - // Android workaround: Handle Enter in keypress instead of keydown. - // Chrome Android fires confused composition events on Enter in keydown, - // but keypress fires after composition state settles. - onkeypress: move |evt| { - use dioxus::prelude::keyboard_types::Key; - - let plat = platform::platform(); - if plat.android && evt.key() == Key::Enter { - tracing::debug!("Android: handling Enter in keypress"); - evt.prevent_default(); - handle_keydown(evt, &mut document); - } - }, - - onpaste: move |evt| { - handle_paste(evt, &mut document); - }, - - oncut: move |evt| { - handle_cut(evt, &mut document); - }, - - oncopy: move |evt| { - handle_copy(evt, &document); - }, - - onblur: move |_| { - // Cancel any in-progress IME composition on focus loss - let had_composition = document.peek().composition.is_some(); - if had_composition { - tracing::debug!("onblur: clearing active composition"); - } - document.with_mut(|doc| { - doc.composition = None; - }); - }, - - oncompositionstart: move |evt: CompositionEvent| { - let data = evt.data().data(); - tracing::debug!( - data = %data, - "compositionstart" - ); - document.with_mut(|doc| { - // Delete selection if present (composition replaces it) - if let Some(sel) = doc.selection.take() { - let (start, end) = - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - tracing::debug!( - start, - end, - "compositionstart: deleting selection" - ); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - } - - tracing::debug!( - cursor = doc.cursor.offset, - "compositionstart: setting composition state" - ); - doc.composition = Some(CompositionState { - start_offset: doc.cursor.offset, - text: data, - }); - }); - }, - - oncompositionupdate: move |evt: CompositionEvent| { - let data = evt.data().data(); - tracing::debug!( - data = %data, - "compositionupdate" - ); - document.with_mut(|doc| { - if let Some(ref mut comp) = doc.composition { - comp.text = data; - } else { - tracing::debug!("compositionupdate without active composition state"); - } - }); - }, - - oncompositionend: move |evt: CompositionEvent| { - let final_text = evt.data().data(); - tracing::debug!( - data = %final_text, - "compositionend" - ); - document.with_mut(|doc| { - // Record when composition ended for Safari timing workaround - doc.composition_ended_at = Some(web_time::Instant::now()); - - if let Some(comp) = doc.composition.take() { - tracing::debug!( - start_offset = comp.start_offset, - final_text = %final_text, - chars = final_text.chars().count(), - "compositionend: inserting text" - ); - - if !final_text.is_empty() { - let mut delete_start = comp.start_offset; - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - _ => break, - } - } - - let zw_count = doc.cursor.offset - delete_start; - if zw_count > 0 { - // Splice: delete zero-width chars and insert new char in one op - let _ = doc.replace_tracked(delete_start, zw_count, &final_text); - doc.cursor.offset = delete_start + final_text.chars().count(); - } else if doc.cursor.offset == doc.len_chars() { - // Fast path: append at end - let _ = doc.push_tracked(&final_text); - doc.cursor.offset = comp.start_offset + final_text.chars().count(); - } else { - let _ = doc.insert_tracked(doc.cursor.offset, &final_text); - doc.cursor.offset = comp.start_offset + final_text.chars().count(); - } - } - } else { - tracing::debug!("compositionend without active composition state"); - } - }); - }, - } - } - - EditorToolbar { - on_format: move |action| { - document.with_mut(|doc| { - formatting::apply_formatting(doc, action); - }); - } - } - } - - } -} - -/// Check if we need to intercept this key event -/// Returns true for content-modifying operations, false for navigation -fn should_intercept_key(evt: &Event) -> bool { - use dioxus::prelude::keyboard_types::Key; - - let key = evt.key(); - let mods = evt.modifiers(); - - // Handle Ctrl/Cmd shortcuts - if mods.ctrl() || mods.meta() { - if let Key::Character(ch) = &key { - // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) - match ch.as_str() { - "b" | "i" | "z" | "y" => return true, - "e" => return true, // Ctrl+E for HTML export/copy - _ => {} - } - } - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) - return false; - } - - // Intercept content modifications - matches!( - key, - Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab - ) -} - -/// Sync internal cursor and selection state from browser DOM selection -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn sync_cursor_from_dom( - document: &mut Signal, - editor_id: &str, - paragraphs: &[ParagraphRender], -) { - use wasm_bindgen::JsCast; - - // Early return if paragraphs not yet populated (first render edge case) - if paragraphs.is_empty() { - return; - } - - let window = match web_sys::window() { - Some(w) => w, - None => return, - }; - - let dom_document = match window.document() { - Some(d) => d, - None => return, - }; - - let editor_element = match dom_document.get_element_by_id(editor_id) { - Some(e) => e, - None => return, - }; - - let selection = match window.get_selection() { - Ok(Some(sel)) => sel, - _ => return, - }; - - // Get both anchor (selection start) and focus (selection end) positions - let anchor_node = match selection.anchor_node() { - Some(node) => node, - None => return, - }; - let focus_node = match selection.focus_node() { - Some(node) => node, - None => return, - }; - let anchor_offset = selection.anchor_offset() as usize; - let focus_offset = selection.focus_offset() as usize; - - // Convert both DOM positions to rope offsets using cached paragraphs - let anchor_rope = dom_position_to_rope_offset( - &dom_document, - &editor_element, - &anchor_node, - anchor_offset, - paragraphs, - ); - let focus_rope = dom_position_to_rope_offset( - &dom_document, - &editor_element, - &focus_node, - focus_offset, - paragraphs, - ); - - document.with_mut(|doc| { - match (anchor_rope, focus_rope) { - (Some(anchor), Some(focus)) => { - doc.cursor.offset = focus; - if anchor != focus { - // There's an actual selection - doc.selection = Some(Selection { - anchor, - head: focus, - }); - } else { - // Collapsed selection (just cursor) - doc.selection = None; - } - } - _ => { - tracing::warn!("Could not map DOM selection to rope offsets"); - } - } - }); -} - -/// Convert a DOM position (node + offset) to a rope char offset using offset maps -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn dom_position_to_rope_offset( - dom_document: &web_sys::Document, - editor_element: &web_sys::Element, - node: &web_sys::Node, - offset_in_text_node: usize, - paragraphs: &[ParagraphRender], -) -> Option { - use wasm_bindgen::JsCast; - - // Find the containing element with a node ID (walk up from text node) - let mut current_node = node.clone(); - let node_id = loop { - if let Some(element) = current_node.dyn_ref::() { - if element == editor_element { - // Selection is on the editor container itself (e.g., Cmd+A select all) - // Return boundary position based on offset: - // offset 0 = start of editor, offset == child count = end of editor - let child_count = editor_element.child_element_count() as usize; - if offset_in_text_node == 0 { - return Some(0); // Start of document - } else if offset_in_text_node >= child_count { - // End of document - find last paragraph's end - return paragraphs.last().map(|p| p.char_range.end); - } - break None; - } - - let id = element - .get_attribute("id") - .or_else(|| element.get_attribute("data-node-id")); - - if let Some(id) = id { - if id.starts_with('n') && id[1..].parse::().is_ok() { - break Some(id); - } - } - } - - current_node = current_node.parent_node()?; - }; - - let node_id = node_id?; - // Get the container element - let container = dom_document.get_element_by_id(&node_id).or_else(|| { - let selector = format!("[data-node-id='{}']", node_id); - dom_document.query_selector(&selector).ok().flatten() - })?; +// Storage +#[allow(unused_imports)] +pub use storage::{ + DRAFT_KEY_PREFIX, EditorSnapshot, clear_all_drafts, delete_draft, list_drafts, + load_from_storage, save_to_storage, +}; - // Calculate UTF-16 offset from start of container to the position - let mut utf16_offset_in_container = 0; - - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { - while let Ok(Some(text_node)) = walker.next_node() { - if &text_node == node { - utf16_offset_in_container += offset_in_text_node; - break; - } - - if let Some(text) = text_node.text_content() { - utf16_offset_in_container += text.encode_utf16().count(); - } - } - } - - // Look up in offset maps - for para in paragraphs { - for mapping in ¶.offset_map { - if mapping.node_id == node_id { - let mapping_start = mapping.char_offset_in_node; - let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; - - if utf16_offset_in_container >= mapping_start - && utf16_offset_in_container <= mapping_end - { - let offset_in_mapping = utf16_offset_in_container - mapping_start; - return Some(mapping.char_range.start + offset_in_mapping); - } - } - } - } - - None -} - -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -fn sync_cursor_from_dom( - _document: &mut Signal, - _editor_id: &str, - _paragraphs: &[ParagraphRender], -) { - // No-op on non-wasm -} - -/// Update syntax span visibility in the DOM based on cursor position. -/// -/// Toggles the "hidden" class on syntax spans based on calculated visibility. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn update_syntax_visibility( - cursor_offset: usize, - selection: Option<&Selection>, - syntax_spans: &[SyntaxSpanInfo], - paragraphs: &[ParagraphRender], -) { - let visibility = - visibility::VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); - - let Some(window) = web_sys::window() else { - return; - }; - let Some(document) = window.document() else { - return; - }; - - // Update each syntax span's visibility - for span in syntax_spans { - let selector = format!("[data-syn-id='{}']", span.syn_id); - if let Ok(Some(element)) = document.query_selector(&selector) { - let class_list = element.class_list(); - if visibility.is_visible(&span.syn_id) { - let _ = class_list.remove_1("hidden"); - } else { - let _ = class_list.add_1("hidden"); - } - } - } -} - -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -fn update_syntax_visibility( - _cursor_offset: usize, - _selection: Option<&Selection>, - _syntax_spans: &[SyntaxSpanInfo], - _paragraphs: &[ParagraphRender], -) { - // No-op on non-wasm -} - -/// Handle paste events and insert text at cursor -fn handle_paste(evt: Event, document: &mut Signal) { - evt.prevent_default(); - - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - // Try our custom type first (internal paste), fall back to text/plain - let text = data_transfer - .get_data("text/x-weaver-md") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| data_transfer.get_data("text/plain").ok()); - - if let Some(text) = text { - 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)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - doc.selection = None; - } - - // Insert pasted text - let _ = doc.insert_tracked(doc.cursor.offset, &text); - doc.cursor.offset += text.chars().count(); - }); - } - } - } else { - tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); - } - } -} - -/// Handle cut events - extract text, write to clipboard, then delete -fn handle_cut(evt: Event, document: &mut Signal) { - evt.prevent_default(); - - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - let cut_text = document.with_mut(|doc| { - if let Some(sel) = doc.selection { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - // Extract text and strip zero-width chars - let selected_text = doc.slice(start, end).unwrap_or_default(); - let clean_text = selected_text - .replace('\u{200C}', "") - .replace('\u{200B}', ""); - - // Write to clipboard BEFORE deleting (sync fallback) - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { - tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); - } - } - - // Now delete - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - doc.selection = None; - - return Some(clean_text); - } - } - None - }); - - // Async: also write custom MIME type for internal paste detection - if let Some(text) = cut_text { - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = write_clipboard_with_custom_type(&text).await { - tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); - } - }); - } - } - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - let _ = evt; // suppress unused warning - } -} - -/// Handle copy events - extract text, clean it up, write to clipboard -fn handle_copy(evt: Event, document: &Signal) { - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - use dioxus::web::WebEventExt; - use wasm_bindgen::JsCast; - - let base_evt = evt.as_web_event(); - if let Some(clipboard_evt) = base_evt.dyn_ref::() { - let doc = document.read(); - if let Some(sel) = doc.selection { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - // Extract text - let selected_text = doc.slice(start, end).unwrap_or_default(); - - // Strip zero-width chars used for gap handling - let clean_text = selected_text - .replace('\u{200C}', "") - .replace('\u{200B}', ""); - - // Sync fallback: write text/plain via DataTransfer - if let Some(data_transfer) = clipboard_evt.clipboard_data() { - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { - tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); - } - } - - // Async: also write custom MIME type for internal paste detection - let text_for_async = clean_text.clone(); - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { - tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); - } - }); - - // Prevent browser's default copy (which would copy rendered HTML) - evt.prevent_default(); - } - } - } - } - - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - let _ = (evt, document); // suppress unused warnings - } -} - -/// Copy markdown as rendered HTML to clipboard -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { - use js_sys::Array; - use wasm_bindgen::JsValue; - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; - - // Render markdown to HTML using ClientWriter - let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); - let mut html = String::new(); - weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( - parser.map(|(evt, _range)| evt), - &mut html, - ) - .run() - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; - - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; - let clipboard = window.navigator().clipboard(); - - // Create blobs for both HTML and plain text (raw HTML for inspection) - let parts = Array::new(); - parts.push(&JsValue::from_str(&html)); - - let mut html_opts = BlobPropertyBag::new(); - html_opts.type_("text/html"); - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; - - let mut text_opts = BlobPropertyBag::new(); - text_opts.type_("text/plain"); - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; - - // Create ClipboardItem with both types - let item_data = js_sys::Object::new(); - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; - - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; - let items = Array::new(); - items.push(&clipboard_item); - - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); - Ok(()) -} - -/// Write text to clipboard with both text/plain and custom MIME type -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { - use js_sys::{Array, Object, Reflect}; - use wasm_bindgen::JsValue; - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; - - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; - let navigator = window.navigator(); - let clipboard = navigator.clipboard(); - - // Create blobs for each MIME type - let text_parts = Array::new(); - text_parts.push(&JsValue::from_str(text)); - - let mut text_opts = BlobPropertyBag::new(); - text_opts.type_("text/plain"); - let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; - - let mut custom_opts = BlobPropertyBag::new(); - custom_opts.type_("text/x-weaver-md"); - let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; - - // Create ClipboardItem with both types - let item_data = Object::new(); - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; - Reflect::set( - &item_data, - &JsValue::from_str("text/x-weaver-md"), - &custom_blob, - )?; - - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; - let items = Array::new(); - items.push(&clipboard_item); - - let promise = clipboard.write(&items); - wasm_bindgen_futures::JsFuture::from(promise).await?; - - Ok(()) -} - -/// Extract a slice of text from a string by char indices -fn extract_text_slice(text: &str, start: usize, end: usize) -> String { - text.chars() - .skip(start) - .take(end.saturating_sub(start)) - .collect() -} - -/// 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; - } - "z" => { - if mods.shift() { - // Ctrl+Shift+Z = redo - if let Ok(true) = doc.redo() { - // Cursor position should be handled by the undo manager - // but we may need to clamp it - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); - } - } else { - // Ctrl+Z = undo - if let Ok(true) = doc.undo() { - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); - } - } - doc.selection = None; - return; - } - "y" => { - // Ctrl+Y = redo (alternative) - if let Ok(true) = doc.redo() { - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); - } - doc.selection = None; - return; - } - "e" => { - // Ctrl+E = copy as HTML (export) - if let Some(sel) = doc.selection { - let (start, end) = - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - if start != end { - if let Some(markdown) = doc.slice(start, end) { - let clean_md = markdown - .replace('\u{200C}', "") - .replace('\u{200B}', ""); - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - wasm_bindgen_futures::spawn_local(async move { - if let Err(e) = copy_as_html(&clean_md).await { - tracing::warn!("[COPY HTML] Failed: {:?}", e); - } - }); - } - } - } - return; - } - _ => {} - } - } - - // Insert character at cursor (replacing selection if any) - if let Some(sel) = doc.selection.take() { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); - doc.cursor.offset = start + ch.chars().count(); - } else { - // Clean up any preceding zero-width chars (gap scaffolding) - let mut delete_start = doc.cursor.offset; - while delete_start > 0 { - match get_char_at(doc.loro_text(), delete_start - 1) { - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, - _ => break, - } - } - - let zw_count = doc.cursor.offset - delete_start; - if zw_count > 0 { - // Splice: delete zero-width chars and insert new char in one op - let _ = doc.replace_tracked(delete_start, zw_count, &ch); - doc.cursor.offset = delete_start + ch.chars().count(); - } else if doc.cursor.offset == doc.len_chars() { - // Fast path: append at end - let _ = doc.push_tracked(&ch); - doc.cursor.offset += ch.chars().count(); - } else { - let _ = doc.insert_tracked(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)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - doc.selection = None; - } else if doc.cursor.offset > 0 { - // Check if we're about to delete a newline - let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); - - if prev_char == Some('\n') { - let newline_pos = doc.cursor.offset - 1; - let mut delete_start = newline_pos; - let mut delete_end = doc.cursor.offset; - - // Check if there's another newline before this one (empty paragraph) - // If so, delete both newlines to merge paragraphs - if newline_pos > 0 { - let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); - if prev_prev_char == Some('\n') { - // Empty paragraph case: delete both newlines - delete_start = newline_pos - 1; - } - } - - // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { - if ch == '\u{200C}' || ch == '\u{200B}' { - delete_end += 1; - } - } - - // Scan backwards through whitespace before the newline(s) - while delete_start > 0 { - let ch = get_char_at(doc.loro_text(), delete_start - 1); - match ch { - Some('\u{200C}') | Some('\u{200B}') => { - delete_start -= 1; - } - Some('\n') => break, // stop at another newline - _ => break, // stop at actual content - } - } - - // Delete from where we stopped to end (including any trailing zero-width) - let _ = doc - .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); - doc.cursor.offset = delete_start; - } else { - // Normal backspace - delete one char - let prev = doc.cursor.offset - 1; - let _ = doc.remove_tracked(prev, 1); - doc.cursor.offset = prev; - } - } - } - - Key::Delete => { - if let Some(sel) = doc.selection.take() { - // Delete selection - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - } else if doc.cursor.offset < doc.len_chars() { - // Delete next char - let _ = doc.remove_tracked(doc.cursor.offset, 1); - } - } - - // Arrow keys handled by browser, synced in onkeyup - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { - // Browser handles these naturally - } - - Key::Enter => { - if let Some(sel) = doc.selection.take() { - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); - let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; - } - - if mods.shift() { - // Shift+Enter: hard line break (soft break) - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); - doc.cursor.offset += 3; - } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { - // We're in a list item - if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { - // Empty item - exit list by removing marker and inserting paragraph break - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); - let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); - - // Delete the empty list item line INCLUDING its trailing newline - // line_end points to the newline, so +1 to include it - let delete_end = (line_end + 1).min(doc.len_chars()); - - // Use replace_tracked to atomically delete line and insert paragraph break - let _ = doc.replace_tracked( - line_start, - delete_end.saturating_sub(line_start), - "\n\n\u{200C}\n", - ); - doc.cursor.offset = line_start + 2; - } else { - // Non-empty item - continue list - let continuation = match ctx { - ListContext::Unordered { indent, marker } => { - format!("\n{}{} ", indent, marker) - } - ListContext::Ordered { indent, number } => { - format!("\n{}{}. ", indent, number + 1) - } - }; - let len = continuation.chars().count(); - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); - doc.cursor.offset += len; - } - } else { - // Not in a list - normal paragraph break - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); - doc.cursor.offset += 2; - } - } - - // Home/End handled by browser, synced in onkeyup - Key::Home | Key::End => { - // Browser handles these naturally - } - - _ => {} - } - - // Sync Loro cursor when edits affect paragraph boundaries - // This ensures cursor position is tracked correctly through structural changes - if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { - doc.sync_loro_cursor(); - } - }); -} - -/// Describes what kind of list item the cursor is in, if any -#[derive(Debug, Clone)] -enum ListContext { - /// Unordered list with the given marker char ('-' or '*') and indentation - Unordered { indent: String, marker: char }, - /// Ordered list with the current number and indentation - Ordered { indent: String, number: usize }, -} - -/// Detect if cursor is in a list item and return context for continuation. -/// -/// Scans backwards to find start of current line, then checks for list marker. -fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option { - // Find start of current line - let line_start = find_line_start(text, cursor_offset); - - // Get the line content from start to cursor - let line_end = find_line_end(text, cursor_offset); - if line_start >= line_end { - return None; - } - - // Extract line text - let line = text.slice(line_start, line_end).ok()?; - - // Parse indentation - let indent: String = line - .chars() - .take_while(|c| *c == ' ' || *c == '\t') - .collect(); - let trimmed = &line[indent.len()..]; - - // Check for unordered list marker: "- " or "* " - if trimmed.starts_with("- ") { - return Some(ListContext::Unordered { - indent, - marker: '-', - }); - } - if trimmed.starts_with("* ") { - return Some(ListContext::Unordered { - indent, - marker: '*', - }); - } - - // Check for ordered list marker: "1. ", "2. ", "123. ", etc. - if let Some(dot_pos) = trimmed.find(". ") { - let num_part = &trimmed[..dot_pos]; - if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { - if let Ok(number) = num_part.parse::() { - return Some(ListContext::Ordered { indent, number }); - } - } - } - - None -} - -/// Check if the current list item is empty (just the marker, no content after cursor). -/// -/// Used to determine whether Enter should continue the list or exit it. -fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { - let line_start = find_line_start(text, cursor_offset); - let line_end = find_line_end(text, cursor_offset); - - // Get line content - let line = match text.slice(line_start, line_end) { - Ok(s) => s, - Err(_) => return false, - }; - - // Calculate expected marker length - let marker_len = match ctx { - ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " - ListContext::Ordered { indent, number } => { - indent.len() + number.to_string().len() + 2 // "1. " - } - }; - - // Item is empty if line length equals marker length (nothing after marker) - line.len() <= marker_len -} - -/// Get character at the given offset in LoroText -fn get_char_at(text: &loro::LoroText, offset: usize) -> Option { - text.char_at(offset).ok() -} - -/// Find start of line containing offset -fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { - if offset == 0 { - return 0; - } - // Only slice the portion before cursor - let prefix = match text.slice(0, offset) { - Ok(s) => s, - Err(_) => return 0, - }; - prefix - .chars() - .enumerate() - .filter(|(_, c)| *c == '\n') - .last() - .map(|(pos, _)| pos + 1) - .unwrap_or(0) -} - -/// Find end of line containing offset -fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { - let char_len = text.len_unicode(); - if offset >= char_len { - return char_len; - } - // Only slice from cursor to end - let suffix = match text.slice(offset, char_len) { - Ok(s) => s, - Err(_) => return char_len, - }; - suffix - .chars() - .enumerate() - .find(|(_, c)| *c == '\n') - .map(|(i, _)| offset + i) - .unwrap_or(char_len) -} - -/// Update paragraph DOM elements incrementally. -/// -/// Only modifies paragraphs that changed (by comparing source_hash). -/// Browser preserves cursor naturally in unchanged paragraphs. -/// -/// Returns true if the paragraph containing the cursor was updated. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -fn update_paragraph_dom( - editor_id: &str, - old_paragraphs: &[ParagraphRender], - new_paragraphs: &[ParagraphRender], - cursor_offset: usize, -) -> bool { - use wasm_bindgen::JsCast; - - let window = match web_sys::window() { - Some(w) => w, - None => return false, - }; - - let document = match window.document() { - Some(d) => d, - None => return false, - }; - - let editor = match document.get_element_by_id(editor_id) { - Some(e) => e, - None => return false, - }; - - // Find which paragraph contains cursor - // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) - // This handles typing at end of paragraph, which is the common case - // The empty paragraph at document end catches any trailing cursor positions - let cursor_para_idx = new_paragraphs - .iter() - .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); - - let mut cursor_para_updated = false; - - // Update or create paragraphs - for (idx, new_para) in new_paragraphs.iter().enumerate() { - let para_id = format!("para-{}", idx); - - if let Some(old_para) = old_paragraphs.get(idx) { - // Paragraph exists - check if changed - if new_para.source_hash != old_para.source_hash { - // Changed - clear and update innerHTML - // We clear first to ensure any browser-added content (from IME composition, - // contenteditable quirks, etc.) is fully removed before setting new content - if let Some(elem) = document.get_element_by_id(¶_id) { - elem.set_text_content(None); // Clear completely - elem.set_inner_html(&new_para.html); - } - - // Track if we updated the cursor's paragraph - if Some(idx) == cursor_para_idx { - cursor_para_updated = true; - } - } - // Unchanged - do nothing, browser preserves cursor - } else { - // New paragraph - create div - if let Ok(div) = document.create_element("div") { - div.set_id(¶_id); - div.set_inner_html(&new_para.html); - let _ = editor.append_child(&div); - } - - // Track if we created the cursor's paragraph - if Some(idx) == cursor_para_idx { - cursor_para_updated = true; - } - } - } - - // Remove extra paragraphs if document got shorter - for idx in new_paragraphs.len()..old_paragraphs.len() { - let para_id = format!("para-{}", idx); - if let Some(elem) = document.get_element_by_id(¶_id) { - let _ = elem.remove(); - } - } +// UI components +pub use publish::PublishButton; +pub use report::ReportButton; +#[allow(unused_imports)] +pub use toolbar::EditorToolbar; - cursor_para_updated -} +// Visibility +#[allow(unused_imports)] +pub use visibility::VisibilityState; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -fn update_paragraph_dom( - _editor_id: &str, - _old_paragraphs: &[ParagraphRender], - _new_paragraphs: &[ParagraphRender], - _cursor_offset: usize, -) -> bool { - false -} +// Logging +#[allow(unused_imports)] +pub use log_buffer::LogCaptureLayer; diff --git a/crates/weaver-app/src/components/editor/paragraph.rs b/crates/weaver-app/src/components/editor/paragraph.rs index 67b27ce..b7bd920 100644 --- a/crates/weaver-app/src/components/editor/paragraph.rs +++ b/crates/weaver-app/src/components/editor/paragraph.rs @@ -44,4 +44,3 @@ pub fn hash_source(text: &str) -> u64 { pub fn text_slice_to_string(text: &LoroText, range: Range) -> String { text.slice(range.start, range.end).unwrap_or_default() } - diff --git a/crates/weaver-app/src/components/editor/platform.rs b/crates/weaver-app/src/components/editor/platform.rs index 23b5edf..ee697c9 100644 --- a/crates/weaver-app/src/components/editor/platform.rs +++ b/crates/weaver-app/src/components/editor/platform.rs @@ -80,10 +80,7 @@ fn detect_platform() -> Platform { let chrome_version = extract_version(&user_agent, "chrome/"); // Mobile detection - let mobile = ios - || android - || user_agent.contains("mobile") - || user_agent.contains("iemobile"); + let mobile = ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile"); Platform { ios, diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs new file mode 100644 index 0000000..be3a1fc --- /dev/null +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -0,0 +1,305 @@ +//! Entry publishing functionality for the markdown editor. +//! +//! Handles creating/updating AT Protocol notebook entries from editor state. + +use dioxus::prelude::*; +use jacquard::types::string::{AtUri, Datetime}; +use weaver_api::sh_weaver::embed::images::Images; +use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; +use weaver_common::{WeaverError, WeaverExt}; + +use crate::auth::AuthState; +use crate::fetch::Fetcher; + +use super::document::EditorDocument; +use super::storage::delete_draft; + +/// Result of a publish operation. +#[derive(Clone, Debug)] +pub enum PublishResult { + /// Entry was created (new) + Created(AtUri<'static>), + /// Entry was updated (existing) + Updated(AtUri<'static>), +} + +impl PublishResult { + pub fn uri(&self) -> &AtUri<'static> { + match self { + PublishResult::Created(uri) | PublishResult::Updated(uri) => uri, + } + } +} + +/// Publish an entry to the AT Protocol. +/// +/// # Arguments +/// * `fetcher` - The authenticated fetcher/client +/// * `doc` - The editor document containing entry data +/// * `notebook_title` - Title of the notebook to publish to +/// * `draft_key` - Storage key for the draft (for cleanup) +/// +/// # Returns +/// The AT-URI of the created/updated entry, or an error. +pub async fn publish_entry( + fetcher: &Fetcher, + doc: &EditorDocument, + notebook_title: &str, + draft_key: &str, +) -> Result { + // Get images from the document + let editor_images = doc.images(); + + // Build embeds if we have images + let entry_embeds = if editor_images.is_empty() { + None + } else { + // Extract Image types from EditorImage wrappers + let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect(); + + Some(EntryEmbeds { + images: Some(Images { + images, + extra_data: None, + }), + ..Default::default() + }) + }; + + // Build tags (convert Vec to the expected type) + let tags = { + let tag_strings = doc.tags(); + if tag_strings.is_empty() { + None + } else { + Some(tag_strings.into_iter().map(Into::into).collect()) + } + }; + + // Determine path - use doc path if set, otherwise slugify title + let path = { + let doc_path = doc.path(); + if doc_path.is_empty() { + slugify(&doc.title()) + } else { + doc_path + } + }; + + // Build the entry + let entry = Entry::new() + .content(doc.content()) + .title(doc.title()) + .path(path) + .created_at(Datetime::now()) + .maybe_tags(tags) + .maybe_embeds(entry_embeds) + .build(); + + // Publish via upsert_entry + let client = fetcher.get_client(); + let (uri, was_created) = client + .upsert_entry(notebook_title, &doc.title(), entry) + .await?; + + // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive) + // TODO: Implement when image upload is added + // for img in &editor_images { + // if let Some(ref published_uri) = img.published_blob_uri { + // let _ = delete_published_blob(fetcher, published_uri).await; + // } + // } + + // Clear local draft + delete_draft(draft_key); + + if was_created { + Ok(PublishResult::Created(uri)) + } else { + Ok(PublishResult::Updated(uri)) + } +} + +/// Simple slug generation from title. +fn slugify(title: &str) -> String { + title + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c + } else if c.is_whitespace() || c == '-' || c == '_' { + '-' + } else { + // Skip other characters + '\0' + } + }) + .filter(|&c| c != '\0') + .collect::() + // Collapse multiple dashes + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Props for the publish button component. +#[derive(Props, Clone, PartialEq)] +pub struct PublishButtonProps { + /// The editor document signal + pub document: Signal, + /// Storage key for the draft + pub draft_key: String, +} + +/// Publish button component with notebook selection. +#[component] +pub fn PublishButton(props: PublishButtonProps) -> Element { + let fetcher = use_context::(); + let auth_state = use_context::>(); + + let mut show_dialog = use_signal(|| false); + let mut notebook_title = use_signal(|| String::from("Default")); + let mut is_publishing = use_signal(|| false); + let mut error_message: Signal> = use_signal(|| None); + let mut success_uri: Signal>> = use_signal(|| None); + + let is_authenticated = auth_state.read().is_authenticated(); + let doc = props.document; + let draft_key = props.draft_key.clone(); + + // Validate that we have required fields + let can_publish = { + let d = doc(); + !d.title().trim().is_empty() && !d.content().trim().is_empty() + }; + + let open_dialog = move |_| { + error_message.set(None); + success_uri.set(None); + show_dialog.set(true); + }; + + let close_dialog = move |_| { + show_dialog.set(false); + }; + + let draft_key_clone = draft_key.clone(); + let do_publish = move |_| { + let fetcher = fetcher.clone(); + let draft_key = draft_key_clone.clone(); + let notebook = notebook_title(); + + spawn(async move { + is_publishing.set(true); + error_message.set(None); + + // Get document snapshot for publishing + let doc_snapshot = doc(); + + match publish_entry(&fetcher, &doc_snapshot, ¬ebook, &draft_key).await { + Ok(result) => { + success_uri.set(Some(result.uri().clone())); + } + Err(e) => { + error_message.set(Some(format!("{}", e))); + } + } + + is_publishing.set(false); + }); + }; + + rsx! { + button { + class: "publish-button", + disabled: !is_authenticated || !can_publish, + onclick: open_dialog, + title: if !is_authenticated { + "Log in to publish" + } else if !can_publish { + "Title and content required" + } else { + "Publish entry" + }, + "Publish" + } + + if show_dialog() { + div { + class: "publish-dialog-overlay", + onclick: close_dialog, + + div { + class: "publish-dialog", + onclick: move |e| e.stop_propagation(), + + h2 { "Publish Entry" } + + if let Some(uri) = success_uri() { + div { class: "publish-success", + p { "Entry published successfully!" } + a { + href: "{uri}", + target: "_blank", + "View entry →" + } + button { + class: "publish-done", + onclick: close_dialog, + "Done" + } + } + } else { + div { class: "publish-form", + div { class: "publish-field", + label { "Notebook" } + input { + r#type: "text", + class: "publish-input", + placeholder: "Notebook title...", + value: "{notebook_title}", + oninput: move |e| notebook_title.set(e.value()), + } + } + + div { class: "publish-preview", + p { "Title: {doc().title()}" } + p { "Path: {doc().path()}" } + if !doc().tags().is_empty() { + p { "Tags: {doc().tags().join(\", \")}" } + } + } + + if let Some(err) = error_message() { + div { class: "publish-error", + "{err}" + } + } + + div { class: "publish-actions", + button { + class: "publish-cancel", + onclick: close_dialog, + disabled: is_publishing(), + "Cancel" + } + button { + class: "publish-submit", + onclick: do_publish, + disabled: is_publishing() || notebook_title().trim().is_empty(), + if is_publishing() { + "Publishing..." + } else { + "Publish" + } + } + } + } + } + } + } + } + } +} diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 78f8e4e..3b4c71a 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -181,7 +181,11 @@ pub fn render_paragraphs_incremental( // Compute delta from actual length difference, not edit info // This handles stale edits gracefully (delta = 0 if lengths match) - let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); + let cached_len = cache + .paragraphs + .last() + .map(|p| p.char_range.end) + .unwrap_or(0); let char_delta = current_len as isize - cached_len as isize; // Adjust each cached paragraph's range diff --git a/crates/weaver-app/src/components/editor/report.rs b/crates/weaver-app/src/components/editor/report.rs index f538120..8db3440 100644 --- a/crates/weaver-app/src/components/editor/report.rs +++ b/crates/weaver-app/src/components/editor/report.rs @@ -27,8 +27,8 @@ impl ReportData { .map(|e| e.outer_html()) .unwrap_or_default(); - let editor_text = load_from_storage() - .map(|snapshot| snapshot.to_string()) + let editor_text = load_from_storage("current") + .map(|doc| doc.content()) .unwrap_or_default(); let platform_info = { diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index e0ad54a..f939c9f 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -2,6 +2,10 @@ //! //! Stores both human-readable content (for debugging) and the full CRDT //! snapshot (for undo history preservation across sessions). +//! +//! Storage key strategy: +//! - New entries: `"draft:new:{uuid}"` +//! - Editing existing: `"draft:{at-uri}"` #[cfg(all(target_family = "wasm", target_os = "unknown"))] use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; @@ -12,13 +16,18 @@ use serde::{Deserialize, Serialize}; use super::document::EditorDocument; +/// Prefix for all draft storage keys. +pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; + /// Editor snapshot for persistence. /// /// Stores both human-readable content and CRDT snapshot for best of both worlds: /// - `content`: Human-readable text for debugging -/// - `snapshot`: Base64-encoded CRDT state for document history +/// - `title`: Entry title for debugging/display in drafts list +/// - `snapshot`: Base64-encoded CRDT state for document history (includes all embeds) /// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position /// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored +/// - `editing_uri`: AT-URI if editing an existing entry /// /// Note: Undo/redo is session-only (UndoManager state is ephemeral). /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. @@ -26,20 +35,45 @@ use super::document::EditorDocument; pub struct EditorSnapshot { /// Human-readable document content (for debugging/fallback) pub content: String, - /// Base64-encoded CRDT snapshot + + /// Entry title (for debugging/display in drafts list) + #[serde(default)] + pub title: String, + + /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) + #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, + /// Loro Cursor for stable cursor position tracking + #[serde(default, skip_serializing_if = "Option::is_none")] pub cursor: Option, + /// Fallback cursor offset (used if Loro cursor can't be restored) + #[serde(default)] pub cursor_offset: usize, + + /// AT-URI if editing an existing entry (None for new entries) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub editing_uri: Option, } -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -const STORAGE_KEY: &str = "weaver_editor_draft"; +/// Build the full storage key from a draft key. +fn storage_key(key: &str) -> String { + format!("{}{}", DRAFT_KEY_PREFIX, key) +} /// Save editor state to LocalStorage (WASM only). +/// +/// # Arguments +/// * `doc` - The editor document to save +/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) +/// * `editing_uri` - AT-URI if editing an existing entry #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> { +pub fn save_to_storage( + doc: &EditorDocument, + key: &str, + editing_uri: Option<&str>, +) -> Result<(), gloo_storage::errors::StorageError> { let snapshot_bytes = doc.export_snapshot(); let snapshot_b64 = if snapshot_bytes.is_empty() { None @@ -48,20 +82,26 @@ pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors: }; let snapshot = EditorSnapshot { - content: doc.to_string(), + content: doc.content(), + title: doc.title(), snapshot: snapshot_b64, cursor: doc.loro_cursor().cloned(), cursor_offset: doc.cursor.offset, + editing_uri: editing_uri.map(String::from), }; - LocalStorage::set(STORAGE_KEY, &snapshot) + LocalStorage::set(storage_key(key), &snapshot) } /// Load editor state from LocalStorage (WASM only). +/// /// Returns an EditorDocument restored from CRDT snapshot if available, /// otherwise falls back to just the text content. +/// +/// # Arguments +/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn load_from_storage() -> Option { - let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?; +pub fn load_from_storage(key: &str) -> Option { + let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; // Try to restore from CRDT snapshot first if let Some(ref snapshot_b64) = snapshot.snapshot { @@ -72,7 +112,7 @@ pub fn load_from_storage() -> Option { snapshot.cursor_offset, ); // Verify the content matches (sanity check) - if doc.to_string() == snapshot.content { + if doc.content() == snapshot.content { return Some(doc); } tracing::warn!("Snapshot content mismatch, falling back to text content"); @@ -86,24 +126,77 @@ pub fn load_from_storage() -> Option { Some(doc) } -/// Clear editor state from LocalStorage (WASM only). +/// Delete a draft from LocalStorage (WASM only). +/// +/// # Arguments +/// * `key` - Storage key to delete +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn delete_draft(key: &str) { + LocalStorage::delete(storage_key(key)); +} + +/// List all draft keys from LocalStorage (WASM only). +/// +/// Returns a list of (key, title, editing_uri) tuples for all saved drafts. +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn list_drafts() -> Vec<(String, String, Option)> { + let mut drafts = Vec::new(); + + // gloo_storage doesn't have a direct way to iterate keys, + // so we use web_sys directly + if let Some(storage) = web_sys::window() + .and_then(|w| w.local_storage().ok()) + .flatten() + { + let len = storage.length().unwrap_or(0); + for i in 0..len { + if let Ok(Some(key)) = storage.key(i) { + if key.starts_with(DRAFT_KEY_PREFIX) { + // Try to load just the metadata + if let Ok(snapshot) = LocalStorage::get::(&key) { + let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); + drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri)); + } + } + } + } + } + + drafts +} + +/// Clear all editor drafts from LocalStorage (WASM only). #[cfg(all(target_family = "wasm", target_os = "unknown"))] #[allow(dead_code)] -pub fn clear_storage() { - LocalStorage::delete(STORAGE_KEY); +pub fn clear_all_drafts() { + for (key, _, _) in list_drafts() { + delete_draft(&key); + } } // Stub implementations for non-WASM targets #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> { +pub fn save_to_storage( + _doc: &EditorDocument, + _key: &str, + _editing_uri: Option<&str>, +) -> Result<(), String> { Ok(()) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn load_from_storage() -> Option { +pub fn load_from_storage(_key: &str) -> Option { None } +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn delete_draft(_key: &str) {} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn list_drafts() -> Vec<(String, String, Option)> { + Vec::new() +} + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] #[allow(dead_code)] -pub fn clear_storage() {} +pub fn clear_all_drafts() {} diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 2b5e7ac..e6fc2bc 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -418,11 +418,18 @@ fn regression_bug11_gap_paragraphs_for_whitespace() { // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) let result = render_test("# Title\n\n\n\nContent"); // 4 newlines assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); - assert!(result[1].html.contains("gap-"), "Middle element should be a gap"); + assert!( + result[1].html.contains("gap-"), + "Middle element should be a gap" + ); // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) let result2 = render_test("# Title\n\n\nContent"); // 3 newlines - assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent"); + assert_eq!( + result2.len(), + 2, + "Expected 2 elements with standard break equivalent" + ); } // ============================================================================= @@ -630,8 +637,8 @@ fn test_hash_alone() { fn test_heading_to_non_heading_transition() { // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) // This tests that the syntax spans are correctly updated on content change. - use loro::LoroDoc; use super::render::render_paragraphs_incremental; + use loro::LoroDoc; let doc = LoroDoc::new(); let text = doc.get_text("content"); @@ -680,7 +687,10 @@ fn test_empty_blockquote() { let result = render_test(">"); eprintln!("Paragraphs for '>': {:?}", result.len()); for (i, p) in result.iter().enumerate() { - eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range); + eprintln!( + " Para {}: html={}, char_range={:?}", + i, p.html, p.char_range + ); } // Empty blockquote should still produce at least one paragraph @@ -759,13 +769,21 @@ fn test_char_range_coverage_allows_paragraph_breaks() { // With standard \n\n break, we expect 2 paragraphs (no gap element) // Paragraph ranges include some trailing whitespace from markdown parsing - assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break"); + assert_eq!( + paragraphs.len(), + 2, + "Expected 2 paragraphs for standard break" + ); // First paragraph ends before second starts, with gap for \n\n let gap_start = paragraphs[0].char_range.end; let gap_end = paragraphs[1].char_range.start; let gap_size = gap_end - gap_start; - assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size); + assert!( + gap_size <= 2, + "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", + gap_size + ); } #[test] @@ -779,17 +797,25 @@ fn test_char_range_coverage_with_extra_whitespace() { let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); // With extra newlines, we expect 3 elements: para, gap, para - assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); + assert_eq!( + paragraphs.len(), + 3, + "Expected 3 elements with extra whitespace" + ); // Gap element should exist and cover whitespace zone let gap = ¶graphs[1]; assert!(gap.html.contains("gap-"), "Second element should be a gap"); // Gap should cover ALL whitespace (not just extra) - assert_eq!(gap.char_range.start, paragraphs[0].char_range.end, - "Gap should start where first paragraph ends"); - assert_eq!(gap.char_range.end, paragraphs[2].char_range.start, - "Gap should end where second paragraph starts"); + assert_eq!( + gap.char_range.start, paragraphs[0].char_range.end, + "Gap should start where first paragraph ends" + ); + assert_eq!( + gap.char_range.end, paragraphs[2].char_range.start, + "Gap should end where second paragraph starts" + ); } #[test] @@ -999,8 +1025,8 @@ fn test_loro_char_to_utf16_conversion() { // UTF-16: 0 1 2 3 4 5 6,7 8 9 10 assert_eq!(char_to_utf16(&text, 0), 0); - assert_eq!(char_to_utf16(&text, 6), 6); // before emoji - assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) + assert_eq!(char_to_utf16(&text, 6), 6); // before emoji + assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) assert_eq!(char_to_utf16(&text, 10), 11); // end } @@ -1026,11 +1052,18 @@ fn test_loro_ascii_fast_path() { if text.len_unicode() == text.len_utf16() { return char_pos; // fast path } - text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0) + text.slice(0, char_pos) + .map(|s| s.encode_utf16().count()) + .unwrap_or(0) } // All positions should be identity for ASCII for i in 0..=text.len_unicode() { - assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i); + assert_eq!( + char_to_utf16(&text, i), + i, + "ASCII fast path failed at pos {}", + i + ); } } diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs index 3400696..c972e5b 100644 --- a/crates/weaver-app/src/components/editor/visibility.rs +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -188,11 +188,59 @@ fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range, + syntax_spans: &[SyntaxSpanInfo], + paragraphs: &[ParagraphRender], +) { + let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); + + let Some(window) = web_sys::window() else { + return; + }; + let Some(document) = window.document() else { + return; + }; + + // Update each syntax span's visibility + for span in syntax_spans { + let selector = format!("[data-syn-id='{}']", span.syn_id); + if let Ok(Some(element)) = document.query_selector(&selector) { + let class_list = element.class_list(); + if visibility.is_visible(&span.syn_id) { + let _ = class_list.remove_1("hidden"); + } else { + let _ = class_list.add_1("hidden"); + } + } + } +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub fn update_syntax_visibility( + _cursor_offset: usize, + _selection: Option<&Selection>, + _syntax_spans: &[SyntaxSpanInfo], + _paragraphs: &[ParagraphRender], +) { + // No-op on non-wasm +} + #[cfg(test)] mod tests { use super::*; - fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo { + fn make_span( + syn_id: &str, + start: usize, + end: usize, + syntax_type: SyntaxType, + ) -> SyntaxSpanInfo { SyntaxSpanInfo { syn_id: syn_id.to_string(), char_range: start..end, @@ -240,16 +288,28 @@ mod tests { // Cursor at position 4 (middle of "bold", inside formatted region) let vis = VisibilityState::calculate(4, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region"); - assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region"); + assert!( + vis.is_visible("s0"), + "opening ** should be visible when cursor inside formatted region" + ); + assert!( + vis.is_visible("s1"), + "closing ** should be visible when cursor inside formatted region" + ); // Cursor at position 2 (adjacent to opening **, start of "bold") let vis = VisibilityState::calculate(2, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold"); + assert!( + vis.is_visible("s0"), + "opening ** should be visible when cursor adjacent at start of bold" + ); // Cursor at position 5 (adjacent to closing **, end of "bold") let vis = VisibilityState::calculate(5, None, &spans, ¶s); - assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold"); + assert!( + vis.is_visible("s1"), + "closing ** should be visible when cursor adjacent at end of bold" + ); } #[test] @@ -263,8 +323,14 @@ mod tests { // Cursor at position 4 (middle of "bold", not adjacent to either marker) let vis = VisibilityState::calculate(4, None, &spans, ¶s); - assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent"); - assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent"); + assert!( + !vis.is_visible("s0"), + "opening ** should be hidden when no formatted_range and cursor not adjacent" + ); + assert!( + !vis.is_visible("s1"), + "closing ** should be hidden when no formatted_range and cursor not adjacent" + ); } #[test] @@ -278,23 +344,30 @@ mod tests { // Cursor at position 4 (one before ** which starts at 5) let vis = VisibilityState::calculate(4, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent"); + assert!( + vis.is_visible("s0"), + "** should be visible when cursor adjacent" + ); // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) let vis = VisibilityState::calculate(7, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span"); + assert!( + vis.is_visible("s0"), + "** should be visible when cursor adjacent after span" + ); } #[test] fn test_inline_visibility_cursor_far() { - let spans = vec![ - make_span("s0", 10, 12, SyntaxType::Inline), - ]; + let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)]; let paras = vec![make_para(0, 33, spans.clone())]; // Cursor at position 0 (far from **) let vis = VisibilityState::calculate(0, None, &spans, ¶s); - assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away"); + assert!( + !vis.is_visible("s0"), + "** should be hidden when cursor far away" + ); } #[test] @@ -310,35 +383,40 @@ mod tests { // Cursor at position 5 (inside heading) let vis = VisibilityState::calculate(5, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph"); + assert!( + vis.is_visible("s0"), + "# should be visible when cursor in same paragraph" + ); } #[test] fn test_block_visibility_different_paragraph() { - let spans = vec![ - make_span("s0", 0, 2, SyntaxType::Block), - ]; - let paras = vec![ - make_para(0, 10, spans.clone()), - make_para(12, 30, vec![]), - ]; + let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)]; + let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])]; // Cursor at position 20 (in second paragraph) let vis = VisibilityState::calculate(20, None, &spans, ¶s); - assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph"); + assert!( + !vis.is_visible("s0"), + "# should be hidden when cursor in different paragraph" + ); } #[test] fn test_selection_reveals_syntax() { - let spans = vec![ - make_span("s0", 5, 7, SyntaxType::Inline), - ]; + let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)]; let paras = vec![make_para(0, 24, spans.clone())]; // Selection overlaps the syntax span - let selection = Selection { anchor: 3, head: 10 }; + let selection = Selection { + anchor: 3, + head: 10, + }; let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s); - assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); + assert!( + vis.is_visible("s0"), + "** should be visible when selection overlaps" + ); } #[test] @@ -351,26 +429,30 @@ mod tests { make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** ]; let paras = vec![ - make_para(0, 8, spans.clone()), // "**bold**" - make_para(9, 13, vec![]), // "text" (after newline) + make_para(0, 8, spans.clone()), // "**bold**" + make_para(9, 13, vec![]), // "text" (after newline) ]; // Cursor at position 9 (start of second paragraph) // Should NOT reveal the closing ** because para bounds clamp extension let vis = VisibilityState::calculate(9, None, &spans, ¶s); - assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph"); + assert!( + !vis.is_visible("s1"), + "closing ** should NOT be visible when cursor is in next paragraph" + ); } #[test] fn test_extension_clamps_to_paragraph() { // Syntax at very start of paragraph - extension left should stop at para start - let spans = vec![ - make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), - ]; + let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)]; let paras = vec![make_para(0, 8, spans.clone())]; // Cursor at position 0 - should still see the opening ** let vis = VisibilityState::calculate(0, None, &spans, ¶s); - assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0"); + assert!( + vis.is_visible("s0"), + "** at start should be visible when cursor at position 0" + ); } } diff --git a/crates/weaver-app/src/components/input/mod.rs b/crates/weaver-app/src/components/input/mod.rs index 9a8ae55..2590c01 100644 --- a/crates/weaver-app/src/components/input/mod.rs +++ b/crates/weaver-app/src/components/input/mod.rs @@ -1,2 +1,2 @@ mod component; -pub use component::*; \ No newline at end of file +pub use component::*; diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 950e888..b5f877e 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -127,5 +127,5 @@ static _IOSK_ITAL_BOLD: Asset = asset!( pub mod accordion; pub mod button; pub mod dialog; -pub mod input; pub mod editor; +pub mod input; diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index f60e75c..c908468 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -106,8 +106,8 @@ fn main() { { use tracing::Level; use tracing::subscriber::set_global_default; - use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::Registry; + use tracing_subscriber::layer::SubscriberExt; let console_level = if cfg!(debug_assertions) { Level::DEBUG diff --git a/crates/weaver-app/src/views/editor.rs b/crates/weaver-app/src/views/editor.rs index 1fd9874..0d78480 100644 --- a/crates/weaver-app/src/views/editor.rs +++ b/crates/weaver-app/src/views/editor.rs @@ -1,6 +1,6 @@ //! Editor view - wraps the MarkdownEditor component for the /editor route. -use crate::components::editor::{MarkdownEditor, ReportButton}; +use crate::components::editor::MarkdownEditor; use dioxus::prelude::*; /// Editor page view. @@ -12,13 +12,6 @@ pub fn Editor() -> Element { rsx! { EditorCss {} div { class: "editor-page", - div { class: "editor-header", - h1 { "Markdown Editor Test" } - ReportButton { - email: "editor-bugs@weaver.sh".to_string(), - editor_id: "markdown-editor".to_string(), - } - } MarkdownEditor { initial_content: None } } } @@ -46,4 +39,3 @@ pub fn EditorCss() -> Element { _ => rsx! {}, } } - -- knotmirror 0.0.1