diff --git a/crates/weaver-app/src/components/editor/actions.rs b/crates/weaver-app/src/components/editor/actions.rs index a08165a..49c4d62 100644 --- a/crates/weaver-app/src/components/editor/actions.rs +++ b/crates/weaver-app/src/components/editor/actions.rs @@ -1,11 +1,11 @@ //! Editor actions and keybinding system. //! //! This module re-exports core types and provides Dioxus-specific conversions -//! and the concrete execute_action implementation for EditorDocument. +//! and the concrete execute_action implementation for SignalEditorDocument. use dioxus::prelude::*; -use super::document::EditorDocument; +use super::document::SignalEditorDocument; use super::platform::Platform; // Re-export core types. @@ -133,7 +133,7 @@ pub fn default_keybindings(platform: &Platform) -> KeybindingConfig { /// /// This is the central dispatch point for all editor operations. /// Returns true if the action was handled and the document was modified. -pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool { +pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { use super::formatting::{self, FormatAction}; use super::input::{ detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, @@ -573,7 +573,7 @@ pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool { } /// Find word boundary backward from cursor. -fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize { +fn find_word_boundary_backward(doc: &SignalEditorDocument, cursor: usize) -> usize { use super::input::get_char_at; if cursor == 0 { @@ -603,7 +603,7 @@ fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize { } /// Find word boundary forward from cursor. -fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize { +fn find_word_boundary_forward(doc: &SignalEditorDocument, cursor: usize) -> usize { use super::input::get_char_at; let len = doc.len_chars(); @@ -639,7 +639,7 @@ fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize { /// are handled by beforeinput. Navigation (arrows, etc.) is passed /// through to the browser. pub fn handle_keydown_with_bindings( - doc: &mut EditorDocument, + doc: &mut SignalEditorDocument, config: &KeybindingConfig, combo: KeyCombo, range: Range, diff --git a/crates/weaver-app/src/components/editor/beforeinput.rs b/crates/weaver-app/src/components/editor/beforeinput.rs index 9967753..a2ad9b1 100644 --- a/crates/weaver-app/src/components/editor/beforeinput.rs +++ b/crates/weaver-app/src/components/editor/beforeinput.rs @@ -17,7 +17,7 @@ use dioxus::prelude::*; use super::actions::{EditorAction, execute_action}; -use super::document::EditorDocument; +use super::document::SignalEditorDocument; use super::platform::Platform; // Re-export types from extracted crates. @@ -33,7 +33,7 @@ pub use weaver_editor_browser::StaticRange; /// Returns whether the event was handled and default should be prevented. #[allow(dead_code)] pub fn handle_beforeinput( - doc: &mut EditorDocument, + doc: &mut SignalEditorDocument, ctx: BeforeInputContext<'_>, ) -> BeforeInputResult { // During composition, let the browser handle most things. @@ -316,7 +316,7 @@ pub fn handle_beforeinput( } /// Get the current range based on cursor and selection state. -fn get_current_range(doc: &EditorDocument) -> Range { +fn get_current_range(doc: &SignalEditorDocument) -> Range { if let Some(sel) = *doc.selection.read() { let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); Range::new(start, end) diff --git a/crates/weaver-app/src/components/editor/collab.rs b/crates/weaver-app/src/components/editor/collab.rs index b9211b1..699310f 100644 --- a/crates/weaver-app/src/components/editor/collab.rs +++ b/crates/weaver-app/src/components/editor/collab.rs @@ -11,12 +11,12 @@ // Only compile for WASM - no-op stub provided at end -use super::document::EditorDocument; +use super::document::SignalEditorDocument; use dioxus::prelude::*; #[cfg(target_arch = "wasm32")] -use jacquard::smol_str::{format_smolstr, SmolStr}; +use jacquard::smol_str::{SmolStr, format_smolstr}; #[cfg(target_arch = "wasm32")] use jacquard::types::string::AtUri; @@ -38,7 +38,7 @@ const PEER_DISCOVERY_INTERVAL_MS: u32 = 30 * 1000; // 30 seconds #[derive(Props, Clone, PartialEq)] pub struct CollabCoordinatorProps { /// The editor document to sync - pub document: EditorDocument, + pub document: SignalEditorDocument, /// Resource URI for the document being edited pub resource_uri: String, /// Presence state signal (updated by coordinator) @@ -239,7 +239,8 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let strong_ref = match fetcher.confirm_record_ref(&uri).await { Ok(r) => r, Err(e) => { - let err = format_smolstr!("Failed to get resource ref: {e}"); + let err = + format_smolstr!("Failed to get resource ref: {e}"); debug_state .with_mut(|ds| ds.last_error = Some(err.clone())); state.set(CoordinatorState::Error(err)); @@ -336,7 +337,9 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { }) .await { - tracing::error!("CollabCoordinator: AddPeers send failed: {e}"); + tracing::error!( + "CollabCoordinator: AddPeers send failed: {e}" + ); } } else { tracing::error!("CollabCoordinator: sink is None!"); @@ -395,29 +398,37 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let fetcher = fetcher.clone(); // Get our profile info and send BroadcastJoin - let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await { + let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher + .current_did() + .await + { Some(did) => { - let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await { - Ok(profile) => { - match &profile.inner { - ProfileDataViewInner::ProfileView(p) => { - p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) - } - ProfileDataViewInner::ProfileViewDetailed(p) => { - p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) - } + let display_name: SmolStr = + match fetcher.fetch_profile(&did.clone().into()).await { + Ok(profile) => match &profile.inner { + ProfileDataViewInner::ProfileView(p) => p + .display_name + .as_ref() + .map(|s| s.as_ref().into()) + .unwrap_or_else(|| did.as_ref().into()), + ProfileDataViewInner::ProfileViewDetailed(p) => p + .display_name + .as_ref() + .map(|s| s.as_ref().into()) + .unwrap_or_else(|| did.as_ref().into()), ProfileDataViewInner::TangledProfileView(p) => { p.handle.as_ref().into() } _ => did.as_ref().into(), - } - } - Err(_) => did.as_ref().into(), - }; + }, + Err(_) => did.as_ref().into(), + }; (did.as_ref().into(), display_name) } None => { - tracing::warn!("CollabCoordinator: no current DID for Join message"); + tracing::warn!( + "CollabCoordinator: no current DID for Join message" + ); ("unknown".into(), "Anonymous".into()) } }; @@ -430,7 +441,9 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { }) .await { - tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}"); + tracing::error!( + "CollabCoordinator: BroadcastJoin send failed: {e}" + ); } } } @@ -460,11 +473,18 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { let position = cursor.offset; let sel = selection.map(|s| (s.anchor, s.head)); - tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting"); + tracing::debug!( + position, + ?sel, + "CollabCoordinator: cursor changed, broadcasting" + ); spawn(async move { if let Some(ref mut s) = *worker_sink.write() { - tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker"); + tracing::debug!( + position, + "CollabCoordinator: sending BroadcastCursor to worker" + ); if let Err(e) = s .send(WorkerInput::BroadcastCursor { position, @@ -475,7 +495,10 @@ pub fn CollabCoordinator(props: CollabCoordinatorProps) -> Element { tracing::warn!("Failed to send BroadcastCursor to worker: {e}"); } } else { - tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast"); + tracing::debug!( + position, + "CollabCoordinator: worker sink not ready, skipping cursor broadcast" + ); } }); }); diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index fef4a57..e22c7c4 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -9,7 +9,7 @@ use super::actions::{ use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use super::beforeinput::{get_data_from_event, get_target_range_from_event}; -use super::document::{CompositionState, EditorDocument, LoadedDocState}; +use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use super::dom_sync::update_paragraph_dom; use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; @@ -421,7 +421,7 @@ fn MarkdownEditorInner( #[allow(unused_mut)] let mut document = use_hook(|| { - let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); + let mut doc = SignalEditorDocument::from_loaded_state(loaded_state.clone()); // Seed collected_refs with existing record embeds so they get fetched/rendered let record_embeds = doc.record_embeds(); @@ -469,6 +469,8 @@ fn MarkdownEditorInner( let entry_index_for_memo = entry_index.clone(); #[allow(unused_mut)] let mut paragraphs = use_memo(move || { + // Read content_changed to establish reactive dependency + let _ = doc_for_memo.content_changed.read(); let edit = doc_for_memo.last_edit(); let cache = render_cache.peek(); let resolver = image_resolver(); @@ -481,7 +483,7 @@ fn MarkdownEditorInner( let cursor_offset = doc_for_memo.cursor.read().offset; let (paras, new_cache, refs) = render::render_paragraphs_incremental( - doc_for_memo.loro_text(), + doc_for_memo.buffer(), Some(&cache), cursor_offset, edit.as_ref(), @@ -501,9 +503,9 @@ fn MarkdownEditorInner( // Background fetch for AT embeds via worker #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { - use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; use dioxus::prelude::Writable; use gloo_worker::Spawnable; + use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; let resolved_content_for_fetch = resolved_content; let mut embed_worker_bridge: Signal>> = @@ -559,7 +561,7 @@ fn MarkdownEditorInner( .filter_map(|r| match r { weaver_common::ExtractedRef::AtEmbed { uri, .. } => { // Skip if already resolved - if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { if current_resolved.get_embed_content(&at_uri).is_none() { return Some(uri.clone()); } @@ -598,7 +600,7 @@ fn MarkdownEditorInner( .filter_map(|r| match r { weaver_common::ExtractedRef::AtEmbed { uri, .. } => { // Skip if already resolved - if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { if current_resolved.get_embed_content(&at_uri).is_none() { return Some(uri.clone()); } @@ -731,10 +733,10 @@ fn MarkdownEditorInner( // Worker-based autosave (offloads export + encode to worker thread) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { - use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; use gloo_storage::Storage; use gloo_worker::Spawnable; use gloo_worker::reactor::ReactorBridge; + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; use futures_util::stream::{SplitSink, SplitStream}; @@ -1810,7 +1812,7 @@ fn MarkdownEditorInner( #[component] fn RemoteCursors( presence: Signal, - document: EditorDocument, + document: SignalEditorDocument, render_cache: Signal, ) -> Element { let presence_read = presence.read(); diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 65b95e2..5eb60af 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -7,18 +7,13 @@ //! //! 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 +//! - Content changes bump `content_changed` Signal to trigger paragraph re-renders //! - The document struct itself is NOT wrapped in a Signal - use `use_hook` -use std::borrow::Cow; -use std::cell::RefCell; -use std::rc::Rc; - use dioxus::prelude::*; use loro::{ - ExportMode, Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, - UndoManager, VersionVector, - cursor::{Cursor, Side}, + Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector, + cursor::Cursor, }; use jacquard::IntoStatic; @@ -28,10 +23,12 @@ use jacquard::types::string::AtUri; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::embed::images::Image; use weaver_api::sh_weaver::embed::records::RecordEmbed; -pub use weaver_editor_core::{ - Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, -}; use weaver_api::sh_weaver::notebook::entry::Entry; +use weaver_editor_core::EditorDocument; +use weaver_editor_core::TextBuffer; +use weaver_editor_core::UndoManager; +pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection}; +use weaver_editor_crdt::LoroTextBuffer; /// Helper for working with editor images. /// Constructed from LoroMap data, NOT serialized directly. @@ -47,7 +44,7 @@ pub struct EditorImage { /// Single source of truth for editor state. /// -/// Contains the document text (backed by Loro CRDT), cursor position, +/// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position, /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` /// schema with CRDT containers for each field. /// @@ -56,23 +53,22 @@ pub struct EditorImage { /// 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 +/// - `content_changed` - bumped to trigger paragraph re-renders when content changes /// -/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`. +/// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`. /// /// # Cloning /// -/// EditorDocument is cheap to clone - Loro types are Arc-backed handles, +/// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed, /// and Signals are Copy. Closures can capture clones without overhead. #[derive(Clone)] -pub struct EditorDocument { - /// The Loro document containing all editor state. - doc: LoroDoc, +pub struct SignalEditorDocument { + /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking. + /// Access the underlying LoroDoc via `buffer.doc()`. + buffer: LoroTextBuffer, // --- Entry schema containers (Loro handles interior mutability) --- - /// Markdown content (maps to entry.content) - content: LoroText, - + // These are obtained from buffer.doc() but cached for convenience. /// Entry title (maps to entry.title) title: LoroText, @@ -119,14 +115,6 @@ pub struct EditorDocument { /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this. pub last_seen_diffs: Signal, AtUri<'static>>>, - // --- Editor state (non-reactive) --- - /// Undo manager for the document. - 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, - // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- /// Current cursor position. Signal so cursor changes don't dirty content memos. pub cursor: Signal, @@ -141,9 +129,9 @@ pub struct EditorDocument { /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. pub composition_ended_at: Signal>, - /// Most recent edit info for incremental rendering optimization. - /// Signal so paragraphs memo can subscribe to content changes only. - pub last_edit: Signal>, + /// Bumped when content changes to trigger paragraph re-renders. + /// Actual EditInfo is obtained from `buffer.last_edit()`. + pub content_changed: Signal<()>, /// Pending snap direction for cursor restoration after edits. /// Set by input handlers, consumed by cursor restoration. @@ -154,14 +142,11 @@ pub struct EditorDocument { pub collected_refs: Signal>, } -// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE -// are imported from weaver_editor_core. - /// Pre-loaded document state that can be created outside of reactive context. /// /// This struct holds the raw LoroDoc (which is safe outside reactive context) -/// along with sync state metadata. Use `EditorDocument::from_loaded_state()` -/// inside a `use_hook` to convert this into a reactive EditorDocument. +/// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()` +/// inside a `use_hook` to convert this into a reactive SignalEditorDocument. /// /// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). /// PartialEq always returns false since we can't meaningfully compare docs. @@ -197,36 +182,19 @@ impl PartialEq for LoadedDocState { } } -impl EditorDocument { - /// Check if a character position is within the block-syntax zone of its line. - fn is_in_block_syntax_zone(&self, pos: usize) -> bool { - if pos <= BLOCK_SYNTAX_ZONE { - return true; - } - - // Search backwards from pos-1, only need to check BLOCK_SYNTAX_ZONE + 1 chars. - let search_start = pos.saturating_sub(BLOCK_SYNTAX_ZONE + 1); - for i in (search_start..pos).rev() { - if self.content.char_at(i).ok() == Some('\n') { - // Found newline at i, distance from line start is pos - i - 1. - return (pos - i - 1) <= BLOCK_SYNTAX_ZONE; - } - } - // No newline found in search range, and pos > BLOCK_SYNTAX_ZONE, so not in zone. - false - } - +impl SignalEditorDocument { /// 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(...))`. + /// a component using `use_hook(|| SignalEditorDocument::new(...))`. pub fn new(initial_content: String) -> Self { - let doc = LoroDoc::new(); + // Create the LoroTextBuffer which owns the LoroDoc + let mut buffer = LoroTextBuffer::new(); + let doc = buffer.doc().clone(); - // Get all containers - let content = doc.get_text("content"); + // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); @@ -235,9 +203,7 @@ impl EditorDocument { // Insert initial content if any if !initial_content.is_empty() { - content - .insert(0, &initial_content) - .expect("failed to insert initial content"); + buffer.insert(0, &initial_content); } // Set created_at to current time (ISO 8601) @@ -246,17 +212,8 @@ impl EditorDocument { .insert(0, &now) .expect("failed to set created_at"); - // Set up undo manager with merge interval for batching keystrokes - let mut undo_mgr = UndoManager::new(&doc); - undo_mgr.set_merge_interval(300); // 300ms merge window - undo_mgr.set_max_undo_steps(100); - - // Create initial Loro cursor at position 0 - let loro_cursor = content.get_cursor(0, Side::default()); - Self { - doc, - content, + buffer, title, path, created_at, @@ -268,9 +225,6 @@ impl EditorDocument { last_diff: Signal::new(None), last_synced_version: Signal::new(None), last_seen_diffs: Signal::new(std::collections::HashMap::new()), - 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, @@ -278,13 +232,13 @@ impl EditorDocument { selection: Signal::new(None), composition: Signal::new(None), composition_ended_at: Signal::new(None), - last_edit: Signal::new(None), + content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } - /// Create an EditorDocument from a fetched Entry. + /// Create a SignalEditorDocument from a fetched Entry. /// /// MUST be called from within a reactive context (e.g., `use_hook`) to /// properly initialize Dioxus Signals. @@ -341,44 +295,54 @@ impl EditorDocument { /// Get the underlying LoroText for read operations on content. pub fn loro_text(&self) -> &LoroText { - &self.content + self.buffer.content() } /// Get the underlying LoroDoc for subscriptions and advanced operations. pub fn loro_doc(&self) -> &LoroDoc { - &self.doc + self.buffer.doc() + } + + /// Get direct access to the LoroTextBuffer. + pub fn buffer(&self) -> &LoroTextBuffer { + &self.buffer + } + + /// Get mutable access to the LoroTextBuffer. + pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer { + &mut self.buffer } // --- Content accessors --- /// Get the markdown content as a string. pub fn content(&self) -> String { - self.content.to_string() + weaver_editor_core::TextBuffer::to_string(&self.buffer) } /// Convert the document content to a string (alias for content()). pub fn to_string(&self) -> String { - self.content.to_string() + weaver_editor_core::TextBuffer::to_string(&self.buffer) } /// Get the length of the content in characters. pub fn len_chars(&self) -> usize { - self.content.len_unicode() + weaver_editor_core::TextBuffer::len_chars(&self.buffer) } /// Get the length of the content in UTF-8 bytes. pub fn len_bytes(&self) -> usize { - self.content.len_utf8() + weaver_editor_core::TextBuffer::len_bytes(&self.buffer) } /// Get the length of the content in UTF-16 code units. pub fn len_utf16(&self) -> usize { - self.content.len_utf16() + self.buffer.content().len_utf16() } /// Check if the content is empty. pub fn is_empty(&self) -> bool { - self.content.len_unicode() == 0 + weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0 } // --- Entry metadata accessors --- @@ -652,91 +616,35 @@ impl EditorDocument { .map(|r| r.into_static()) } - /// Insert text into content and record edit info for incremental rendering. + /// Insert text into content and bump content_changed for re-rendering. + /// Edit info is tracked automatically by the buffer. pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { - let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); - let len_before = self.content.len_unicode(); - let result = self.content.insert(pos, text); - let len_after = self.content.len_unicode(); - 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, - timestamp: web_time::Instant::now(), - })); - result + self.buffer.insert(pos, text); + self.content_changed.set(()); + Ok(()) } /// Push text to end of content. Faster than insert for appending. pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { - let pos = self.content.len_unicode(); - 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.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, - timestamp: web_time::Instant::now(), - })); - result + let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer); + self.buffer.insert(pos, text); + self.content_changed.set(()); + Ok(()) } - /// Remove text range from content and record edit info for incremental rendering. + /// Remove text range from content and bump content_changed for re-rendering. + /// Edit info is tracked automatically by the buffer. pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { - let contains_newline = self - .content - .slice(start, start + len) - .map(|s| s.contains('\n')) - .unwrap_or(false); - let in_block_syntax_zone = self.is_in_block_syntax_zone(start); - - let result = self.content.delete(start, len); - 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(), - timestamp: web_time::Instant::now(), - })); - result + self.buffer.delete(start..start + len); + self.content_changed.set(()); + Ok(()) } - /// Replace text in content (delete then insert) and record combined edit info. + /// Replace text in content (atomic splice) and bump content_changed. + /// Edit info is tracked automatically by the buffer. pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { - let delete_has_newline = self - .content - .slice(start, start + len) - .map(|s| s.contains('\n')) - .unwrap_or(false); - let in_block_syntax_zone = self.is_in_block_syntax_zone(start); - - let len_before = self.content.len_unicode(); - // Use splice for atomic replace - self.content.splice(start, len, text)?; - let len_after = self.content.len_unicode(); - - // inserted_len = (len_after - len_before) + deleted_len - // because: len_after = len_before - deleted + inserted - let inserted_len = (len_after + len).saturating_sub(len_before); - - 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, - timestamp: web_time::Instant::now(), - })); + self.buffer.replace(start..start + len, text); + self.content_changed.set(()); Ok(()) } @@ -746,12 +654,12 @@ impl EditorDocument { // so it tracks through the undo operation self.sync_loro_cursor(); - let result = self.undo_mgr.borrow_mut().undo()?; + let result = self.buffer.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); + self.content_changed.set(()); } Ok(result) } @@ -761,30 +669,30 @@ impl EditorDocument { // Sync Loro cursor to current position BEFORE redo self.sync_loro_cursor(); - let result = self.undo_mgr.borrow_mut().redo()?; + let result = self.buffer.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); + self.content_changed.set(()); } Ok(result) } /// Check if undo is available. pub fn can_undo(&self) -> bool { - self.undo_mgr.borrow().can_undo() + UndoManager::can_undo(&self.buffer) } /// Check if redo is available. pub fn can_redo(&self) -> bool { - self.undo_mgr.borrow().can_redo() + UndoManager::can_redo(&self.buffer) } /// Get a slice of the content text. /// Returns None if the range is invalid. - pub fn slice(&self, start: usize, end: usize) -> Option { - self.content.slice(start, end).ok() + pub fn slice(&self, start: usize, end: usize) -> Option { + self.buffer.slice(start..end) } /// Sync the Loro cursor to the current cursor.offset position. @@ -792,18 +700,20 @@ impl EditorDocument { pub fn sync_loro_cursor(&mut self) { let offset = self.cursor.read().offset; tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); - self.loro_cursor = self.content.get_cursor(offset, Side::default()); + self.buffer.sync_cursor(offset); } /// Update cursor.offset from the Loro cursor's tracked position. /// Call this after undo/redo or remote edits where the position may have shifted. /// Returns the new offset, or None if the cursor couldn't be resolved. 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 old_offset = self.cursor.read().offset; - let new_offset = result.current.pos.min(self.len_chars()); - let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset }; + let new_offset = self.buffer.resolve_cursor()?; + let jump = if new_offset > old_offset { + new_offset - old_offset + } else { + old_offset - new_offset + }; if jump > 100 { tracing::warn!( old_offset, @@ -812,22 +722,26 @@ impl EditorDocument { "sync_cursor_from_loro: LARGE CURSOR JUMP detected" ); } - tracing::debug!(old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro"); + tracing::debug!( + old_offset, + new_offset, + "sync_cursor_from_loro: updating cursor from Loro" + ); self.cursor.with_mut(|c| c.offset = new_offset); Some(new_offset) } /// Get the Loro cursor for serialization. - pub fn loro_cursor(&self) -> Option<&Cursor> { - self.loro_cursor.as_ref() + pub fn loro_cursor(&self) -> Option { + self.buffer.loro_cursor() } /// Set the Loro cursor (used when restoring from storage). pub fn set_loro_cursor(&mut self, cursor: Option) { tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); - self.loro_cursor = cursor; + self.buffer.set_loro_cursor(cursor); // Sync cursor.offset from the restored Loro cursor - if self.loro_cursor.is_some() { + if self.buffer.loro_cursor().is_some() { self.sync_cursor_from_loro(); } } @@ -835,24 +749,30 @@ impl EditorDocument { /// Export the document as a binary snapshot. /// This captures all CRDT state including undo history. pub fn export_snapshot(&self) -> Vec { - self.doc.export(ExportMode::Snapshot).unwrap_or_default() + self.buffer.export_snapshot() } /// Get the current state frontiers for change detection. /// Frontiers represent the "version" of the document state. pub fn state_frontiers(&self) -> Frontiers { - self.doc.state_frontiers() + self.buffer.doc().state_frontiers() } /// Get the current version vector. pub fn version_vector(&self) -> VersionVector { - self.doc.oplog_vv() + self.buffer.version() } /// Get the last edit info for incremental rendering. - /// Reading this creates a reactive dependency on content changes. + /// This comes from the buffer's internal tracking. pub fn last_edit(&self) -> Option { - self.last_edit.read().clone() + self.buffer.last_edit() + } + + /// Bump the content_changed signal to trigger re-renders. + /// Call this after remote imports or other external content changes. + pub fn notify_content_changed(&mut self) { + self.content_changed.set(()); } // --- Collected refs accessors --- @@ -916,7 +836,7 @@ impl EditorDocument { /// Check if there are unsynced changes since the last PDS sync. pub fn has_unsynced_changes(&self) -> bool { match &*self.last_synced_version.read() { - Some(synced_vv) => self.doc.oplog_vv() != *synced_vv, + Some(synced_vv) => self.buffer.version() != *synced_vv, None => true, // Never synced, so there are changes } } @@ -926,44 +846,27 @@ impl EditorDocument { /// After successful upload, call `mark_synced()` to update the sync marker. pub fn export_updates_since_sync(&self) -> Option> { let from_vv = self.last_synced_version.read().clone().unwrap_or_default(); - let current_vv = self.doc.oplog_vv(); - - // No changes since last sync - if from_vv == current_vv { - return None; - } - - let updates = self - .doc - .export(ExportMode::Updates { - from: Cow::Owned(from_vv), - }) - .ok()?; - - // Don't return empty updates - if updates.is_empty() { - return None; - } - - Some(updates) + self.buffer.export_updates_since(&from_vv) } /// Mark the current state as synced. /// Call this after successfully uploading a diff to the PDS. pub fn mark_synced(&mut self) { - self.last_synced_version.set(Some(self.doc.oplog_vv())); + self.last_synced_version.set(Some(self.buffer.version())); } /// Import updates from a PDS diff blob. /// Used when loading edit history from the PDS. pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { - let len_before = self.content.len_unicode(); - let vv_before = self.doc.oplog_vv(); + let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer); + let vv_before = self.buffer.version(); - self.doc.import(updates)?; + self.buffer + .import(updates) + .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?; - let len_after = self.content.len_unicode(); - let vv_after = self.doc.oplog_vv(); + let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer); + let vv_after = self.buffer.version(); let vv_changed = vv_before != vv_after; let len_changed = len_before != len_after; @@ -977,7 +880,7 @@ impl EditorDocument { // Only trigger re-render if something actually changed if vv_changed { - self.last_edit.set(None); + self.content_changed.set(()); } Ok(()) } @@ -985,25 +888,7 @@ impl EditorDocument { /// Export updates since the given version vector. /// Used for real-time P2P sync where we track broadcast version separately from PDS sync. pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option> { - let current_vv = self.doc.oplog_vv(); - - // No changes since the given version - if *from_vv == current_vv { - return None; - } - - let updates = self - .doc - .export(ExportMode::Updates { - from: Cow::Borrowed(from_vv), - }) - .ok()?; - - if updates.is_empty() { - return None; - } - - Some(updates) + self.buffer.export_updates_since(from_vv) } /// Set the sync state when loading from PDS. @@ -1016,10 +901,10 @@ impl EditorDocument { ) { self.edit_root.set(Some(edit_root)); self.last_diff.set(last_diff); - self.last_synced_version.set(Some(self.doc.oplog_vv())); + self.last_synced_version.set(Some(self.buffer.version())); } - /// Create a new EditorDocument from a binary snapshot. + /// Create a new SignalEditorDocument from a binary snapshot. /// Falls back to empty document if import fails. /// /// If `loro_cursor` is provided, it will be used to restore the cursor position. @@ -1037,29 +922,30 @@ impl EditorDocument { loro_cursor: Option, fallback_offset: usize, ) -> Self { - let doc = LoroDoc::new(); - - if !snapshot.is_empty() { - if let Err(e) = doc.import(snapshot) { - tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); + // Create buffer from snapshot + let buffer = if snapshot.is_empty() { + LoroTextBuffer::new() + } else { + match LoroTextBuffer::from_snapshot(snapshot) { + Ok(buf) => buf, + Err(e) => { + tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); + LoroTextBuffer::new() + } } - } + }; + + let doc = buffer.doc().clone(); - // Get all containers (they will contain data from the snapshot if import succeeded) - let content = doc.get_text("content"); + // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); let tags = doc.get_list("tags"); let embeds = doc.get_map("embeds"); - // Set up undo manager - tracks operations from this point forward only - let mut undo_mgr = UndoManager::new(&doc); - undo_mgr.set_merge_interval(300); - undo_mgr.set_max_undo_steps(100); - // Try to restore cursor from Loro cursor, fall back to offset - let max_offset = content.len_unicode(); + let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); let cursor_offset = if let Some(ref lc) = loro_cursor { doc.get_cursor_pos(lc) .map(|r| r.current.pos) @@ -1073,13 +959,16 @@ impl EditorDocument { 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_state.offset, Side::default())); + // Set up the Loro cursor + let buffer = buffer; + if let Some(lc) = loro_cursor { + buffer.set_loro_cursor(Some(lc)); + } else { + buffer.sync_cursor(cursor_state.offset); + } Self { - doc, - content, + buffer, title, path, created_at, @@ -1091,20 +980,17 @@ impl EditorDocument { last_diff: Signal::new(None), last_synced_version: Signal::new(None), last_seen_diffs: Signal::new(std::collections::HashMap::new()), - undo_mgr: Rc::new(RefCell::new(undo_mgr)), - loro_cursor, - // 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), + content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } - /// Create an EditorDocument from pre-loaded state. + /// Create a SignalEditorDocument from pre-loaded state. /// /// Use this when loading from PDS/localStorage merge outside reactive context. /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it @@ -1113,32 +999,30 @@ impl EditorDocument { /// # Note /// This creates Dioxus Signals. Call from within a component using `use_hook`. pub fn from_loaded_state(state: LoadedDocState) -> Self { - let doc = state.doc; + // Create buffer from the loaded doc + let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content"); + let doc = buffer.doc().clone(); - // Get all containers from the loaded doc - let content = doc.get_text("content"); + // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); let tags = doc.get_list("tags"); let embeds = doc.get_map("embeds"); - // Set up undo manager - let mut undo_mgr = UndoManager::new(&doc); - undo_mgr.set_merge_interval(300); - undo_mgr.set_max_undo_steps(100); - // Position cursor at end of content - let cursor_offset = content.len_unicode(); + let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); let cursor_state = CursorState { offset: cursor_offset, affinity: Affinity::Before, }; - let loro_cursor = content.get_cursor(cursor_offset, Side::default()); + + // Set up the Loro cursor + let buffer = buffer; + buffer.sync_cursor(cursor_offset); Self { - doc, - content, + buffer, title, path, created_at, @@ -1151,28 +1035,26 @@ impl EditorDocument { // Use the synced version from state (tracks the PDS version vector) last_synced_version: Signal::new(state.synced_version), last_seen_diffs: Signal::new(state.last_seen_diffs), - undo_mgr: Rc::new(RefCell::new(undo_mgr)), - loro_cursor, cursor: Signal::new(cursor_state), selection: Signal::new(None), composition: Signal::new(None), composition_ended_at: Signal::new(None), - last_edit: Signal::new(None), + content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } } -impl PartialEq for EditorDocument { +impl PartialEq for SignalEditorDocument { fn eq(&self, _other: &Self) -> bool { - // EditorDocument uses interior mutability, so we can't meaningfully compare. + // SignalEditorDocument uses interior mutability, so we can't meaningfully compare. // Return false to ensure components re-render when passed as props. false } } -impl weaver_editor_crdt::CrdtDocument for EditorDocument { +impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument { fn export_snapshot(&self) -> Vec { self.export_snapshot() } @@ -1191,26 +1073,72 @@ impl weaver_editor_crdt::CrdtDocument for EditorDocument { } fn edit_root(&self) -> Option> { - self.edit_root() + SignalEditorDocument::edit_root(self) } fn set_edit_root(&mut self, root: Option>) { - self.set_edit_root(root); + SignalEditorDocument::set_edit_root(self, root); } fn last_diff(&self) -> Option> { - self.last_diff() + SignalEditorDocument::last_diff(self) } fn set_last_diff(&mut self, diff: Option>) { - self.set_last_diff(diff); + SignalEditorDocument::set_last_diff(self, diff); } fn mark_synced(&mut self) { - self.mark_synced(); + SignalEditorDocument::mark_synced(self); } fn has_unsynced_changes(&self) -> bool { - self.has_unsynced_changes() + SignalEditorDocument::has_unsynced_changes(self) + } +} + +impl EditorDocument for SignalEditorDocument { + type Buffer = LoroTextBuffer; + + fn buffer(&self) -> &Self::Buffer { + &self.buffer + } + + fn buffer_mut(&mut self) -> &mut Self::Buffer { + &mut self.buffer + } + + fn cursor(&self) -> CursorState { + *self.cursor.read() + } + + fn set_cursor(&mut self, cursor: CursorState) { + self.cursor.set(cursor); + } + + fn selection(&self) -> Option { + self.selection.read().clone() + } + + fn set_selection(&mut self, selection: Option) { + self.selection.set(selection); + } + + fn last_edit(&self) -> Option { + self.buffer.last_edit() + } + + fn set_last_edit(&mut self, _edit: Option) { + // Buffer tracks edit info internally. We use this hook to + // bump content_changed for reactive re-rendering. + self.content_changed.set(()); + } + + fn composition(&self) -> Option { + self.composition.read().clone() + } + + fn set_composition(&mut self, composition: Option) { + self.composition.set(composition); } } diff --git a/crates/weaver-app/src/components/editor/dom_sync.rs b/crates/weaver-app/src/components/editor/dom_sync.rs index 5ec1794..8f3dc1d 100644 --- a/crates/weaver-app/src/components/editor/dom_sync.rs +++ b/crates/weaver-app/src/components/editor/dom_sync.rs @@ -8,7 +8,9 @@ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use super::cursor::restore_cursor_position; #[allow(unused_imports)] -use super::document::{EditorDocument, Selection}; +use super::document::Selection; +#[allow(unused_imports)] +use super::document::SignalEditorDocument; use super::paragraph::ParagraphRender; #[allow(unused_imports)] use dioxus::prelude::*; @@ -25,7 +27,7 @@ pub use weaver_editor_browser::dom_position_to_text_offset; /// 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( - doc: &mut EditorDocument, + doc: &mut SignalEditorDocument, editor_id: &str, paragraphs: &[ParagraphRender], ) { @@ -37,7 +39,7 @@ pub fn sync_cursor_from_dom( /// 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, + doc: &mut SignalEditorDocument, editor_id: &str, paragraphs: &[ParagraphRender], direction_hint: Option, @@ -133,7 +135,7 @@ pub fn sync_cursor_from_dom_with_direction( #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn sync_cursor_from_dom( - _document: &mut EditorDocument, + _document: &mut SignalEditorDocument, _editor_id: &str, _paragraphs: &[ParagraphRender], ) { @@ -141,7 +143,7 @@ pub fn sync_cursor_from_dom( #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] pub fn sync_cursor_from_dom_with_direction( - _document: &mut EditorDocument, + _document: &mut SignalEditorDocument, _editor_id: &str, _paragraphs: &[ParagraphRender], _direction_hint: Option, @@ -332,11 +334,9 @@ pub fn update_paragraph_dom( if is_cursor_para { // Restore cursor synchronously - don't wait for rAF // This prevents race conditions with fast typing - if let Err(e) = restore_cursor_position( - cursor_offset, - &new_para.offset_map, - None, - ) { + if let Err(e) = + restore_cursor_position(cursor_offset, &new_para.offset_map, None) + { tracing::warn!("Synchronous cursor restore failed: {:?}", e); } cursor_para_updated = true; diff --git a/crates/weaver-app/src/components/editor/formatting.rs b/crates/weaver-app/src/components/editor/formatting.rs index 338c632..bf7525c 100644 --- a/crates/weaver-app/src/components/editor/formatting.rs +++ b/crates/weaver-app/src/components/editor/formatting.rs @@ -1,6 +1,7 @@ //! Formatting actions and utilities for applying markdown formatting. -use super::document::EditorDocument; +use crate::components::editor::SignalEditorDocument; + #[allow(unused_imports)] use super::input::{ListContext, detect_list_context, find_line_end}; use dioxus::prelude::*; @@ -47,7 +48,7 @@ pub fn find_word_boundaries(text: &loro::LoroText, offset: usize) -> (usize, usi /// Apply formatting to document. /// /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. -pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { +pub fn apply_formatting(doc: &mut SignalEditorDocument, action: FormatAction) { let cursor_offset = doc.cursor.read().offset; let (start, end) = if let Some(sel) = *doc.selection.read() { // Use selection diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs index 19f7ab6..b2953ed 100644 --- a/crates/weaver-app/src/components/editor/input.rs +++ b/crates/weaver-app/src/components/editor/input.rs @@ -4,7 +4,7 @@ use dioxus::prelude::*; -use super::document::EditorDocument; +use super::document::SignalEditorDocument; use super::formatting::{self, FormatAction}; use weaver_editor_core::SnapDirection; @@ -52,7 +52,7 @@ pub fn should_intercept_key(evt: &Event) -> bool { /// Handle keyboard events and update document state. #[allow(unused)] -pub fn handle_keydown(evt: Event, doc: &mut EditorDocument) { +pub fn handle_keydown(evt: Event, doc: &mut SignalEditorDocument) { use dioxus::prelude::keyboard_types::Key; let key = evt.key(); @@ -326,18 +326,13 @@ pub fn handle_keydown(evt: Event, doc: &mut EditorDocument) { // 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) - { + if doc.last_edit().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, doc: &mut EditorDocument) { +pub fn handle_paste(evt: Event, doc: &mut SignalEditorDocument) { evt.prevent_default(); #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] let _ = doc; @@ -379,7 +374,7 @@ pub fn handle_paste(evt: Event, doc: &mut EditorDocument) { } /// Handle cut events - extract text, write to clipboard, then delete. -pub fn handle_cut(evt: Event, doc: &mut EditorDocument) { +pub fn handle_cut(evt: Event, doc: &mut SignalEditorDocument) { evt.prevent_default(); #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] let _ = doc; @@ -440,7 +435,7 @@ pub fn handle_cut(evt: Event, doc: &mut EditorDocument) { } /// Handle copy events - extract text, clean it up, write to clipboard. -pub fn handle_copy(evt: Event, doc: &EditorDocument) { +pub fn handle_copy(evt: Event, doc: &SignalEditorDocument) { #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use dioxus::web::WebEventExt; diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 56c48a1..1200f91 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -44,7 +44,7 @@ pub use component::MarkdownEditor; // Document types #[allow(unused_imports)] pub use document::{ - Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection, + Affinity, CompositionState, CursorState, LoadedDocState, Selection, SignalEditorDocument, }; // Formatting diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs index 16eae86..5763376 100644 --- a/crates/weaver-app/src/components/editor/publish.rs +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -55,9 +55,9 @@ fn rewrite_draft_paths_for_notebook(content: &str, notebook_key: &str) -> String } use crate::auth::AuthState; +use crate::components::editor::SignalEditorDocument; use crate::fetch::Fetcher; -use super::document::EditorDocument; use super::storage::{delete_draft, save_to_storage}; /// Result of a publish operation. @@ -161,7 +161,7 @@ pub async fn load_entry_for_editing( /// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. pub async fn publish_entry( fetcher: &Fetcher, - doc: &mut EditorDocument, + doc: &mut SignalEditorDocument, notebook_title: Option<&str>, draft_key: &str, ) -> Result { @@ -484,7 +484,7 @@ fn slugify(title: &str) -> String { #[derive(Props, Clone, PartialEq)] pub struct PublishButtonProps { /// The editor document - pub document: EditorDocument, + pub document: SignalEditorDocument, /// Storage key for the draft pub draft_key: String, /// Pre-selected notebook (from URL param) diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 14b3bd2..d69d1b1 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -3,78 +3,23 @@ //! Phase 2: Paragraph-level incremental rendering with formatting characters visible. //! //! This module provides a thin wrapper around the core rendering logic, -//! adapting it to LoroText and adding app-specific features like timing. +//! adding app-specific features like timing instrumentation. use super::paragraph::ParagraphRender; use super::writer::embed::EditorImageResolver; -use loro::LoroText; use weaver_common::{EntryIndex, ResolvedContent}; -use weaver_editor_core::{EditInfo, SmolStr, TextBuffer}; +use weaver_editor_core::{EditInfo, TextBuffer}; // Re-export core types. pub use weaver_editor_core::RenderCache; -/// Adapter to make LoroText implement TextBuffer-like interface for core. -/// temporary until weaver-editor-crdt crate complete -struct LoroTextAdapter<'a>(&'a LoroText); - -impl TextBuffer for LoroTextAdapter<'_> { - fn len_chars(&self) -> usize { - self.0.len_unicode() - } - - fn len_bytes(&self) -> usize { - self.0.len_utf8() - } - - fn slice(&self, range: std::ops::Range) -> Option { - self.0 - .slice(range.start, range.end) - .ok() - .map(|s| SmolStr::new(&s)) - } - - fn char_at(&self, offset: usize) -> Option { - self.0 - .slice(offset, offset + 1) - .ok() - .and_then(|s| s.chars().next()) - } - - fn char_to_byte(&self, char_offset: usize) -> usize { - // LoroText doesn't expose this directly, so we compute it. - let slice = self.0.slice(0, char_offset).unwrap_or_default(); - slice.len() - } - - fn byte_to_char(&self, byte_offset: usize) -> usize { - // LoroText doesn't expose this directly, so we compute it. - let full = self.0.to_string(); - full[..byte_offset.min(full.len())].chars().count() - } - - fn insert(&mut self, _offset: usize, _text: &str) { - // Read-only adapter - this should never be called during rendering. - panic!("LoroTextAdapter is read-only"); - } - - fn delete(&mut self, _range: std::ops::Range) { - // Read-only adapter - this should never be called during rendering. - panic!("LoroTextAdapter is read-only"); - } - - fn to_string(&self) -> String { - self.0.to_string() - } -} - /// Render markdown with incremental caching. /// /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. /// This is a thin wrapper around the core rendering logic that adds timing. /// /// # Parameters -/// - `text`: The LoroText to render +/// - `text`: Any TextBuffer implementation (LoroTextBuffer, EditorRope, etc.) /// - `cache`: Optional previous render cache /// - `cursor_offset`: Current cursor position /// - `edit`: Edit info for stable ID assignment @@ -84,8 +29,8 @@ impl TextBuffer for LoroTextAdapter<'_> { /// /// # Returns /// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render -pub fn render_paragraphs_incremental( - text: &LoroText, +pub fn render_paragraphs_incremental( + text: &T, cache: Option<&RenderCache>, cursor_offset: usize, edit: Option<&EditInfo>, @@ -99,12 +44,8 @@ pub fn render_paragraphs_incremental( ) { let fn_start = crate::perf::now(); - // Create adapter for LoroText to use with core rendering. - let adapter = LoroTextAdapter(text); - - // Call the core rendering function. let result = weaver_editor_core::render_paragraphs_incremental( - &adapter, + text, cache, cursor_offset, edit, diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index bf5080e..144f905 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -29,7 +29,7 @@ use loro::cursor::Cursor; use serde::{Deserialize, Serialize}; use weaver_api::com_atproto::repo::strong_ref::StrongRef; -use super::document::EditorDocument; +use super::document::SignalEditorDocument; /// Prefix for all draft storage keys. pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; @@ -89,7 +89,7 @@ fn storage_key(key: &str) -> String { /// Save editor state to LocalStorage (WASM only). #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn save_to_storage( - doc: &EditorDocument, + doc: &SignalEditorDocument, key: &str, ) -> Result<(), gloo_storage::errors::StorageError> { let export_start = crate::perf::now(); @@ -108,7 +108,7 @@ pub fn save_to_storage( content: doc.content(), title: doc.title().into(), snapshot: snapshot_b64, - cursor: doc.loro_cursor().cloned(), + cursor: doc.loro_cursor(), cursor_offset: doc.cursor.read().offset, editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), @@ -132,10 +132,10 @@ pub fn save_to_storage( /// Load editor state from LocalStorage (WASM only). /// -/// Returns an EditorDocument restored from CRDT snapshot if available, +/// Returns an SignalEditorDocument restored from CRDT snapshot if available, /// otherwise falls back to just the text content. #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn load_from_storage(key: &str) -> Option { +pub fn load_from_storage(key: &str) -> Option { let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; // Parse entry_ref from the snapshot (requires both URI and CID) @@ -152,7 +152,7 @@ pub fn load_from_storage(key: &str) -> Option { // Try to restore from CRDT snapshot first if let Some(ref snapshot_b64) = snapshot.snapshot { if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { - let mut doc = EditorDocument::from_snapshot( + let mut doc = SignalEditorDocument::from_snapshot( &snapshot_bytes, snapshot.cursor.clone(), snapshot.cursor_offset, @@ -170,7 +170,7 @@ pub fn load_from_storage(key: &str) -> Option { } // Fallback: create new doc from text content - let mut doc = EditorDocument::new(snapshot.content); + let mut doc = SignalEditorDocument::new(snapshot.content); doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); doc.sync_loro_cursor(); doc.set_entry_ref(entry_ref); @@ -192,7 +192,7 @@ pub struct LocalSnapshotData { /// Load snapshot data from LocalStorage (WASM only). /// -/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe +/// Unlike `load_from_storage`, this doesn't create an SignalEditorDocument and is safe /// to call outside of reactive context. Use with `load_and_merge_document`. #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub fn load_snapshot_from_storage(key: &str) -> Option { @@ -344,12 +344,12 @@ pub fn clear_all_drafts() { // Stub implementations for non-WASM targets #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> { +pub fn save_to_storage(_doc: &SignalEditorDocument, _key: &str) -> Result<(), String> { Ok(()) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn load_from_storage(_key: &str) -> Option { +pub fn load_from_storage(_key: &str) -> Option { None } diff --git a/crates/weaver-app/src/components/editor/sync.rs b/crates/weaver-app/src/components/editor/sync.rs index 518d4c2..5b6d7b1 100644 --- a/crates/weaver-app/src/components/editor/sync.rs +++ b/crates/weaver-app/src/components/editor/sync.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; -use super::document::{EditorDocument, LoadedDocState}; +use super::document::{LoadedDocState, SignalEditorDocument}; use crate::fetch::Fetcher; use jacquard::IntoStatic; use jacquard::prelude::*; @@ -214,7 +214,7 @@ pub async fn list_drafts_from_pds(fetcher: &Fetcher) -> Result, /// Wraps the crdt crate's create_edit_root with Fetcher support. pub async fn create_edit_root( fetcher: &Fetcher, - doc: &EditorDocument, + doc: &SignalEditorDocument, draft_key: &str, entry_uri: Option<&AtUri<'_>>, entry_cid: Option<&Cid<'_>>, @@ -235,7 +235,7 @@ pub async fn create_edit_root( /// Wraps the crdt crate's create_diff with Fetcher support. pub async fn create_diff( fetcher: &Fetcher, - doc: &EditorDocument, + doc: &SignalEditorDocument, root_uri: &AtUri<'_>, root_cid: &Cid<'_>, prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>, @@ -264,7 +264,7 @@ pub async fn create_diff( /// Updates the document's sync state on success. pub async fn sync_to_pds( fetcher: &Fetcher, - doc: &mut EditorDocument, + doc: &mut SignalEditorDocument, draft_key: &str, ) -> Result { let fn_start = crate::perf::now(); @@ -415,7 +415,7 @@ pub async fn load_all_edit_states_from_pds( /// /// Loads from localStorage and PDS (if available), then merges both using Loro's /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an -/// EditorDocument inside a reactive context using `use_hook`. +/// SignalEditorDocument inside a reactive context using `use_hook`. /// /// For unpublished drafts, attempts to discover edit state via Constellation /// using the synthetic draft URI. @@ -603,7 +603,7 @@ pub enum SyncState { #[derive(Props, Clone, PartialEq)] pub struct SyncStatusProps { /// The editor document to sync - pub document: EditorDocument, + pub document: SignalEditorDocument, /// Draft key for this document pub draft_key: String, /// Auto-sync interval in milliseconds (0 to disable, default disabled) @@ -707,8 +707,8 @@ pub fn SyncStatus(props: SyncStatusProps) -> Element { // Note: We use peek() to avoid creating a reactive dependency on sync_state let doc_for_effect = doc.clone(); use_effect(move || { - // Check for unsynced changes (reads last_edit signal for reactivity) - let _edit = doc_for_effect.last_edit(); + // Read content_changed to create reactive dependency on document changes + let _ = doc_for_effect.content_changed.read(); // Use peek to avoid reactive loop let current_state = *sync_state.peek(); diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 081735a..fb80ff2 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -1,11 +1,11 @@ //! Snapshot tests for the markdown editor rendering pipeline. -use weaver_editor_core::{OffsetMapping, find_mapping_for_char}; use super::paragraph::ParagraphRender; use super::render::render_paragraphs_incremental; -use loro::LoroDoc; use serde::Serialize; use weaver_common::ResolvedContent; +use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char}; +use weaver_editor_crdt::LoroTextBuffer; /// Serializable version of ParagraphRender for snapshot testing. #[derive(Debug, Serialize)] @@ -55,11 +55,10 @@ impl From<&OffsetMapping> for TestOffsetMapping { /// Helper: render markdown and convert to serializable test output. fn render_test(input: &str) -> Vec { - let doc = LoroDoc::new(); - let text = doc.get_text("content"); - text.insert(0, input).unwrap(); + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, input); let (paragraphs, _cache, _refs) = - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); paragraphs.iter().map(TestParagraph::from).collect() } @@ -640,15 +639,13 @@ fn test_heading_to_non_heading_transition() { // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) // This tests that the syntax spans are correctly updated on content change. use super::render::render_paragraphs_incremental; - use loro::LoroDoc; - let doc = LoroDoc::new(); - let text = doc.get_text("content"); + let mut buffer = LoroTextBuffer::new(); // Initial state: "#" is a valid empty heading - text.insert(0, "#").unwrap(); + buffer.insert(0, "#"); let (paras1, cache1, _refs1) = - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); eprintln!("State 1 ('#'): {}", paras1[0].html); assert!(paras1[0].html.contains(" 2 - let doc = LoroDoc::new(); - let text = doc.get_text("content"); - text.insert(0, input).unwrap(); + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, input); let (paragraphs, _cache, _refs) = - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); // With extra newlines, we expect 3 elements: para, gap, para assert_eq!( @@ -903,17 +898,16 @@ fn test_offset_mappings_reference_own_paragraph() { fn test_incremental_cache_reuse() { // Verify cache is populated and can be reused let input = "First para\n\nSecond para"; - let doc = LoroDoc::new(); - let text = doc.get_text("content"); - text.insert(0, input).unwrap(); + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, input); let (paras1, cache1, _refs1) = - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); // Second render with same content should reuse cache let (paras2, _cache2, _refs2) = render_paragraphs_incremental( - &text, + &buffer, Some(&cache1), 0, None, diff --git a/crates/weaver-editor-crdt/src/buffer.rs b/crates/weaver-editor-crdt/src/buffer.rs index 65438a4..4560e0d 100644 --- a/crates/weaver-editor-crdt/src/buffer.rs +++ b/crates/weaver-editor-crdt/src/buffer.rs @@ -227,6 +227,31 @@ impl TextBuffer for LoroTextBuffer { }); } + fn replace(&mut self, char_range: Range, text: &str) { + let in_block_syntax_zone = self.is_in_block_syntax_zone(char_range.start); + let delete_has_newline = self + .slice(char_range.clone()) + .map(|s| s.contains('\n')) + .unwrap_or(false); + let deleted_len = char_range.len(); + let inserted_len = text.chars().count(); + + // Use Loro's atomic splice operation + self.content + .splice(char_range.start, deleted_len, text) + .ok(); + + self.inner.borrow_mut().last_edit = Some(EditInfo { + edit_char_pos: char_range.start, + inserted_len, + deleted_len, + contains_newline: delete_has_newline || text.contains('\n'), + in_block_syntax_zone, + doc_len_after: self.content.len_unicode(), + timestamp: Instant::now(), + }); + } + fn slice(&self, char_range: Range) -> Option { if char_range.end > self.content.len_unicode() { return None; diff --git a/docs/graph-data.json b/docs/graph-data.json index 1839841..8c96b4c 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1781,6 +1781,50 @@ "created_at": "2026-01-06T15:54:32.396727943-05:00", "updated_at": "2026-01-06T15:54:32.396727943-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 164, + "change_id": "9f555968-9c6c-4db6-bda1-6223c92acbec", + "node_type": "goal", + "title": "Refactor EditorDocument to wrap LoroTextBuffer and impl TextBuffer+UndoManager", + "description": null, + "status": "pending", + "created_at": "2026-01-06T16:00:43.381698744-05:00", + "updated_at": "2026-01-06T16:00:43.381698744-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"User: refactor EditorDocument struct (rename it) to wrap LoroTextBuffer and implement EditorDocument trait. Replace EditInfo signal with Signal<()>. Migrate callers to trait methods. Remove LoroTextAdapter later.\"}" + }, + { + "id": 165, + "change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", + "node_type": "action", + "title": "Analyze EditorDocument refactor scope", + "description": null, + "status": "pending", + "created_at": "2026-01-06T16:01:15.257884614-05:00", + "updated_at": "2026-01-06T16:01:15.257884614-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 166, + "change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", + "node_type": "action", + "title": "Rename EditorDocument to SignalEditorDocument and wrap LoroTextBuffer", + "description": null, + "status": "pending", + "created_at": "2026-01-06T16:01:49.255692341-05:00", + "updated_at": "2026-01-06T16:01:49.255692341-05:00", + "metadata_json": "{\"confidence\":90}" + }, + { + "id": 167, + "change_id": "8921c6ea-9bf0-4c2a-8b1e-02fd290dde68", + "node_type": "outcome", + "title": "SignalEditorDocument refactor compiles - wraps LoroTextBuffer, uses content_changed signal", + "description": null, + "status": "pending", + "created_at": "2026-01-06T16:13:31.725544621-05:00", + "updated_at": "2026-01-06T16:13:31.725544621-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -3620,6 +3664,39 @@ "weight": 1.0, "rationale": "Refactoring complete with tests", "created_at": "2026-01-06T15:54:32.412922730-05:00" + }, + { + "id": 169, + "from_node_id": 164, + "to_node_id": 165, + "from_change_id": "9f555968-9c6c-4db6-bda1-6223c92acbec", + "to_change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Analysis step of refactor goal", + "created_at": "2026-01-06T16:01:15.381825313-05:00" + }, + { + "id": 170, + "from_node_id": 165, + "to_node_id": 166, + "from_change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", + "to_change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Analysis complete, starting implementation", + "created_at": "2026-01-06T16:01:53.140032559-05:00" + }, + { + "id": 171, + "from_node_id": 166, + "to_node_id": 167, + "from_change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", + "to_change_id": "8921c6ea-9bf0-4c2a-8b1e-02fd290dde68", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Refactor implementation complete", + "created_at": "2026-01-06T16:13:31.873150501-05:00" } ] } \ No newline at end of file