From 157cb538e4f271eef8143f6253c0c27ad5073180 Mon Sep 17 00:00:00 2001 From: Orual Date: Thu, 27 Nov 2025 16:47:59 -0500 Subject: [PATCH] perf tuning/refactor --- crates/weaver-app/assets/styling/editor.css | 3 +- .../src/components/editor/component.rs | 762 ++++++++++-------- .../src/components/editor/cursor.rs | 29 +- .../src/components/editor/document.rs | 191 +++-- .../src/components/editor/dom_sync.rs | 100 ++- .../src/components/editor/formatting.rs | 73 +- .../weaver-app/src/components/editor/input.rs | 469 ++++++----- .../src/components/editor/offset_map.rs | 246 ++++++ .../src/components/editor/publish.rs | 26 +- .../src/components/editor/render.rs | 14 +- .../src/components/editor/storage.rs | 5 +- .../src/components/editor/visibility.rs | 36 +- .../src/components/editor/writer.rs | 376 +++++++-- 13 files changed, 1524 insertions(+), 806 deletions(-) diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 2c79053..64ccbaf 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -227,7 +227,8 @@ /* Hidden syntax spans - collapsed when cursor is not near */ .md-syntax-inline.hidden, -.md-syntax-block.hidden { +.md-syntax-block.hidden, +.image-alt.hidden { display: none; } diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 2fb25ae..0d18e0c 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -11,7 +11,8 @@ use crate::components::editor::ReportButton; use crate::fetch::Fetcher; use super::document::{CompositionState, EditorDocument}; -use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; +use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom}; +use super::offset_map::SnapDirection; use super::formatting; use super::input::{ get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, @@ -45,7 +46,8 @@ 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 || { + // Document is NOT in a signal - its fields are individually reactive + let mut document = use_hook(|| { storage::load_from_storage(draft_key) .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) }); @@ -58,14 +60,19 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { let mut image_resolver = use_signal(EditorImageResolver::default); // Render paragraphs with incremental caching + // Reads document.last_edit signal - creates dependency on content changes only + let doc_for_memo = document.clone(); let paragraphs = use_memo(move || { - let doc = document(); + let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency let cache = render_cache.peek(); - let edit = doc.last_edit.as_ref(); let resolver = image_resolver(); - let (paras, new_cache) = - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver)); + let (paras, new_cache) = render::render_paragraphs_incremental( + doc_for_memo.loro_text(), + Some(&cache), + edit.as_ref(), + Some(&resolver), + ); // Update cache for next render (write-only via spawn to avoid reactive loop) dioxus::prelude::spawn(async move { @@ -96,33 +103,31 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Update DOM when paragraphs change (incremental rendering) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let mut doc_for_dom = document.clone(); + #[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, + composition_active = doc_for_dom.composition.read().is_some(), + cursor = doc_for_dom.cursor.read().offset, "DOM update: checking state" ); // Skip DOM updates during IME composition - browser controls the preview - if doc.composition.is_some() { + if doc_for_dom.composition.read().is_some() { tracing::debug!("skipping DOM update during composition"); return; } tracing::debug!( - cursor = doc.cursor.offset, - len = doc.len_chars(), + cursor = doc_for_dom.cursor.read().offset, + len = doc_for_dom.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 cursor_offset = doc_for_dom.cursor.read().offset; + let selection = *doc_for_dom.selection.read(); let new_paras = paragraphs(); let map = offset_map(); @@ -138,11 +143,14 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; + // Read and consume pending snap direction + let snap_direction = doc_for_dom.pending_snap.write().take(); + // 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) + super::cursor::restore_cursor_position(cursor_offset, &map, editor_id, snap_direction) { tracing::warn!("Cursor restoration failed: {:?}", e); } @@ -165,11 +173,13 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Auto-save with periodic check (no reactive dependency to avoid loops) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let doc_for_autosave = document.clone(); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { // Check every 500ms if there are unsaved changes + let mut doc = doc_for_autosave.clone(); let interval = gloo_timers::callback::Interval::new(500, move || { - // Peek both signals without creating reactive dependencies - let current_frontiers = document.peek().state_frontiers(); + let current_frontiers = doc.state_frontiers(); // Only save if frontiers changed (document was edited) let needs_save = { @@ -181,10 +191,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { }; // drop last_frontiers borrow here if needs_save { - document.with_mut(|doc| { - doc.sync_loro_cursor(); - let _ = storage::save_to_storage(doc, draft_key); - }); + doc.sync_loro_cursor(); + let _ = storage::save_to_storage(&doc, draft_key); // Update last saved frontiers last_saved_frontiers.set(Some(current_frontiers)); @@ -195,6 +203,8 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Set up beforeinput listener for iOS/Android virtual keyboard quirks #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + let doc_for_beforeinput = document.clone(); + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -219,7 +229,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { None => return, }; - let mut document_signal = document; + let mut doc = doc_for_beforeinput.clone(); let cached_paras = cached_paragraphs; let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { @@ -235,23 +245,23 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { 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; - } + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.write().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; - } - }); + let cursor_offset = doc.cursor.read().offset; + if input_type == "insertLineBreak" { + // Soft break (like Shift+Enter) + let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); + doc.cursor.write().offset = cursor_offset + 3; + } else { + // Paragraph break + let _ = doc.insert_tracked(cursor_offset, "\n\n"); + doc.cursor.write().offset = cursor_offset + 2; + } } // Android workaround: When swipe keyboard picks a suggestion, @@ -264,12 +274,12 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { 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 mut doc_for_timeout = doc.clone(); 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); + sync_cursor_from_dom(&mut doc_for_timeout, editor_id, ¶s); }); let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), @@ -299,9 +309,12 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { r#type: "text", class: "title-input", placeholder: "Entry title...", - value: "{document().title()}", - oninput: move |e| { - document.with_mut(|doc| doc.set_title(&e.value())); + value: "{document.title()}", + oninput: { + let doc = document.clone(); + move |e| { + doc.set_title(&e.value()); + } }, } } @@ -314,9 +327,12 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { r#type: "text", class: "path-input", placeholder: "url-slug", - value: "{document().path()}", - oninput: move |e| { - document.with_mut(|doc| doc.set_path(&e.value())); + value: "{document.path()}", + oninput: { + let doc = document.clone(); + move |e| { + doc.set_path(&e.value()); + } }, } } @@ -324,16 +340,17 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { div { class: "meta-tags", label { "Tags" } div { class: "tags-container", - for tag in document().tags() { + for tag in document.tags() { span { class: "tag-chip", "{tag}" button { class: "tag-remove", onclick: { + let doc = document.clone(); let tag_to_remove = tag.clone(); move |_| { - document.with_mut(|doc| doc.remove_tag(&tag_to_remove)); + doc.remove_tag(&tag_to_remove); } }, "×" @@ -346,13 +363,16 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { 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()); + onkeydown: { + let doc = document.clone(); + 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(); + doc.add_tag(&tag); + new_tag.set(String::new()); + } } }, } @@ -360,7 +380,7 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } PublishButton { - document: document, + document: document.clone(), draft_key: draft_key.to_string(), } } @@ -372,197 +392,244 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { 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 - } + onkeydown: { + let mut doc = document.clone(); + 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 doc.composition.read().is_some() { + if evt.key() == Key::Escape { + tracing::debug!("Escape pressed - cancelling composition"); + doc.composition.set(None); + return; + } - // 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) { + // Allow modifier shortcuts through during composition + if !has_modifier { tracing::debug!( - "Safari: ignoring Enter within 500ms of compositionend" + key = ?evt.key(), + "keydown during composition - delegating to browser" ); return; } + // Fall through to handle the shortcut } - } - // 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; - } + // 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) = *doc.composition_ended_at.read() { + if ended_at.elapsed() < Duration::from_millis(500) { + tracing::debug!( + "Safari: ignoring Enter within 500ms of compositionend" + ); + 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); + // 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 doc); + } } }, - onkeyup: move |evt| { - use dioxus::prelude::keyboard_types::Key; + onkeyup: { + let mut doc = document.clone(); + move |evt| { + use dioxus::prelude::keyboard_types::Key; + + // Arrow keys with direction hint for snapping + let direction_hint = match evt.key() { + Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), + Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), + _ => None, + }; + + // 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 + ); - // 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"); - // 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(); + if let Some(dir) = direction_hint { + sync_cursor_from_dom_with_direction(&mut doc, editor_id, ¶s, Some(dir)); + } else { + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + } + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + selection.as_ref(), + &spans, + ¶s, + ); + } + } + }, - if navigation || select_all { + onselect: { + let mut doc = document.clone(); + move |_evt| { + tracing::debug!("onselect fired"); let paras = cached_paragraphs(); - sync_cursor_from_dom(&mut document, editor_id, ¶s); - let doc = document(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); update_syntax_visibility( - doc.cursor.offset, - doc.selection.as_ref(), + cursor_offset, + 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, - ); + onselectstart: { + let mut doc = document.clone(); + move |_evt| { + tracing::debug!("onselectstart fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + 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, - ); + onselectionchange: { + let mut doc = document.clone(); + move |_evt| { + tracing::debug!("onselectionchange fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + 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, - ); + onclick: { + let mut doc = document.clone(); + move |_evt| { + tracing::debug!("onclick fired"); + let paras = cached_paragraphs(); + sync_cursor_from_dom(&mut doc, editor_id, ¶s); + let spans = syntax_spans(); + let cursor_offset = doc.cursor.read().offset; + let selection = *doc.selection.read(); + update_syntax_visibility( + cursor_offset, + 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); + onkeypress: { + let mut doc = document.clone(); + 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 doc); + } } }, - onpaste: move |evt| { - handle_paste(evt, &mut document); + onpaste: { + let mut doc = document.clone(); + move |evt| { + handle_paste(evt, &mut doc); + } }, - oncut: move |evt| { - handle_cut(evt, &mut document); + oncut: { + let mut doc = document.clone(); + move |evt| { + handle_cut(evt, &mut doc); + } }, - oncopy: move |evt| { - handle_copy(evt, &document); + oncopy: { + let doc = document.clone(); + move |evt| { + handle_copy(evt, &doc); + } }, - 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"); + onblur: { + let mut doc = document.clone(); + move |_| { + // Cancel any in-progress IME composition on focus loss + let had_composition = doc.composition.read().is_some(); + if had_composition { + tracing::debug!("onblur: clearing active composition"); + } + doc.composition.set(None); } - 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| { + oncompositionstart: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let data = evt.data().data(); + tracing::debug!( + data = %data, + "compositionstart" + ); // Delete selection if present (composition replaces it) - if let Some(sel) = doc.selection.take() { + let sel = doc.selection.write().take(); + if let Some(sel) = sel { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); tracing::debug!( @@ -571,46 +638,51 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { "compositionstart: deleting selection" ); let _ = doc.remove_tracked(start, end.saturating_sub(start)); - doc.cursor.offset = start; + doc.cursor.write().offset = start; } + let cursor_offset = doc.cursor.read().offset; tracing::debug!( - cursor = doc.cursor.offset, + cursor = cursor_offset, "compositionstart: setting composition state" ); - doc.composition = Some(CompositionState { - start_offset: doc.cursor.offset, + doc.composition.set(Some(CompositionState { + start_offset: 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 { + oncompositionupdate: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let data = evt.data().data(); + tracing::debug!( + data = %data, + "compositionupdate" + ); + let mut comp_guard = doc.composition.write(); + if let Some(ref mut comp) = *comp_guard { 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| { + oncompositionend: { + let mut doc = document.clone(); + move |evt: CompositionEvent| { + let final_text = evt.data().data(); + tracing::debug!( + data = %final_text, + "compositionend" + ); // Record when composition ended for Safari timing workaround - doc.composition_ended_at = Some(web_time::Instant::now()); + doc.composition_ended_at.set(Some(web_time::Instant::now())); - if let Some(comp) = doc.composition.take() { + let comp = doc.composition.write().take(); + if let Some(comp) = comp { tracing::debug!( start_offset = comp.start_offset, final_text = %final_text, @@ -627,30 +699,31 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { } } - let zw_count = doc.cursor.offset - delete_start; + let cursor_offset = doc.cursor.read().offset; + let zw_count = 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() { + doc.cursor.write().offset = delete_start + final_text.chars().count(); + } else if 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(); + doc.cursor.write().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(); + let _ = doc.insert_tracked(cursor_offset, &final_text); + doc.cursor.write().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()}" }, + div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, ReportButton { email: "editor-bugs@weaver.sh".to_string(), editor_id: "markdown-editor".to_string(), @@ -660,117 +733,118 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Toolbar in grid column 2, row 3 EditorToolbar { - on_format: move |action| { - document.with_mut(|doc| { - formatting::apply_formatting(doc, action); - }); + on_format: { + let mut doc = document.clone(); + move |action| { + formatting::apply_formatting(&mut doc, action); + } }, - on_image: move |uploaded: super::image_upload::UploadedImage| { - // Build data URL for immediate preview - use base64::{Engine, engine::general_purpose::STANDARD}; - let data_url = format!( - "data:{};base64,{}", - uploaded.mime_type, - STANDARD.encode(&uploaded.data) - ); - - // Add to resolver for immediate display - let name = uploaded.name.clone(); - image_resolver.with_mut(|resolver| { - resolver.add_pending(name.clone(), data_url); - }); - - // Insert markdown image syntax at cursor - let alt_text = if uploaded.alt.is_empty() { - name.clone() - } else { - uploaded.alt.clone() - }; - let markdown = format!("![{}](/image/{})", alt_text, name); - - document.with_mut(|doc| { - let pos = doc.cursor.offset; - let _ = doc.insert_tracked(pos, &markdown); - doc.cursor.offset = pos + markdown.chars().count(); - }); - - // Upload to PDS in background if authenticated - let is_authenticated = auth_state.read().is_authenticated(); - if is_authenticated { - let fetcher = fetcher.clone(); - let name_for_upload = name.clone(); - let alt_for_upload = alt_text.clone(); - let data = uploaded.data.clone(); - - spawn(async move { - let client = fetcher.get_client(); - - // Upload blob and create temporary PublishedBlob record - match client.publish_blob(data, &name_for_upload, None).await { - Ok((strong_ref, published_blob)) => { - // Extract the blob from PublishedBlob - let blob = match published_blob.upload { - BlobRef::Blob(b) => b, - _ => { - tracing::warn!("Unexpected BlobRef variant"); - return; - } - }; - - // Get format from mime type - let format = blob - .mime_type - .0 - .strip_prefix("image/") - .unwrap_or("jpeg") - .to_string(); - - // Get DID from fetcher - let did = match fetcher.current_did().await { - Some(d) => d.to_string(), - None => { - tracing::warn!("No DID available"); - return; - } - }; - - let cid = blob.cid().to_string(); - - // Build Image using the builder API - let name_for_resolver = name_for_upload.clone(); - let image = Image::new() - .alt(alt_for_upload.to_cowstr()) - .image(BlobRef::Blob(blob)) - .name(name_for_upload.to_cowstr()) - .build(); - - // Add to document - document.with_mut(|doc| { - doc.add_image(&image, Some(&strong_ref.uri)); - }); - - // Promote from pending to uploaded in resolver - image_resolver.with_mut(|resolver| { - resolver.promote_to_uploaded( - &name_for_resolver, - cid, - did, - format, - ); - }); + on_image: { + let mut doc = document.clone(); + move |uploaded: super::image_upload::UploadedImage| { + // Build data URL for immediate preview + use base64::{Engine, engine::general_purpose::STANDARD}; + let data_url = format!( + "data:{};base64,{}", + uploaded.mime_type, + STANDARD.encode(&uploaded.data) + ); - tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); - } - Err(e) => { - tracing::error!(error = %e, "Failed to upload image"); - // Image stays as data URL - will work for preview but not publish - } - } + // Add to resolver for immediate display + let name = uploaded.name.clone(); + image_resolver.with_mut(|resolver| { + resolver.add_pending(name.clone(), data_url); }); - } else { - tracing::info!(name = %name, "Image added with data URL (not authenticated)"); + + // Insert markdown image syntax at cursor + let alt_text = if uploaded.alt.is_empty() { + name.clone() + } else { + uploaded.alt.clone() + }; + let markdown = format!("![{}](/image/{})", alt_text, name); + + let pos = doc.cursor.read().offset; + let _ = doc.insert_tracked(pos, &markdown); + doc.cursor.write().offset = pos + markdown.chars().count(); + + // Upload to PDS in background if authenticated + let is_authenticated = auth_state.read().is_authenticated(); + if is_authenticated { + let fetcher = fetcher.clone(); + let name_for_upload = name.clone(); + let alt_for_upload = alt_text.clone(); + let data = uploaded.data.clone(); + let mut doc_for_spawn = doc.clone(); + + spawn(async move { + let client = fetcher.get_client(); + + // Upload blob and create temporary PublishedBlob record + match client.publish_blob(data, &name_for_upload, None).await { + Ok((strong_ref, published_blob)) => { + // Extract the blob from PublishedBlob + let blob = match published_blob.upload { + BlobRef::Blob(b) => b, + _ => { + tracing::warn!("Unexpected BlobRef variant"); + return; + } + }; + + // Get format from mime type + let format = blob + .mime_type + .0 + .strip_prefix("image/") + .unwrap_or("jpeg") + .to_string(); + + // Get DID from fetcher + let did = match fetcher.current_did().await { + Some(d) => d.to_string(), + None => { + tracing::warn!("No DID available"); + return; + } + }; + + let cid = blob.cid().to_string(); + + // Build Image using the builder API + let name_for_resolver = name_for_upload.clone(); + let image = Image::new() + .alt(alt_for_upload.to_cowstr()) + .image(BlobRef::Blob(blob)) + .name(name_for_upload.to_cowstr()) + .build(); + + // Add to document + doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); + + // Promote from pending to uploaded in resolver + image_resolver.with_mut(|resolver| { + resolver.promote_to_uploaded( + &name_for_resolver, + cid, + did, + format, + ); + }); + + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); + } + Err(e) => { + tracing::error!(error = %e, "Failed to upload image"); + // Image stays as data URL - will work for preview but not publish + } + } + }); + } else { + tracing::info!(name = %name, "Image added with data URL (not authenticated)"); + } } - } + }, } } diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 29d50a1..9316f8d 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::{OffsetMapping, find_mapping_for_char}; +use super::offset_map::{OffsetMapping, SnapDirection, find_mapping_for_char, find_nearest_valid_position}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use wasm_bindgen::JsCast; @@ -18,6 +18,7 @@ use wasm_bindgen::JsCast; /// - `char_offset`: Cursor position as char offset in document /// - `offset_map`: Mappings from source to DOM positions /// - `editor_id`: DOM ID of the contenteditable element +/// - `snap_direction`: Optional direction hint for snapping from invisible content /// /// # Algorithm /// 1. Find offset mapping containing char_offset @@ -29,6 +30,7 @@ pub fn restore_cursor_position( char_offset: usize, offset_map: &[OffsetMapping], editor_id: &str, + snap_direction: Option, ) -> Result<(), wasm_bindgen::JsValue> { // Empty document - no cursor to restore if offset_map.is_empty() { @@ -51,9 +53,27 @@ pub fn restore_cursor_position( return Ok(()); } - // Find mapping for this cursor position - let (mapping, _should_snap) = find_mapping_for_char(offset_map, char_offset) - .ok_or("no mapping found for cursor offset")?; + // Find mapping for this cursor position, snapping if needed + let (mapping, char_offset) = match find_mapping_for_char(offset_map, char_offset) { + Some((m, false)) => (m, char_offset), // Valid position, use as-is + Some((m, true)) => { + // Position is on invisible content, snap to nearest valid + if let Some(snapped) = find_nearest_valid_position(offset_map, char_offset, snap_direction) { + tracing::trace!( + target: "weaver::cursor", + original_offset = char_offset, + snapped_offset = snapped.char_offset(), + direction = ?snapped.snapped, + "snapping cursor from invisible content" + ); + (snapped.mapping, snapped.char_offset()) + } else { + // Fallback to original mapping if no valid snap target + (m, char_offset) + } + } + None => return Err("no mapping found for cursor offset".into()), + }; tracing::trace!( target: "weaver::cursor", @@ -160,6 +180,7 @@ pub fn restore_cursor_position( _char_offset: usize, _offset_map: &[OffsetMapping], _editor_id: &str, + _snap_direction: Option, ) -> Result<(), String> { Ok(()) } diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 3d6aad2..ce7a4c9 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -2,7 +2,18 @@ //! //! Uses Loro CRDT for text storage with built-in undo/redo support. //! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. +//! +//! # Reactive Architecture +//! +//! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity: +//! - Cursor/selection changes don't trigger content re-renders +//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation +//! - The document struct itself is NOT wrapped in a Signal - use `use_hook` + +use std::cell::RefCell; +use std::rc::Rc; +use dioxus::prelude::*; use loro::{ ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, cursor::{Cursor, Side}, @@ -30,12 +41,26 @@ pub struct EditorImage { /// Contains the document text (backed by Loro CRDT), cursor position, /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` /// schema with CRDT containers for each field. -#[derive(Debug)] +/// +/// # Reactive Architecture +/// +/// The document itself is NOT wrapped in a Signal. Instead, individual fields +/// that need reactivity are wrapped in Signals: +/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates +/// - `last_edit` - triggers paragraph re-renders when content changes +/// +/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`. +/// +/// # Cloning +/// +/// EditorDocument is cheap to clone - Loro types are Arc-backed handles, +/// and Signals are Copy. Closures can capture clones without overhead. +#[derive(Clone)] pub struct EditorDocument { /// The Loro document containing all editor state. doc: LoroDoc, - // --- Entry schema containers --- + // --- Entry schema containers (Loro handles interior mutability) --- /// Markdown content (maps to entry.content) content: LoroText, @@ -60,31 +85,35 @@ pub struct EditorDocument { /// None for new entries that haven't been published yet. entry_uri: Option>, - // --- Editor state --- + // --- Editor state (non-reactive) --- /// Undo manager for the document. - undo_mgr: UndoManager, - - /// Current cursor position (char offset) - fast local cache. - /// This is the authoritative position for immediate operations. - pub cursor: CursorState, + undo_mgr: Rc>, /// CRDT-aware cursor that tracks position through remote edits and undo/redo. /// Recreated after our own edits, queried after undo/redo/remote edits. loro_cursor: Option, - /// Active selection if any - pub selection: Option, + // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- + /// Current cursor position. Signal so cursor changes don't dirty content memos. + pub cursor: Signal, - /// IME composition state (for Phase 3) - pub composition: Option, + /// Active selection if any. Signal for same reason as cursor. + pub selection: Signal>, + + /// IME composition state. Signal so composition updates are isolated. + pub composition: Signal>, /// Timestamp when the last composition ended. /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. - pub composition_ended_at: Option, + pub composition_ended_at: Signal>, /// Most recent edit info for incremental rendering optimization. - /// Used to determine if we can skip full re-parsing. - pub last_edit: Option, + /// Signal so paragraphs memo can subscribe to content changes only. + pub last_edit: Signal>, + + /// Pending snap direction for cursor restoration after edits. + /// Set by input handlers, consumed by cursor restoration. + pub pending_snap: Signal>, } /// Cursor state including position and affinity. @@ -121,7 +150,8 @@ pub struct CompositionState { } /// Information about the most recent edit, used for incremental rendering optimization. -#[derive(Clone, Debug, Default)] +/// Derives PartialEq so it can be used with Dioxus memos for change detection. +#[derive(Clone, Debug, Default, PartialEq)] pub struct EditInfo { /// Character offset where the edit occurred pub edit_char_pos: usize, @@ -170,6 +200,10 @@ impl EditorDocument { /// Create a new editor document with the given content. /// Sets `created_at` to current time. + /// + /// # Note + /// This creates Dioxus Signals for reactive fields. Call from within + /// a component using `use_hook(|| EditorDocument::new(...))`. pub fn new(initial_content: String) -> Self { let doc = LoroDoc::new(); @@ -211,16 +245,18 @@ impl EditorDocument { tags, embeds, entry_uri: None, - undo_mgr, - cursor: CursorState { + undo_mgr: Rc::new(RefCell::new(undo_mgr)), + loro_cursor, + // Reactive editor state - wrapped in Signals + cursor: Signal::new(CursorState { offset: 0, affinity: Affinity::Before, - }, - loro_cursor, - selection: None, - composition: None, - composition_ended_at: None, - last_edit: None, + }), + selection: Signal::new(None), + composition: Signal::new(None), + composition_ended_at: Signal::new(None), + last_edit: Signal::new(None), + pending_snap: Signal::new(None), } } @@ -284,7 +320,8 @@ impl EditorDocument { } /// Set the entry title (replaces existing). - pub fn set_title(&mut self, new_title: &str) { + /// Takes &self because Loro has interior mutability. + pub fn set_title(&self, new_title: &str) { let current_len = self.title.len_unicode(); if current_len > 0 { self.title.delete(0, current_len).ok(); @@ -298,7 +335,8 @@ impl EditorDocument { } /// Set the URL path/slug (replaces existing). - pub fn set_path(&mut self, new_path: &str) { + /// Takes &self because Loro has interior mutability. + pub fn set_path(&self, new_path: &str) { let current_len = self.path.len_unicode(); if current_len > 0 { self.path.delete(0, current_len).ok(); @@ -312,7 +350,8 @@ impl EditorDocument { } /// Set the created_at timestamp (usually only called once on creation or when loading). - pub fn set_created_at(&mut self, datetime: &str) { + /// Takes &self because Loro has interior mutability. + pub fn set_created_at(&self, datetime: &str) { let current_len = self.created_at.len_unicode(); if current_len > 0 { self.created_at.delete(0, current_len).ok(); @@ -346,7 +385,8 @@ impl EditorDocument { } /// Add a tag (if not already present). - pub fn add_tag(&mut self, tag: &str) { + /// Takes &self because Loro has interior mutability. + pub fn add_tag(&self, tag: &str) { let existing = self.tags(); if !existing.iter().any(|t| t == tag) { self.tags.push(LoroValue::String(tag.into())).ok(); @@ -354,7 +394,8 @@ impl EditorDocument { } /// Remove a tag by value. - pub fn remove_tag(&mut self, tag: &str) { + /// Takes &self because Loro has interior mutability. + pub fn remove_tag(&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) { @@ -367,7 +408,8 @@ impl EditorDocument { } /// Clear all tags. - pub fn clear_tags(&mut self) { + /// Takes &self because Loro has interior mutability. + pub fn clear_tags(&self) { let len = self.tags.len(); if len > 0 { self.tags.delete(0, len).ok(); @@ -484,14 +526,14 @@ impl EditorDocument { 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 { + self.last_edit.set(Some(EditInfo { edit_char_pos: pos, inserted_len: len_after.saturating_sub(len_before), deleted_len: 0, contains_newline: text.contains('\n'), in_block_syntax_zone, doc_len_after: len_after, - }); + })); result } @@ -501,14 +543,14 @@ impl EditorDocument { let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); let result = self.content.push_str(text); let len_after = self.content.len_unicode(); - self.last_edit = Some(EditInfo { + self.last_edit.set(Some(EditInfo { edit_char_pos: pos, inserted_len: text.chars().count(), deleted_len: 0, contains_newline: text.contains('\n'), in_block_syntax_zone, doc_len_after: len_after, - }); + })); result } @@ -519,14 +561,14 @@ impl EditorDocument { let in_block_syntax_zone = self.is_in_block_syntax_zone(start); let result = self.content.delete(start, len); - self.last_edit = Some(EditInfo { + self.last_edit.set(Some(EditInfo { edit_char_pos: start, inserted_len: 0, deleted_len: len, contains_newline, in_block_syntax_zone, doc_len_after: self.content.len_unicode(), - }); + })); result } @@ -545,14 +587,14 @@ impl EditorDocument { // because: len_after = len_before - deleted + inserted let inserted_len = (len_after + len).saturating_sub(len_before); - self.last_edit = Some(EditInfo { + self.last_edit.set(Some(EditInfo { edit_char_pos: start, inserted_len, deleted_len: len, contains_newline: delete_has_newline || text.contains('\n'), in_block_syntax_zone, doc_len_after: len_after, - }); + })); Ok(()) } @@ -564,10 +606,12 @@ impl EditorDocument { // so it tracks through the undo operation self.sync_loro_cursor(); - let result = self.undo_mgr.undo()?; + let result = self.undo_mgr.borrow_mut().undo()?; if result { // After undo, query Loro cursor for new position self.sync_cursor_from_loro(); + // Signal content change for re-render + self.last_edit.set(None); } Ok(result) } @@ -579,22 +623,24 @@ impl EditorDocument { // Sync Loro cursor to current position BEFORE redo self.sync_loro_cursor(); - let result = self.undo_mgr.redo()?; + let result = self.undo_mgr.borrow_mut().redo()?; if result { // After redo, query Loro cursor for new position self.sync_cursor_from_loro(); + // Signal content change for re-render + self.last_edit.set(None); } Ok(result) } /// Check if undo is available. pub fn can_undo(&self) -> bool { - self.undo_mgr.can_undo() + self.undo_mgr.borrow().can_undo() } /// Check if redo is available. pub fn can_redo(&self) -> bool { - self.undo_mgr.can_redo() + self.undo_mgr.borrow().can_redo() } /// Get a slice of the content text. @@ -606,7 +652,8 @@ impl EditorDocument { /// 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.content.get_cursor(self.cursor.offset, Side::default()); + let offset = self.cursor.read().offset; + self.loro_cursor = self.content.get_cursor(offset, Side::default()); } /// Update cursor.offset from the Loro cursor's tracked position. @@ -615,9 +662,9 @@ impl EditorDocument { pub fn sync_cursor_from_loro(&mut self) -> Option { let loro_cursor = self.loro_cursor.as_ref()?; let result = self.doc.get_cursor_pos(loro_cursor).ok()?; - let new_offset = result.current.pos; - self.cursor.offset = new_offset.min(self.len_chars()); - Some(self.cursor.offset) + let new_offset = result.current.pos.min(self.len_chars()); + self.cursor.with_mut(|c| c.offset = new_offset); + Some(new_offset) } /// Get the Loro cursor for serialization. @@ -646,6 +693,12 @@ impl EditorDocument { self.doc.state_frontiers() } + /// Get the last edit info for incremental rendering. + /// Reading this creates a reactive dependency on content changes. + pub fn last_edit(&self) -> Option { + self.last_edit.read().clone() + } + /// Create a new EditorDocument from a binary snapshot. /// Falls back to empty document if import fails. /// @@ -655,6 +708,10 @@ impl EditorDocument { /// Note: Undo/redo is session-only. The UndoManager tracks operations as they /// happen in real-time; it cannot rebuild history from imported CRDT ops. /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. + /// + /// # Note + /// This creates Dioxus Signals for reactive fields. Call from within + /// a component using `use_hook`. pub fn from_snapshot( snapshot: &[u8], loro_cursor: Option, @@ -691,14 +748,14 @@ impl EditorDocument { fallback_offset }; - let cursor = CursorState { + let cursor_state = CursorState { offset: cursor_offset.min(max_offset), affinity: Affinity::Before, }; // If no Loro cursor provided, create one at the restored position let loro_cursor = - loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default())); + loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default())); Self { doc, @@ -709,35 +766,23 @@ impl EditorDocument { tags, embeds, entry_uri: None, - undo_mgr, - cursor, + undo_mgr: Rc::new(RefCell::new(undo_mgr)), loro_cursor, - selection: None, - composition: None, - composition_ended_at: None, - last_edit: None, + // Reactive editor state - wrapped in Signals + cursor: Signal::new(cursor_state), + selection: Signal::new(None), + composition: Signal::new(None), + composition_ended_at: Signal::new(None), + last_edit: Signal::new(None), + pending_snap: Signal::new(None), } } } -// EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. -// This is intentional - the document should be the single source of truth. - -impl Clone for EditorDocument { - fn clone(&self) -> Self { - // 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; - new_doc.sync_loro_cursor(); - new_doc.selection = self.selection; - new_doc.composition = self.composition.clone(); - new_doc.composition_ended_at = self.composition_ended_at; - new_doc.last_edit = self.last_edit.clone(); - new_doc.entry_uri = self.entry_uri.clone(); - new_doc +impl PartialEq for EditorDocument { + fn eq(&self, _other: &Self) -> bool { + // EditorDocument uses interior mutability, so we can't meaningfully compare. + // Return false to ensure components re-render when passed as props. + false } } diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index ac227af..094d833 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -6,14 +6,31 @@ use dioxus::prelude::*; use super::document::{EditorDocument, Selection}; +use super::offset_map::{find_nearest_valid_position, is_valid_cursor_position, SnapDirection}; use super::paragraph::ParagraphRender; /// Sync internal cursor and selection state from browser DOM selection. +/// +/// The optional `direction_hint` is used when snapping cursor from invisible content. +/// Pass `SnapDirection::Backward` for left/up arrow keys, `SnapDirection::Forward` for right/down. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] pub fn sync_cursor_from_dom( - document: &mut Signal, + doc: &mut EditorDocument, editor_id: &str, paragraphs: &[ParagraphRender], +) { + sync_cursor_from_dom_with_direction(doc, editor_id, paragraphs, None); +} + +/// Sync cursor with optional direction hint for snapping. +/// +/// Use this when handling arrow keys to ensure cursor snaps in the expected direction. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub fn sync_cursor_from_dom_with_direction( + doc: &mut EditorDocument, + editor_id: &str, + paragraphs: &[ParagraphRender], + direction_hint: Option, ) { use wasm_bindgen::JsCast; @@ -61,6 +78,7 @@ pub fn sync_cursor_from_dom( &anchor_node, anchor_offset, paragraphs, + direction_hint, ); let focus_rope = dom_position_to_rope_offset( &dom_document, @@ -68,31 +86,33 @@ pub fn sync_cursor_from_dom( &focus_node, focus_offset, paragraphs, + direction_hint, ); - 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"); + match (anchor_rope, focus_rope) { + (Some(anchor), Some(focus)) => { + doc.cursor.write().offset = focus; + if anchor != focus { + // There's an actual selection + doc.selection.set(Some(Selection { + anchor, + head: focus, + })); + } else { + // Collapsed selection (just cursor) + doc.selection.set(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. +/// +/// The `direction_hint` is used when snapping from invisible content to determine +/// which direction to prefer. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] fn dom_position_to_rope_offset( dom_document: &web_sys::Document, @@ -100,6 +120,7 @@ fn dom_position_to_rope_offset( node: &web_sys::Node, offset_in_text_node: usize, paragraphs: &[ParagraphRender], + direction_hint: Option, ) -> Option { use wasm_bindgen::JsCast; @@ -170,20 +191,53 @@ fn dom_position_to_rope_offset( && 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); + let char_offset = mapping.char_range.start + offset_in_mapping; + + // Check if this position is valid (not on invisible content) + if is_valid_cursor_position(¶.offset_map, char_offset) { + return Some(char_offset); + } + + // Position is on invisible content, snap to nearest valid + if let Some(snapped) = + find_nearest_valid_position(¶.offset_map, char_offset, direction_hint) + { + return Some(snapped.char_offset()); + } + + // Fallback to original if no snap target + return Some(char_offset); } } } } + // No mapping found - try to find any valid position in paragraphs + // This handles clicks on non-text elements like images + for para in paragraphs { + if let Some(snapped) = find_nearest_valid_position(¶.offset_map, para.char_range.start, direction_hint) { + return Some(snapped.char_offset()); + } + } + None } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn sync_cursor_from_dom( - _document: &mut Signal, + _document: &mut EditorDocument, + _editor_id: &str, + _paragraphs: &[ParagraphRender], +) { + // No-op on non-wasm +} + +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +pub fn sync_cursor_from_dom_with_direction( + _document: &mut EditorDocument, _editor_id: &str, _paragraphs: &[ParagraphRender], + _direction_hint: Option, ) { // No-op on non-wasm } @@ -265,6 +319,10 @@ pub fn update_paragraph_dom( } // Remove extra paragraphs if document got shorter + // Also mark cursor as needing restoration since structure changed + if new_paragraphs.len() < old_paragraphs.len() { + cursor_para_updated = true; + } 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) { diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs index 88461ac..985d67d 100644 --- a/crates/weaver-app/src/components/editor/formatting.rs +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -1,8 +1,8 @@ //! Formatting actions and utilities for applying markdown formatting. -use super::input::{ListContext, detect_list_context, find_line_end}; - use super::document::EditorDocument; +use super::input::{ListContext, detect_list_context, find_line_end}; +use dioxus::prelude::*; /// Formatting actions available in the editor. #[derive(Clone, Debug, PartialEq)] @@ -59,12 +59,13 @@ pub fn find_word_boundaries(text: &loro::LoroText, offset: usize) -> (usize, usi /// /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { - let (start, end) = if let Some(sel) = doc.selection { + let cursor_offset = doc.cursor.read().offset; + let (start, end) = if let Some(sel) = *doc.selection.read() { // Use selection (sel.anchor.min(sel.head), sel.anchor.max(sel.head)) } else { // Expand to word - find_word_boundaries(doc.loro_text(), doc.cursor.offset) + find_word_boundaries(doc.loro_text(), cursor_offset) }; match action { @@ -72,51 +73,51 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { // Insert end marker first so start position stays valid let _ = doc.insert_tracked(end, "**"); let _ = doc.insert_tracked(start, "**"); - doc.cursor.offset = end + 4; - doc.selection = None; + doc.cursor.write().offset = end + 4; + doc.selection.set(None); } FormatAction::Italic => { let _ = doc.insert_tracked(end, "*"); let _ = doc.insert_tracked(start, "*"); - doc.cursor.offset = end + 2; - doc.selection = None; + doc.cursor.write().offset = end + 2; + doc.selection.set(None); } FormatAction::Strikethrough => { let _ = doc.insert_tracked(end, "~~"); let _ = doc.insert_tracked(start, "~~"); - doc.cursor.offset = end + 4; - doc.selection = None; + doc.cursor.write().offset = end + 4; + doc.selection.set(None); } FormatAction::Code => { let _ = doc.insert_tracked(end, "`"); let _ = doc.insert_tracked(start, "`"); - doc.cursor.offset = end + 2; - doc.selection = None; + doc.cursor.write().offset = end + 2; + doc.selection.set(None); } FormatAction::Link => { // Insert [selected text](url) let _ = doc.insert_tracked(end, "](url)"); let _ = doc.insert_tracked(start, "["); - doc.cursor.offset = end + 8; // Position cursor after ](url) - doc.selection = None; + doc.cursor.write().offset = end + 8; // Position cursor after ](url) + doc.selection.set(None); } FormatAction::Image => { // Insert ![alt text](url) let _ = doc.insert_tracked(end, "](url)"); let _ = doc.insert_tracked(start, "!["); - doc.cursor.offset = end + 9; - doc.selection = None; + doc.cursor.write().offset = end + 9; + doc.selection.set(None); } FormatAction::Heading(level) => { // Find start of current line - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), cursor_offset); let prefix = "#".repeat(level as usize) + " "; let _ = doc.insert_tracked(line_start, &prefix); - doc.cursor.offset += prefix.len(); - doc.selection = None; + doc.cursor.write().offset = cursor_offset + prefix.len(); + doc.selection.set(None); } FormatAction::BulletList => { - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { let continuation = match ctx { ListContext::Unordered { indent, marker } => { format!("\n{}{} ", indent, marker) @@ -126,18 +127,18 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { } }; let len = continuation.chars().count(); - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); - doc.cursor.offset += len; - doc.selection = None; + let _ = doc.insert_tracked(cursor_offset, &continuation); + doc.cursor.write().offset = cursor_offset + len; + doc.selection.set(None); } else { - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), cursor_offset); let _ = doc.insert_tracked(line_start, " - "); - doc.cursor.offset += 3; - doc.selection = None; + doc.cursor.write().offset = cursor_offset + 3; + doc.selection.set(None); } } FormatAction::NumberedList => { - if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { + if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { let continuation = match ctx { ListContext::Unordered { .. } => { format!("\n\n1. ") @@ -147,21 +148,21 @@ pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { } }; let len = continuation.chars().count(); - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); - doc.cursor.offset += len; - doc.selection = None; + let _ = doc.insert_tracked(cursor_offset, &continuation); + doc.cursor.write().offset = cursor_offset + len; + doc.selection.set(None); } else { - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), cursor_offset); let _ = doc.insert_tracked(line_start, "1. "); - doc.cursor.offset += 3; - doc.selection = None; + doc.cursor.write().offset = cursor_offset + 3; + doc.selection.set(None); } } FormatAction::Quote => { - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); + let line_start = find_line_start(doc.loro_text(), cursor_offset); let _ = doc.insert_tracked(line_start, "> "); - doc.cursor.offset += 2; - doc.selection = None; + doc.cursor.write().offset = cursor_offset + 2; + doc.selection.set(None); } } } diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs index 96235a3..6411d99 100644 --- a/crates/weaver-app/src/components/editor/input.rs +++ b/crates/weaver-app/src/components/editor/input.rs @@ -6,6 +6,7 @@ use dioxus::prelude::*; use super::document::EditorDocument; use super::formatting::{self, FormatAction}; +use super::offset_map::SnapDirection; /// Check if we need to intercept this key event. /// Returns true for content-modifying operations, false for navigation. @@ -37,248 +38,270 @@ pub fn should_intercept_key(evt: &Event) -> bool { } /// Handle keyboard events and update document state. -pub fn handle_keydown(evt: Event, document: &mut Signal) { +pub fn handle_keydown(evt: Event, doc: &mut EditorDocument) { 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) + 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()); + let max = doc.len_chars(); + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); + } + } else { + // Ctrl+Z = undo + if let Ok(true) = doc.undo() { + let max = doc.len_chars(); + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); } - 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); - } - }); - } + doc.selection.set(None); + return; + } + "y" => { + // Ctrl+Y = redo (alternative) + if let Ok(true) = doc.redo() { + let max = doc.len_chars(); + doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); + } + doc.selection.set(None); + return; + } + "e" => { + // Ctrl+E = copy as HTML (export) + if let Some(sel) = *doc.selection.read() { + 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; } - _ => {} + 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, - } + // Insert character at cursor (replacing selection if any) + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + 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.write().offset = start + ch.chars().count(); + } else { + // Clean up any preceding zero-width chars (gap scaffolding) + let cursor_offset = doc.cursor.read().offset; + let mut delete_start = 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(); - } + let zw_count = 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.write().offset = delete_start + ch.chars().count(); + } else if cursor_offset == doc.len_chars() { + // Fast path: append at end + let _ = doc.push_tracked(&ch); + doc.cursor.write().offset = cursor_offset + ch.chars().count(); + } else { + let _ = doc.insert_tracked(cursor_offset, &ch); + doc.cursor.write().offset = 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; - } + Key::Backspace => { + // Snap backward after backspace (toward deleted content) + doc.pending_snap.set(Some(SnapDirection::Backward)); + + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + // 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.write().offset = start; + } else if doc.cursor.read().offset > 0 { + let cursor_offset = doc.cursor.read().offset; + // Check if we're about to delete a newline + let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); + + if prev_char == Some('\n') { + let newline_pos = cursor_offset - 1; + let mut delete_start = newline_pos; + let mut delete_end = 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; - } + // 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 + // 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; } + + // 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.write().offset = delete_start; + } else { + // Normal backspace - delete one char + let prev = cursor_offset - 1; + let _ = doc.remove_tracked(prev, 1); + doc.cursor.write().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() { + Key::Delete => { + // Snap forward after delete (toward remaining content) + doc.pending_snap.set(Some(SnapDirection::Forward)); + + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + // 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.write().offset = start; + } else { + let cursor_offset = doc.cursor.read().offset; + if cursor_offset < doc.len_chars() { // Delete next char - let _ = doc.remove_tracked(doc.cursor.offset, 1); + let _ = doc.remove_tracked(cursor_offset, 1); } } + } - // Arrow keys handled by browser, synced in onkeyup - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { - // Browser handles these naturally - } + // 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; - } + Key::Enter => { + // Snap forward after enter (into new paragraph/line) + doc.pending_snap.set(Some(SnapDirection::Forward)); - 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; - } + let sel = doc.selection.write().take(); + if let Some(sel) = sel { + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); + let _ = doc.remove_tracked(start, end.saturating_sub(start)); + doc.cursor.write().offset = start; } - // Home/End handled by browser, synced in onkeyup - Key::Home | Key::End => { - // Browser handles these naturally + let cursor_offset = doc.cursor.read().offset; + if mods.shift() { + // Shift+Enter: hard line break (soft break) + let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); + doc.cursor.write().offset = cursor_offset + 3; + } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { + // We're in a list item + if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { + // Empty item - exit list by removing marker and inserting paragraph break + let line_start = find_line_start(doc.loro_text(), cursor_offset); + let line_end = find_line_end(doc.loro_text(), 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.write().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(cursor_offset, &continuation); + doc.cursor.write().offset = cursor_offset + len; + } + } else { + // Not in a list - normal paragraph break + let _ = doc.insert_tracked(cursor_offset, "\n\n"); + doc.cursor.write().offset = cursor_offset + 2; } - - _ => {} } - // 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(); + // 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 + .read() + .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) { +pub fn handle_paste(evt: Event, doc: &mut EditorDocument) { evt.prevent_default(); #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -297,19 +320,18 @@ pub fn handle_paste(evt: Event, document: &mut Signal, document: &mut Signal, document: &mut Signal) { +pub fn handle_cut(evt: Event, doc: &mut EditorDocument) { evt.prevent_default(); #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] @@ -329,8 +351,9 @@ pub fn handle_cut(evt: Event, document: &mut Signal() { - let cut_text = document.with_mut(|doc| { - if let Some(sel) = doc.selection { + let cut_text = { + let sel = doc.selection.write().take(); + if let Some(sel) = sel { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); if start != end { // Extract text and strip zero-width chars @@ -348,14 +371,16 @@ pub fn handle_cut(evt: Event, document: &mut Signal, document: &mut Signal, document: &Signal) { +pub fn handle_copy(evt: Event, doc: &EditorDocument) { #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use dioxus::web::WebEventExt; @@ -383,8 +408,8 @@ pub fn handle_copy(evt: Event, document: &Signal) 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 sel = *doc.selection.read(); + if let Some(sel) = sel { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); if start != end { // Extract text @@ -419,7 +444,7 @@ pub fn handle_copy(evt: Event, document: &Signal) #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] { - let _ = (evt, document); // suppress unused warnings + let _ = (evt, doc); // suppress unused warnings } } diff --git a/crates/weaver-app/src/components/editor/offset_map.rs b/crates/weaver-app/src/components/editor/offset_map.rs index d228e69..a809cab 100644 --- a/crates/weaver-app/src/components/editor/offset_map.rs +++ b/crates/weaver-app/src/components/editor/offset_map.rs @@ -153,6 +153,142 @@ pub fn find_mapping_for_char( Some((mapping, should_snap)) } +/// Direction hint for cursor snapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapDirection { + Backward, + Forward, +} + +/// Result of finding a valid cursor position. +#[derive(Debug, Clone)] +pub struct SnappedPosition<'a> { + pub mapping: &'a OffsetMapping, + pub offset_in_mapping: usize, + pub snapped: Option, +} + +impl SnappedPosition<'_> { + /// Get the absolute char offset for this position. + pub fn char_offset(&self) -> usize { + self.mapping.char_range.start + self.offset_in_mapping + } +} + +/// Find the nearest valid cursor position to a char offset. +/// +/// A valid position is one that maps to visible content (utf16_len > 0). +/// If the position is already valid, returns it directly. Otherwise, +/// searches in the preferred direction first, falling back to the other +/// direction if needed. +/// +/// # Arguments +/// - `offset_map`: The offset mappings for the paragraph +/// - `char_offset`: The target char offset +/// - `preferred_direction`: Which direction to search first when snapping +/// +/// # Returns +/// The snapped position, or None if no valid position exists. +pub fn find_nearest_valid_position( + offset_map: &[OffsetMapping], + char_offset: usize, + preferred_direction: Option, +) -> Option> { + if offset_map.is_empty() { + return None; + } + + // Try exact match first + if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { + if !should_snap { + // Position is valid, return it directly + let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: None, + }); + } + } + + // Position is invalid or not found - search for nearest valid + let search_order = match preferred_direction { + Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], + Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], + }; + + for direction in search_order { + if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { + return Some(pos); + } + } + + None +} + +/// Search for a valid position in a specific direction. +fn find_valid_in_direction( + offset_map: &[OffsetMapping], + char_offset: usize, + direction: SnapDirection, +) -> Option> { + match direction { + SnapDirection::Forward => { + // Find first visible mapping at or after char_offset + for mapping in offset_map { + if mapping.char_range.start >= char_offset && !mapping.is_invisible() { + return Some(SnappedPosition { + mapping, + offset_in_mapping: 0, + snapped: Some(SnapDirection::Forward), + }); + } + // Also check if char_offset falls within this visible mapping + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { + let offset_in_mapping = char_offset - mapping.char_range.start; + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Forward), + }); + } + } + None + } + SnapDirection::Backward => { + // Find last visible mapping at or before char_offset + for mapping in offset_map.iter().rev() { + if mapping.char_range.end <= char_offset && !mapping.is_invisible() { + // Snap to end of this mapping + let offset_in_mapping = mapping.char_range.len(); + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Backward), + }); + } + // Also check if char_offset falls within this visible mapping + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { + let offset_in_mapping = char_offset - mapping.char_range.start; + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Backward), + }); + } + } + None + } + } +} + +/// Check if a char offset is at a valid (non-invisible) cursor position. +pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { + find_mapping_for_char(offset_map, char_offset) + .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) + .unwrap_or(false) +} + #[cfg(test)] mod tests { use super::*; @@ -282,4 +418,114 @@ mod tests { assert!(mapping.contains_char(14)); assert!(!mapping.contains_char(15)); } + + fn make_test_mappings() -> Vec { + vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible: "![" + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, // visible: "alt" + }, + OffsetMapping { + byte_range: 5..15, + char_range: 5..15, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible: "](url.png)" + }, + OffsetMapping { + byte_range: 15..20, + char_range: 15..20, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 5, // visible: " text" + }, + ] + } + + #[test] + fn test_find_nearest_valid_position_exact_match() { + let mappings = make_test_mappings(); + + // Position 3 is in visible mapping (2..5) + let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); + assert_eq!(pos.char_offset(), 3); + assert!(pos.snapped.is_none()); + } + + #[test] + fn test_find_nearest_valid_position_snap_forward() { + let mappings = make_test_mappings(); + + // Position 0 is invisible, should snap forward to 2 + let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); + assert_eq!(pos.char_offset(), 2); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_find_nearest_valid_position_snap_backward() { + let mappings = make_test_mappings(); + + // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); + assert_eq!(pos.char_offset(), 5); // end of "alt" mapping + assert_eq!(pos.snapped, Some(SnapDirection::Backward)); + } + + #[test] + fn test_find_nearest_valid_position_default_forward() { + let mappings = make_test_mappings(); + + // Position 0 is invisible, None direction defaults to forward + let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); + assert_eq!(pos.char_offset(), 2); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_find_nearest_valid_position_snap_forward_from_invisible() { + let mappings = make_test_mappings(); + + // Position 10 is in invisible range (5..15), forward finds visible (15..20) + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); + assert_eq!(pos.char_offset(), 15); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_is_valid_cursor_position() { + let mappings = make_test_mappings(); + + // Invisible positions + assert!(!is_valid_cursor_position(&mappings, 0)); + assert!(!is_valid_cursor_position(&mappings, 1)); + assert!(!is_valid_cursor_position(&mappings, 10)); + + // Visible positions + assert!(is_valid_cursor_position(&mappings, 2)); + assert!(is_valid_cursor_position(&mappings, 3)); + assert!(is_valid_cursor_position(&mappings, 4)); + assert!(is_valid_cursor_position(&mappings, 15)); + assert!(is_valid_cursor_position(&mappings, 17)); + } + + #[test] + fn test_find_nearest_valid_position_empty() { + let mappings: Vec = vec![]; + assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); + } } diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs index 5ce85f4..85d5452 100644 --- a/crates/weaver-app/src/components/editor/publish.rs +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -213,8 +213,8 @@ fn slugify(title: &str) -> String { /// Props for the publish button component. #[derive(Props, Clone, PartialEq)] pub struct PublishButtonProps { - /// The editor document signal - pub document: Signal, + /// The editor document + pub document: EditorDocument, /// Storage key for the draft pub draft_key: String, } @@ -233,17 +233,14 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let mut success_uri: Signal>> = use_signal(|| None); let is_authenticated = auth_state.read().is_authenticated(); - let doc = props.document; + let doc = props.document.clone(); let draft_key = props.draft_key.clone(); // Check if we're editing an existing entry - let is_editing_existing = doc().entry_uri().is_some(); + let is_editing_existing = doc.entry_uri().is_some(); // Validate that we have required fields - let can_publish = { - let d = doc(); - !d.title().trim().is_empty() && !d.content().trim().is_empty() - }; + let can_publish = !doc.title().trim().is_empty() && !doc.content().trim().is_empty(); let open_dialog = move |_| { error_message.set(None); @@ -256,9 +253,11 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { }; let draft_key_clone = draft_key.clone(); + let doc_for_publish = doc.clone(); let do_publish = move |_| { let fetcher = fetcher.clone(); let draft_key = draft_key_clone.clone(); + let doc_snapshot = doc_for_publish.clone(); let notebook = if use_notebook() { Some(notebook_title()) } else { @@ -269,9 +268,6 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { is_publishing.set(true); error_message.set(None); - // Get document snapshot for publishing - let doc_snapshot = doc(); - match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await { Ok(result) => { success_uri.set(Some(result.uri().clone())); @@ -358,10 +354,10 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { } div { class: "publish-preview", - p { "Title: {doc().title()}" } - p { "Path: {doc().path()}" } - if !doc().tags().is_empty() { - p { "Tags: {doc().tags().join(\", \")}" } + p { "Title: {doc.title()}" } + p { "Path: {doc.path()}" } + if !doc.tags().is_empty() { + p { "Tags: {doc.tags().join(\", \")}" } } } diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index a2b70d6..fa74d9b 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -85,13 +85,7 @@ fn adjust_paragraph_positions( let mut adjusted_syntax = cached.syntax_spans.clone(); for span in &mut adjusted_syntax { - span.char_range.start = apply_delta(span.char_range.start, char_delta); - span.char_range.end = apply_delta(span.char_range.end, char_delta); - // Also adjust formatted_range if present (used for inline visibility) - if let Some(ref mut fr) = span.formatted_range { - fr.start = apply_delta(fr.start, char_delta); - fr.end = apply_delta(fr.end, char_delta); - } + span.adjust_positions(char_delta); } ParagraphRender { @@ -284,8 +278,7 @@ pub fn render_paragraphs_incremental( let mut adjusted_syntax = cached.syntax_spans.clone(); for span in &mut adjusted_syntax { - span.char_range.start = (span.char_range.start as isize + char_delta) as usize; - span.char_range.end = (span.char_range.end as isize + char_delta) as usize; + span.adjust_positions(char_delta); } (cached.html.clone(), adjusted_map, adjusted_syntax) @@ -356,8 +349,7 @@ pub fn render_paragraphs_incremental( mapping.char_range.end += para_char_start; } for span in &mut syntax_spans { - span.char_range.start += para_char_start; - span.char_range.end += para_char_start; + span.adjust_positions(para_char_start as isize); } (output, offset_map, syntax_spans) diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index 4ad34f9..75e9d90 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -9,6 +9,7 @@ #[cfg(all(target_family = "wasm", target_os = "unknown"))] use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use dioxus::prelude::*; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use gloo_storage::{LocalStorage, Storage}; use jacquard::IntoStatic; @@ -86,7 +87,7 @@ pub fn save_to_storage( title: doc.title(), snapshot: snapshot_b64, cursor: doc.loro_cursor().cloned(), - cursor_offset: doc.cursor.offset, + cursor_offset: doc.cursor.read().offset, editing_uri: doc.entry_uri().map(|u| u.to_string()), }; LocalStorage::set(storage_key(key), &snapshot) @@ -129,7 +130,7 @@ pub fn load_from_storage(key: &str) -> Option { // Fallback: create new doc from text content let mut doc = EditorDocument::new(snapshot.content); - doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); + doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); doc.sync_loro_cursor(); doc.set_entry_uri(entry_uri); Some(doc) diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs index c972e5b..db9b9b7 100644 --- a/crates/weaver-app/src/components/editor/visibility.rs +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -198,6 +198,8 @@ pub fn update_syntax_visibility( syntax_spans: &[SyntaxSpanInfo], paragraphs: &[ParagraphRender], ) { + use wasm_bindgen::JsCast; + let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); let Some(window) = web_sys::window() else { @@ -207,16 +209,30 @@ pub fn update_syntax_visibility( 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"); - } + // Single querySelectorAll instead of N individual queries + let Ok(node_list) = document.query_selector_all("[data-syn-id]") else { + return; + }; + + for i in 0..node_list.length() { + let Some(node) = node_list.item(i) else { + continue; + }; + + // Cast to Element to access attributes and class_list + let Some(element) = node.dyn_ref::() else { + continue; + }; + + let Some(syn_id) = element.get_attribute("data-syn-id") else { + continue; + }; + + let class_list = element.class_list(); + if visibility.is_visible(&syn_id) { + let _ = class_list.remove_1("hidden"); + } else { + let _ = class_list.add_1("hidden"); } } } diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 3787620..068b4bb 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -56,6 +56,22 @@ pub struct SyntaxSpanInfo { pub formatted_range: Option>, } +impl SyntaxSpanInfo { + /// Adjust all position fields by a character delta. + /// + /// This adjusts both `char_range` and `formatted_range` (if present) together, + /// ensuring they stay in sync. Use this instead of manually adjusting fields + /// to avoid forgetting one. + pub fn adjust_positions(&mut self, char_delta: isize) { + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; + if let Some(ref mut fr) = self.formatted_range { + fr.start = (fr.start as isize + char_delta) as usize; + fr.end = (fr.end as isize + char_delta) as usize; + } + } +} + /// Classify syntax text as inline or block level fn classify_syntax(text: &str) -> SyntaxType { let trimmed = text.trim_start(); @@ -838,13 +854,20 @@ impl< // the closing syntax must be emitted AFTER the closing HTML tag, not before. // Otherwise the closing `**` span ends up INSIDE the element. // These tags handle their own closing syntax in end_tag(). + // Image and Embed handle ALL their syntax in the Start event, so exclude them too. use markdown_weaver::TagEnd; - let is_inline_format_end = matches!( + let is_self_handled_end = matches!( &event, - Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Strikethrough) + Event::End( + TagEnd::Strong + | TagEnd::Emphasis + | TagEnd::Strikethrough + | TagEnd::Image + | TagEnd::Embed + ) ); - if matches!(&event, Event::End(_)) && !is_inline_format_end { + if matches!(&event, Event::End(_)) && !is_self_handled_end { // Emit gap from last_byte_offset to range.end self.emit_gap_before(range.end)?; } else if !matches!(&event, Event::End(_)) { @@ -976,6 +999,25 @@ impl< Ok(()) } + /// Consume events until End tag without writing anything. + /// Used when we've already extracted content from source and just need to advance the iterator. + fn consume_until_end(&mut self) { + use Event::*; + let mut nest = 0; + while let Some((event, _range)) = self.events.next() { + match event { + Start(_) => nest += 1, + End(_) => { + if nest == 0 { + break; + } + nest -= 1; + } + _ => {} + } + } + } + fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), W::Error> { use Event::*; @@ -1026,12 +1068,13 @@ impl< } } Code(text) => { - let char_start = self.last_char_offset; + let format_start = self.last_char_offset; let raw_text = &self.source[range.clone()]; - // Emit opening backtick and track it - if raw_text.starts_with('`') { + // Track opening span index so we can set formatted_range later + let opening_span_idx = if raw_text.starts_with('`') { let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; let backtick_char_end = char_start + 1; write!( &mut self.writer, @@ -1042,10 +1085,13 @@ impl< syn_id, char_range: char_start..backtick_char_end, syntax_type: SyntaxType::Inline, - formatted_range: None, + formatted_range: None, // Set after we know the full range }); self.last_char_offset += 1; - } + Some(self.syntax_spans.len() - 1) + } else { + None + }; self.write("")?; @@ -1070,20 +1116,31 @@ impl< "`", syn_id, backtick_char_start, backtick_char_end )?; + + // Now we know the full formatted range + let formatted_range = format_start..backtick_char_end; + self.syntax_spans.push(SyntaxSpanInfo { syn_id, char_range: backtick_char_start..backtick_char_end, syntax_type: SyntaxType::Inline, - formatted_range: None, + formatted_range: Some(formatted_range.clone()), }); + + // Update opening span with formatted_range + if let Some(idx) = opening_span_idx { + self.syntax_spans[idx].formatted_range = Some(formatted_range); + } + self.last_char_offset += 1; } } InlineMath(text) => { + let format_start = self.last_char_offset; let raw_text = &self.source[range.clone()]; - // Emit opening $ and track it - if raw_text.starts_with('$') { + // Track opening span index so we can set formatted_range later + let opening_span_idx = if raw_text.starts_with('$') { let syn_id = self.gen_syn_id(); let char_start = self.last_char_offset; let char_end = char_start + 1; @@ -1096,10 +1153,13 @@ impl< syn_id, char_range: char_start..char_end, syntax_type: SyntaxType::Inline, - formatted_range: None, + formatted_range: None, // Set after we know the full range }); self.last_char_offset += 1; - } + Some(self.syntax_spans.len() - 1) + } else { + None + }; self.write(r#""#)?; let text_char_len = text.chars().count(); @@ -1117,12 +1177,22 @@ impl< "$", syn_id, char_start, char_end )?; + + // Now we know the full formatted range + let formatted_range = format_start..char_end; + self.syntax_spans.push(SyntaxSpanInfo { syn_id, char_range: char_start..char_end, syntax_type: SyntaxType::Inline, - formatted_range: None, + formatted_range: Some(formatted_range.clone()), }); + + // Update opening span with formatted_range + if let Some(idx) = opening_span_idx { + self.syntax_spans[idx].formatted_range = Some(formatted_range); + } + self.last_char_offset += 1; } } @@ -1377,13 +1447,21 @@ impl< None } } - Tag::Link { .. } => { - if raw_text.starts_with('[') { + Tag::Link { link_type, .. } => { + if matches!(link_type, LinkType::WikiLink { .. }) { + if raw_text.starts_with("[[") { + Some("[[") + } else { + None + } + } else if raw_text.starts_with('[') { Some("[") } else { None } } + // Note: Tag::Image and Tag::Embed handle their own syntax spans + // in their respective handlers, so don't emit here _ => None, }; @@ -1425,9 +1503,11 @@ impl< let byte_end = range.start + syntax_byte_len; self.record_mapping(byte_start..byte_end, char_start..char_end); - // For paired inline syntax (Strong, Emphasis, Strikethrough), - // track the opening span so we can set formatted_range when closing - if matches!(tag, Tag::Strong | Tag::Emphasis | Tag::Strikethrough) { + // For paired inline syntax, track opening span for formatted_range + if matches!( + tag, + Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } + ) { self.pending_inline_formats.push((syn_id, char_start)); } @@ -1911,31 +1991,107 @@ impl< attrs, .. } => { - // Emit opening ![ - if range.start < range.end { - let raw_text = &self.source[range.clone()]; - if raw_text.starts_with("![") { - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let char_end = char_start + 2; + // Image rendering: all syntax elements share one syn_id for visibility toggling + // Structure: ![ alt text ](url) cursor-landing + let raw_text = &self.source[range.clone()]; + let syn_id = self.gen_syn_id(); + let opening_char_start = self.last_char_offset; - write!( - &mut self.writer, - "![", - syn_id, char_start, char_end - )?; + // Find the alt text and closing syntax positions + let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len()); + let alt_text = if raw_text.starts_with("![") && paren_pos > 2 { + &raw_text[2..paren_pos] + } else { + "" + }; + let closing_syntax = if paren_pos < raw_text.len() { + &raw_text[paren_pos..] + } else { + "" + }; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, - }); - } + // Calculate char positions + let alt_char_len = alt_text.chars().count(); + let closing_char_len = closing_syntax.chars().count(); + let opening_char_end = opening_char_start + 2; // "![" + let alt_char_start = opening_char_end; + let alt_char_end = alt_char_start + alt_char_len; + let closing_char_start = alt_char_end; + let closing_char_end = closing_char_start + closing_char_len; + let formatted_range = opening_char_start..closing_char_end; + + // 1. Emit opening ![ syntax span + if raw_text.starts_with("![") { + write!( + &mut self.writer, + "![", + syn_id, opening_char_start, opening_char_end + )?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: opening_char_start..opening_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + // Record offset mapping for ![ + self.record_mapping( + range.start..range.start + 2, + opening_char_start..opening_char_end, + ); + } + + // 2. Emit alt text span (same syn_id, editable when visible) + if !alt_text.is_empty() { + write!( + &mut self.writer, + "", + syn_id, alt_char_start, alt_char_end + )?; + escape_html(&mut self.writer, alt_text)?; + self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: alt_char_start..alt_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + // Record offset mapping for alt text + self.record_mapping( + range.start + 2..range.start + 2 + alt_text.len(), + alt_char_start..alt_char_end, + ); + } + + // 3. Emit closing ](url) syntax span + if !closing_syntax.is_empty() { + write!( + &mut self.writer, + "", + syn_id, closing_char_start, closing_char_end + )?; + escape_html(&mut self.writer, closing_syntax)?; + self.write("")?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: closing_char_start..closing_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + // Record offset mapping for ](url) + self.record_mapping( + range.start + paren_pos..range.end, + closing_char_start..closing_char_end, + ); } + // 4. Emit element (no syn_id - always visible) self.write("\"")?;")?; - // Emit closing ](url) - if range.start < range.end { - let raw_text = &self.source[range]; - if let Some(paren_pos) = raw_text.rfind("](") { - let syntax = &raw_text[paren_pos..]; - let syn_id = self.gen_syn_id(); - let char_start = self.last_char_offset; - let syntax_char_len = syntax.chars().count(); - let char_end = char_start + syntax_char_len; + // Consume the text events for alt (they're still in the iterator) + // Use consume_until_end() since we already wrote alt text from source + self.consume_until_end(); - write!( - &mut self.writer, - "", - syn_id, char_start, char_end - )?; - escape_html(&mut self.writer, syntax)?; - self.write("")?; + // Update offsets + self.last_char_offset = closing_char_end; + self.last_byte_offset = range.end; - self.syntax_spans.push(SyntaxSpanInfo { - syn_id, - char_range: char_start..char_end, - syntax_type: SyntaxType::Inline, - formatted_range: None, - }); - } - } Ok(()) } Tag::Embed { @@ -2009,7 +2146,7 @@ impl< title, id, attrs, - } => self.write_embed(embed_type, dest_url, title, id, attrs), + } => self.write_embed(range, embed_type, dest_url, title, id, attrs), Tag::WeaverBlock(_, _) => { self.in_non_writing_block = true; Ok(()) @@ -2305,7 +2442,37 @@ impl< self.finalize_paired_inline_format(); Ok(()) } - TagEnd::Link => self.write(""), + TagEnd::Link => { + self.write("")?; + // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) + let raw_text = &self.source[range.clone()]; + if raw_text.ends_with("]]") { + // WikiLink: emit ]] as closing syntax + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; + + write!( + &mut self.writer, + "]]", + syn_id, char_start, char_end + )?; + + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, // Will be set by finalize + }); + + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } else { + self.emit_gap_before(range.end)?; + } + self.finalize_paired_inline_format(); + Ok(()) + } TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event TagEnd::Embed => Ok(()), TagEnd::WeaverBlock(_) => { @@ -2340,12 +2507,51 @@ impl< { fn write_embed( &mut self, + range: Range, embed_type: EmbedType, dest_url: CowStr<'_>, title: CowStr<'_>, id: CowStr<'_>, attrs: Option>, ) -> Result<(), W::Error> { + // Track opening span index for formatted_range + let opening_span_idx: Option; + let opening_char_start: usize; + + // Emit opening ![[ + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if raw_text.starts_with("![[") { + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 3; // "![[" + + write!( + &mut self.writer, + "![[", + syn_id, char_start, char_end + )?; + + opening_span_idx = Some(self.syntax_spans.len()); + opening_char_start = char_start; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); + + self.last_char_offset = char_end; + self.last_byte_offset = range.start + 3; + } else { + opening_span_idx = None; + opening_char_start = self.last_char_offset; + } + } else { + opening_span_idx = None; + opening_char_start = self.last_char_offset; + } + // Try to get content from attributes first let content_from_attrs = if let Some(ref attrs) = attrs { attrs @@ -2413,6 +2619,42 @@ impl< } self.write(">")?; } + + // Emit closing ]] + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if raw_text.ends_with("]]") { + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; // "]]" + + write!( + &mut self.writer, + "]]", + syn_id, char_start, char_end + )?; + + // Set formatted_range on both opening and closing spans + let formatted_range = opening_char_start..char_end; + self.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + // Update opening span's formatted_range + if let Some(idx) = opening_span_idx { + if let Some(span) = self.syntax_spans.get_mut(idx) { + span.formatted_range = Some(formatted_range); + } + } + + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } + } + Ok(()) } } -- knotmirror 0.0.1