trait refactor

Orual 25329504 2aa819db

+443 -457
+6 -6
crates/weaver-app/src/components/editor/actions.rs
··· 1 //! Editor actions and keybinding system. 2 //! 3 //! This module re-exports core types and provides Dioxus-specific conversions 4 - //! and the concrete execute_action implementation for EditorDocument. 5 6 use dioxus::prelude::*; 7 8 - use super::document::EditorDocument; 9 use super::platform::Platform; 10 11 // Re-export core types. ··· 133 /// 134 /// This is the central dispatch point for all editor operations. 135 /// Returns true if the action was handled and the document was modified. 136 - pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool { 137 use super::formatting::{self, FormatAction}; 138 use super::input::{ 139 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, ··· 573 } 574 575 /// Find word boundary backward from cursor. 576 - fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize { 577 use super::input::get_char_at; 578 579 if cursor == 0 { ··· 603 } 604 605 /// Find word boundary forward from cursor. 606 - fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize { 607 use super::input::get_char_at; 608 609 let len = doc.len_chars(); ··· 639 /// are handled by beforeinput. Navigation (arrows, etc.) is passed 640 /// through to the browser. 641 pub fn handle_keydown_with_bindings( 642 - doc: &mut EditorDocument, 643 config: &KeybindingConfig, 644 combo: KeyCombo, 645 range: Range,
··· 1 //! Editor actions and keybinding system. 2 //! 3 //! This module re-exports core types and provides Dioxus-specific conversions 4 + //! and the concrete execute_action implementation for SignalEditorDocument. 5 6 use dioxus::prelude::*; 7 8 + use super::document::SignalEditorDocument; 9 use super::platform::Platform; 10 11 // Re-export core types. ··· 133 /// 134 /// This is the central dispatch point for all editor operations. 135 /// Returns true if the action was handled and the document was modified. 136 + pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { 137 use super::formatting::{self, FormatAction}; 138 use super::input::{ 139 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, ··· 573 } 574 575 /// Find word boundary backward from cursor. 576 + fn find_word_boundary_backward(doc: &SignalEditorDocument, cursor: usize) -> usize { 577 use super::input::get_char_at; 578 579 if cursor == 0 { ··· 603 } 604 605 /// Find word boundary forward from cursor. 606 + fn find_word_boundary_forward(doc: &SignalEditorDocument, cursor: usize) -> usize { 607 use super::input::get_char_at; 608 609 let len = doc.len_chars(); ··· 639 /// are handled by beforeinput. Navigation (arrows, etc.) is passed 640 /// through to the browser. 641 pub fn handle_keydown_with_bindings( 642 + doc: &mut SignalEditorDocument, 643 config: &KeybindingConfig, 644 combo: KeyCombo, 645 range: Range,
+3 -3
crates/weaver-app/src/components/editor/beforeinput.rs
··· 17 use dioxus::prelude::*; 18 19 use super::actions::{EditorAction, execute_action}; 20 - use super::document::EditorDocument; 21 use super::platform::Platform; 22 23 // Re-export types from extracted crates. ··· 33 /// Returns whether the event was handled and default should be prevented. 34 #[allow(dead_code)] 35 pub fn handle_beforeinput( 36 - doc: &mut EditorDocument, 37 ctx: BeforeInputContext<'_>, 38 ) -> BeforeInputResult { 39 // During composition, let the browser handle most things. ··· 316 } 317 318 /// Get the current range based on cursor and selection state. 319 - fn get_current_range(doc: &EditorDocument) -> Range { 320 if let Some(sel) = *doc.selection.read() { 321 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 322 Range::new(start, end)
··· 17 use dioxus::prelude::*; 18 19 use super::actions::{EditorAction, execute_action}; 20 + use super::document::SignalEditorDocument; 21 use super::platform::Platform; 22 23 // Re-export types from extracted crates. ··· 33 /// Returns whether the event was handled and default should be prevented. 34 #[allow(dead_code)] 35 pub fn handle_beforeinput( 36 + doc: &mut SignalEditorDocument, 37 ctx: BeforeInputContext<'_>, 38 ) -> BeforeInputResult { 39 // During composition, let the browser handle most things. ··· 316 } 317 318 /// Get the current range based on cursor and selection state. 319 + fn get_current_range(doc: &SignalEditorDocument) -> Range { 320 if let Some(sel) = *doc.selection.read() { 321 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 322 Range::new(start, end)
+47 -24
crates/weaver-app/src/components/editor/collab.rs
··· 11 12 // Only compile for WASM - no-op stub provided at end 13 14 - use super::document::EditorDocument; 15 16 use dioxus::prelude::*; 17 18 #[cfg(target_arch = "wasm32")] 19 - use jacquard::smol_str::{format_smolstr, SmolStr}; 20 #[cfg(target_arch = "wasm32")] 21 use jacquard::types::string::AtUri; 22 ··· 38 #[derive(Props, Clone, PartialEq)] 39 pub struct CollabCoordinatorProps { 40 /// The editor document to sync 41 - pub document: EditorDocument, 42 /// Resource URI for the document being edited 43 pub resource_uri: String, 44 /// Presence state signal (updated by coordinator) ··· 239 let strong_ref = match fetcher.confirm_record_ref(&uri).await { 240 Ok(r) => r, 241 Err(e) => { 242 - let err = format_smolstr!("Failed to get resource ref: {e}"); 243 debug_state 244 .with_mut(|ds| ds.last_error = Some(err.clone())); 245 state.set(CoordinatorState::Error(err)); ··· 336 }) 337 .await 338 { 339 - tracing::error!("CollabCoordinator: AddPeers send failed: {e}"); 340 } 341 } else { 342 tracing::error!("CollabCoordinator: sink is None!"); ··· 395 let fetcher = fetcher.clone(); 396 397 // Get our profile info and send BroadcastJoin 398 - let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await { 399 Some(did) => { 400 - let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await { 401 - Ok(profile) => { 402 - match &profile.inner { 403 - ProfileDataViewInner::ProfileView(p) => { 404 - p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 405 - } 406 - ProfileDataViewInner::ProfileViewDetailed(p) => { 407 - p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 408 - } 409 ProfileDataViewInner::TangledProfileView(p) => { 410 p.handle.as_ref().into() 411 } 412 _ => did.as_ref().into(), 413 - } 414 - } 415 - Err(_) => did.as_ref().into(), 416 - }; 417 (did.as_ref().into(), display_name) 418 } 419 None => { 420 - tracing::warn!("CollabCoordinator: no current DID for Join message"); 421 ("unknown".into(), "Anonymous".into()) 422 } 423 }; ··· 430 }) 431 .await 432 { 433 - tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}"); 434 } 435 } 436 } ··· 460 let position = cursor.offset; 461 let sel = selection.map(|s| (s.anchor, s.head)); 462 463 - tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting"); 464 465 spawn(async move { 466 if let Some(ref mut s) = *worker_sink.write() { 467 - tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker"); 468 if let Err(e) = s 469 .send(WorkerInput::BroadcastCursor { 470 position, ··· 475 tracing::warn!("Failed to send BroadcastCursor to worker: {e}"); 476 } 477 } else { 478 - tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast"); 479 } 480 }); 481 });
··· 11 12 // Only compile for WASM - no-op stub provided at end 13 14 + use super::document::SignalEditorDocument; 15 16 use dioxus::prelude::*; 17 18 #[cfg(target_arch = "wasm32")] 19 + use jacquard::smol_str::{SmolStr, format_smolstr}; 20 #[cfg(target_arch = "wasm32")] 21 use jacquard::types::string::AtUri; 22 ··· 38 #[derive(Props, Clone, PartialEq)] 39 pub struct CollabCoordinatorProps { 40 /// The editor document to sync 41 + pub document: SignalEditorDocument, 42 /// Resource URI for the document being edited 43 pub resource_uri: String, 44 /// Presence state signal (updated by coordinator) ··· 239 let strong_ref = match fetcher.confirm_record_ref(&uri).await { 240 Ok(r) => r, 241 Err(e) => { 242 + let err = 243 + format_smolstr!("Failed to get resource ref: {e}"); 244 debug_state 245 .with_mut(|ds| ds.last_error = Some(err.clone())); 246 state.set(CoordinatorState::Error(err)); ··· 337 }) 338 .await 339 { 340 + tracing::error!( 341 + "CollabCoordinator: AddPeers send failed: {e}" 342 + ); 343 } 344 } else { 345 tracing::error!("CollabCoordinator: sink is None!"); ··· 398 let fetcher = fetcher.clone(); 399 400 // Get our profile info and send BroadcastJoin 401 + let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher 402 + .current_did() 403 + .await 404 + { 405 Some(did) => { 406 + let display_name: SmolStr = 407 + match fetcher.fetch_profile(&did.clone().into()).await { 408 + Ok(profile) => match &profile.inner { 409 + ProfileDataViewInner::ProfileView(p) => p 410 + .display_name 411 + .as_ref() 412 + .map(|s| s.as_ref().into()) 413 + .unwrap_or_else(|| did.as_ref().into()), 414 + ProfileDataViewInner::ProfileViewDetailed(p) => p 415 + .display_name 416 + .as_ref() 417 + .map(|s| s.as_ref().into()) 418 + .unwrap_or_else(|| did.as_ref().into()), 419 ProfileDataViewInner::TangledProfileView(p) => { 420 p.handle.as_ref().into() 421 } 422 _ => did.as_ref().into(), 423 + }, 424 + Err(_) => did.as_ref().into(), 425 + }; 426 (did.as_ref().into(), display_name) 427 } 428 None => { 429 + tracing::warn!( 430 + "CollabCoordinator: no current DID for Join message" 431 + ); 432 ("unknown".into(), "Anonymous".into()) 433 } 434 }; ··· 441 }) 442 .await 443 { 444 + tracing::error!( 445 + "CollabCoordinator: BroadcastJoin send failed: {e}" 446 + ); 447 } 448 } 449 } ··· 473 let position = cursor.offset; 474 let sel = selection.map(|s| (s.anchor, s.head)); 475 476 + tracing::debug!( 477 + position, 478 + ?sel, 479 + "CollabCoordinator: cursor changed, broadcasting" 480 + ); 481 482 spawn(async move { 483 if let Some(ref mut s) = *worker_sink.write() { 484 + tracing::debug!( 485 + position, 486 + "CollabCoordinator: sending BroadcastCursor to worker" 487 + ); 488 if let Err(e) = s 489 .send(WorkerInput::BroadcastCursor { 490 position, ··· 495 tracing::warn!("Failed to send BroadcastCursor to worker: {e}"); 496 } 497 } else { 498 + tracing::debug!( 499 + position, 500 + "CollabCoordinator: worker sink not ready, skipping cursor broadcast" 501 + ); 502 } 503 }); 504 });
+10 -8
crates/weaver-app/src/components/editor/component.rs
··· 9 use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 10 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 11 use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 12 - use super::document::{CompositionState, EditorDocument, LoadedDocState}; 13 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 use super::dom_sync::update_paragraph_dom; 15 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; ··· 421 422 #[allow(unused_mut)] 423 let mut document = use_hook(|| { 424 - let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); 425 426 // Seed collected_refs with existing record embeds so they get fetched/rendered 427 let record_embeds = doc.record_embeds(); ··· 469 let entry_index_for_memo = entry_index.clone(); 470 #[allow(unused_mut)] 471 let mut paragraphs = use_memo(move || { 472 let edit = doc_for_memo.last_edit(); 473 let cache = render_cache.peek(); 474 let resolver = image_resolver(); ··· 481 482 let cursor_offset = doc_for_memo.cursor.read().offset; 483 let (paras, new_cache, refs) = render::render_paragraphs_incremental( 484 - doc_for_memo.loro_text(), 485 Some(&cache), 486 cursor_offset, 487 edit.as_ref(), ··· 501 // Background fetch for AT embeds via worker 502 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 503 { 504 - use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 505 use dioxus::prelude::Writable; 506 use gloo_worker::Spawnable; 507 508 let resolved_content_for_fetch = resolved_content; 509 let mut embed_worker_bridge: Signal<Option<gloo_worker::WorkerBridge<EmbedWorker>>> = ··· 559 .filter_map(|r| match r { 560 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 561 // Skip if already resolved 562 - if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { 563 if current_resolved.get_embed_content(&at_uri).is_none() { 564 return Some(uri.clone()); 565 } ··· 598 .filter_map(|r| match r { 599 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 600 // Skip if already resolved 601 - if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) { 602 if current_resolved.get_embed_content(&at_uri).is_none() { 603 return Some(uri.clone()); 604 } ··· 731 // Worker-based autosave (offloads export + encode to worker thread) 732 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 733 { 734 - use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 735 use gloo_storage::Storage; 736 use gloo_worker::Spawnable; 737 use gloo_worker::reactor::ReactorBridge; 738 739 use futures_util::stream::{SplitSink, SplitStream}; 740 ··· 1810 #[component] 1811 fn RemoteCursors( 1812 presence: Signal<weaver_common::transport::PresenceSnapshot>, 1813 - document: EditorDocument, 1814 render_cache: Signal<render::RenderCache>, 1815 ) -> Element { 1816 let presence_read = presence.read();
··· 9 use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 10 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 11 use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 12 + use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; 13 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 use super::dom_sync::update_paragraph_dom; 15 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; ··· 421 422 #[allow(unused_mut)] 423 let mut document = use_hook(|| { 424 + let mut doc = SignalEditorDocument::from_loaded_state(loaded_state.clone()); 425 426 // Seed collected_refs with existing record embeds so they get fetched/rendered 427 let record_embeds = doc.record_embeds(); ··· 469 let entry_index_for_memo = entry_index.clone(); 470 #[allow(unused_mut)] 471 let mut paragraphs = use_memo(move || { 472 + // Read content_changed to establish reactive dependency 473 + let _ = doc_for_memo.content_changed.read(); 474 let edit = doc_for_memo.last_edit(); 475 let cache = render_cache.peek(); 476 let resolver = image_resolver(); ··· 483 484 let cursor_offset = doc_for_memo.cursor.read().offset; 485 let (paras, new_cache, refs) = render::render_paragraphs_incremental( 486 + doc_for_memo.buffer(), 487 Some(&cache), 488 cursor_offset, 489 edit.as_ref(), ··· 503 // Background fetch for AT embeds via worker 504 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 505 { 506 use dioxus::prelude::Writable; 507 use gloo_worker::Spawnable; 508 + use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; 509 510 let resolved_content_for_fetch = resolved_content; 511 let mut embed_worker_bridge: Signal<Option<gloo_worker::WorkerBridge<EmbedWorker>>> = ··· 561 .filter_map(|r| match r { 562 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 563 // Skip if already resolved 564 + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { 565 if current_resolved.get_embed_content(&at_uri).is_none() { 566 return Some(uri.clone()); 567 } ··· 600 .filter_map(|r| match r { 601 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 602 // Skip if already resolved 603 + if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { 604 if current_resolved.get_embed_content(&at_uri).is_none() { 605 return Some(uri.clone()); 606 } ··· 733 // Worker-based autosave (offloads export + encode to worker thread) 734 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 735 { 736 use gloo_storage::Storage; 737 use gloo_worker::Spawnable; 738 use gloo_worker::reactor::ReactorBridge; 739 + use weaver_editor_crdt::{EditorReactor, WorkerInput, WorkerOutput}; 740 741 use futures_util::stream::{SplitSink, SplitStream}; 742 ··· 1812 #[component] 1813 fn RemoteCursors( 1814 presence: Signal<weaver_common::transport::PresenceSnapshot>, 1815 + document: SignalEditorDocument, 1816 render_cache: Signal<render::RenderCache>, 1817 ) -> Element { 1818 let presence_read = presence.read();
+208 -280
crates/weaver-app/src/components/editor/document.rs
··· 7 //! 8 //! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity: 9 //! - Cursor/selection changes don't trigger content re-renders 10 - //! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation 11 //! - The document struct itself is NOT wrapped in a Signal - use `use_hook` 12 13 - use std::borrow::Cow; 14 - use std::cell::RefCell; 15 - use std::rc::Rc; 16 - 17 use dioxus::prelude::*; 18 use loro::{ 19 - ExportMode, Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, 20 - UndoManager, VersionVector, 21 - cursor::{Cursor, Side}, 22 }; 23 24 use jacquard::IntoStatic; ··· 28 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 29 use weaver_api::sh_weaver::embed::images::Image; 30 use weaver_api::sh_weaver::embed::records::RecordEmbed; 31 - pub use weaver_editor_core::{ 32 - Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, 33 - }; 34 use weaver_api::sh_weaver::notebook::entry::Entry; 35 36 /// Helper for working with editor images. 37 /// Constructed from LoroMap data, NOT serialized directly. ··· 47 48 /// Single source of truth for editor state. 49 /// 50 - /// Contains the document text (backed by Loro CRDT), cursor position, 51 /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 52 /// schema with CRDT containers for each field. 53 /// ··· 56 /// The document itself is NOT wrapped in a Signal. Instead, individual fields 57 /// that need reactivity are wrapped in Signals: 58 /// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates 59 - /// - `last_edit` - triggers paragraph re-renders when content changes 60 /// 61 - /// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`. 62 /// 63 /// # Cloning 64 /// 65 - /// EditorDocument is cheap to clone - Loro types are Arc-backed handles, 66 /// and Signals are Copy. Closures can capture clones without overhead. 67 #[derive(Clone)] 68 - pub struct EditorDocument { 69 - /// The Loro document containing all editor state. 70 - doc: LoroDoc, 71 72 // --- Entry schema containers (Loro handles interior mutability) --- 73 - /// Markdown content (maps to entry.content) 74 - content: LoroText, 75 - 76 /// Entry title (maps to entry.title) 77 title: LoroText, 78 ··· 118 /// Maps root URI -> last diff URI we've imported from that root. 119 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this. 120 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>, 121 - 122 - // --- Editor state (non-reactive) --- 123 - /// Undo manager for the document. 124 - undo_mgr: Rc<RefCell<UndoManager>>, 125 - 126 - /// CRDT-aware cursor that tracks position through remote edits and undo/redo. 127 - /// Recreated after our own edits, queried after undo/redo/remote edits. 128 - loro_cursor: Option<Cursor>, 129 130 // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- 131 /// Current cursor position. Signal so cursor changes don't dirty content memos. ··· 141 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 142 pub composition_ended_at: Signal<Option<web_time::Instant>>, 143 144 - /// Most recent edit info for incremental rendering optimization. 145 - /// Signal so paragraphs memo can subscribe to content changes only. 146 - pub last_edit: Signal<Option<EditInfo>>, 147 148 /// Pending snap direction for cursor restoration after edits. 149 /// Set by input handlers, consumed by cursor restoration. ··· 154 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>, 155 } 156 157 - // CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE 158 - // are imported from weaver_editor_core. 159 - 160 /// Pre-loaded document state that can be created outside of reactive context. 161 /// 162 /// This struct holds the raw LoroDoc (which is safe outside reactive context) 163 - /// along with sync state metadata. Use `EditorDocument::from_loaded_state()` 164 - /// inside a `use_hook` to convert this into a reactive EditorDocument. 165 /// 166 /// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). 167 /// PartialEq always returns false since we can't meaningfully compare docs. ··· 197 } 198 } 199 200 - impl EditorDocument { 201 - /// Check if a character position is within the block-syntax zone of its line. 202 - fn is_in_block_syntax_zone(&self, pos: usize) -> bool { 203 - if pos <= BLOCK_SYNTAX_ZONE { 204 - return true; 205 - } 206 - 207 - // Search backwards from pos-1, only need to check BLOCK_SYNTAX_ZONE + 1 chars. 208 - let search_start = pos.saturating_sub(BLOCK_SYNTAX_ZONE + 1); 209 - for i in (search_start..pos).rev() { 210 - if self.content.char_at(i).ok() == Some('\n') { 211 - // Found newline at i, distance from line start is pos - i - 1. 212 - return (pos - i - 1) <= BLOCK_SYNTAX_ZONE; 213 - } 214 - } 215 - // No newline found in search range, and pos > BLOCK_SYNTAX_ZONE, so not in zone. 216 - false 217 - } 218 - 219 /// Create a new editor document with the given content. 220 /// Sets `created_at` to current time. 221 /// 222 /// # Note 223 /// This creates Dioxus Signals for reactive fields. Call from within 224 - /// a component using `use_hook(|| EditorDocument::new(...))`. 225 pub fn new(initial_content: String) -> Self { 226 - let doc = LoroDoc::new(); 227 228 - // Get all containers 229 - let content = doc.get_text("content"); 230 let title = doc.get_text("title"); 231 let path = doc.get_text("path"); 232 let created_at = doc.get_text("created_at"); ··· 235 236 // Insert initial content if any 237 if !initial_content.is_empty() { 238 - content 239 - .insert(0, &initial_content) 240 - .expect("failed to insert initial content"); 241 } 242 243 // Set created_at to current time (ISO 8601) ··· 246 .insert(0, &now) 247 .expect("failed to set created_at"); 248 249 - // Set up undo manager with merge interval for batching keystrokes 250 - let mut undo_mgr = UndoManager::new(&doc); 251 - undo_mgr.set_merge_interval(300); // 300ms merge window 252 - undo_mgr.set_max_undo_steps(100); 253 - 254 - // Create initial Loro cursor at position 0 255 - let loro_cursor = content.get_cursor(0, Side::default()); 256 - 257 Self { 258 - doc, 259 - content, 260 title, 261 path, 262 created_at, ··· 268 last_diff: Signal::new(None), 269 last_synced_version: Signal::new(None), 270 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 271 - undo_mgr: Rc::new(RefCell::new(undo_mgr)), 272 - loro_cursor, 273 - // Reactive editor state - wrapped in Signals 274 cursor: Signal::new(CursorState { 275 offset: 0, 276 affinity: Affinity::Before, ··· 278 selection: Signal::new(None), 279 composition: Signal::new(None), 280 composition_ended_at: Signal::new(None), 281 - last_edit: Signal::new(None), 282 pending_snap: Signal::new(None), 283 collected_refs: Signal::new(Vec::new()), 284 } 285 } 286 287 - /// Create an EditorDocument from a fetched Entry. 288 /// 289 /// MUST be called from within a reactive context (e.g., `use_hook`) to 290 /// properly initialize Dioxus Signals. ··· 341 342 /// Get the underlying LoroText for read operations on content. 343 pub fn loro_text(&self) -> &LoroText { 344 - &self.content 345 } 346 347 /// Get the underlying LoroDoc for subscriptions and advanced operations. 348 pub fn loro_doc(&self) -> &LoroDoc { 349 - &self.doc 350 } 351 352 // --- Content accessors --- 353 354 /// Get the markdown content as a string. 355 pub fn content(&self) -> String { 356 - self.content.to_string() 357 } 358 359 /// Convert the document content to a string (alias for content()). 360 pub fn to_string(&self) -> String { 361 - self.content.to_string() 362 } 363 364 /// Get the length of the content in characters. 365 pub fn len_chars(&self) -> usize { 366 - self.content.len_unicode() 367 } 368 369 /// Get the length of the content in UTF-8 bytes. 370 pub fn len_bytes(&self) -> usize { 371 - self.content.len_utf8() 372 } 373 374 /// Get the length of the content in UTF-16 code units. 375 pub fn len_utf16(&self) -> usize { 376 - self.content.len_utf16() 377 } 378 379 /// Check if the content is empty. 380 pub fn is_empty(&self) -> bool { 381 - self.content.len_unicode() == 0 382 } 383 384 // --- Entry metadata accessors --- ··· 652 .map(|r| r.into_static()) 653 } 654 655 - /// Insert text into content and record edit info for incremental rendering. 656 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { 657 - let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 658 - let len_before = self.content.len_unicode(); 659 - let result = self.content.insert(pos, text); 660 - let len_after = self.content.len_unicode(); 661 - self.last_edit.set(Some(EditInfo { 662 - edit_char_pos: pos, 663 - inserted_len: len_after.saturating_sub(len_before), 664 - deleted_len: 0, 665 - contains_newline: text.contains('\n'), 666 - in_block_syntax_zone, 667 - doc_len_after: len_after, 668 - timestamp: web_time::Instant::now(), 669 - })); 670 - result 671 } 672 673 /// Push text to end of content. Faster than insert for appending. 674 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { 675 - let pos = self.content.len_unicode(); 676 - let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 677 - let result = self.content.push_str(text); 678 - let len_after = self.content.len_unicode(); 679 - self.last_edit.set(Some(EditInfo { 680 - edit_char_pos: pos, 681 - inserted_len: text.chars().count(), 682 - deleted_len: 0, 683 - contains_newline: text.contains('\n'), 684 - in_block_syntax_zone, 685 - doc_len_after: len_after, 686 - timestamp: web_time::Instant::now(), 687 - })); 688 - result 689 } 690 691 - /// Remove text range from content and record edit info for incremental rendering. 692 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 693 - let contains_newline = self 694 - .content 695 - .slice(start, start + len) 696 - .map(|s| s.contains('\n')) 697 - .unwrap_or(false); 698 - let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 699 - 700 - let result = self.content.delete(start, len); 701 - self.last_edit.set(Some(EditInfo { 702 - edit_char_pos: start, 703 - inserted_len: 0, 704 - deleted_len: len, 705 - contains_newline, 706 - in_block_syntax_zone, 707 - doc_len_after: self.content.len_unicode(), 708 - timestamp: web_time::Instant::now(), 709 - })); 710 - result 711 } 712 713 - /// Replace text in content (delete then insert) and record combined edit info. 714 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 715 - let delete_has_newline = self 716 - .content 717 - .slice(start, start + len) 718 - .map(|s| s.contains('\n')) 719 - .unwrap_or(false); 720 - let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 721 - 722 - let len_before = self.content.len_unicode(); 723 - // Use splice for atomic replace 724 - self.content.splice(start, len, text)?; 725 - let len_after = self.content.len_unicode(); 726 - 727 - // inserted_len = (len_after - len_before) + deleted_len 728 - // because: len_after = len_before - deleted + inserted 729 - let inserted_len = (len_after + len).saturating_sub(len_before); 730 - 731 - self.last_edit.set(Some(EditInfo { 732 - edit_char_pos: start, 733 - inserted_len, 734 - deleted_len: len, 735 - contains_newline: delete_has_newline || text.contains('\n'), 736 - in_block_syntax_zone, 737 - doc_len_after: len_after, 738 - timestamp: web_time::Instant::now(), 739 - })); 740 Ok(()) 741 } 742 ··· 746 // so it tracks through the undo operation 747 self.sync_loro_cursor(); 748 749 - let result = self.undo_mgr.borrow_mut().undo()?; 750 if result { 751 // After undo, query Loro cursor for new position 752 self.sync_cursor_from_loro(); 753 // Signal content change for re-render 754 - self.last_edit.set(None); 755 } 756 Ok(result) 757 } ··· 761 // Sync Loro cursor to current position BEFORE redo 762 self.sync_loro_cursor(); 763 764 - let result = self.undo_mgr.borrow_mut().redo()?; 765 if result { 766 // After redo, query Loro cursor for new position 767 self.sync_cursor_from_loro(); 768 // Signal content change for re-render 769 - self.last_edit.set(None); 770 } 771 Ok(result) 772 } 773 774 /// Check if undo is available. 775 pub fn can_undo(&self) -> bool { 776 - self.undo_mgr.borrow().can_undo() 777 } 778 779 /// Check if redo is available. 780 pub fn can_redo(&self) -> bool { 781 - self.undo_mgr.borrow().can_redo() 782 } 783 784 /// Get a slice of the content text. 785 /// Returns None if the range is invalid. 786 - pub fn slice(&self, start: usize, end: usize) -> Option<String> { 787 - self.content.slice(start, end).ok() 788 } 789 790 /// Sync the Loro cursor to the current cursor.offset position. ··· 792 pub fn sync_loro_cursor(&mut self) { 793 let offset = self.cursor.read().offset; 794 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); 795 - self.loro_cursor = self.content.get_cursor(offset, Side::default()); 796 } 797 798 /// Update cursor.offset from the Loro cursor's tracked position. 799 /// Call this after undo/redo or remote edits where the position may have shifted. 800 /// Returns the new offset, or None if the cursor couldn't be resolved. 801 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 802 - let loro_cursor = self.loro_cursor.as_ref()?; 803 - let result = self.doc.get_cursor_pos(loro_cursor).ok()?; 804 let old_offset = self.cursor.read().offset; 805 - let new_offset = result.current.pos.min(self.len_chars()); 806 - let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset }; 807 if jump > 100 { 808 tracing::warn!( 809 old_offset, ··· 812 "sync_cursor_from_loro: LARGE CURSOR JUMP detected" 813 ); 814 } 815 - tracing::debug!(old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro"); 816 self.cursor.with_mut(|c| c.offset = new_offset); 817 Some(new_offset) 818 } 819 820 /// Get the Loro cursor for serialization. 821 - pub fn loro_cursor(&self) -> Option<&Cursor> { 822 - self.loro_cursor.as_ref() 823 } 824 825 /// Set the Loro cursor (used when restoring from storage). 826 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) { 827 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); 828 - self.loro_cursor = cursor; 829 // Sync cursor.offset from the restored Loro cursor 830 - if self.loro_cursor.is_some() { 831 self.sync_cursor_from_loro(); 832 } 833 } ··· 835 /// Export the document as a binary snapshot. 836 /// This captures all CRDT state including undo history. 837 pub fn export_snapshot(&self) -> Vec<u8> { 838 - self.doc.export(ExportMode::Snapshot).unwrap_or_default() 839 } 840 841 /// Get the current state frontiers for change detection. 842 /// Frontiers represent the "version" of the document state. 843 pub fn state_frontiers(&self) -> Frontiers { 844 - self.doc.state_frontiers() 845 } 846 847 /// Get the current version vector. 848 pub fn version_vector(&self) -> VersionVector { 849 - self.doc.oplog_vv() 850 } 851 852 /// Get the last edit info for incremental rendering. 853 - /// Reading this creates a reactive dependency on content changes. 854 pub fn last_edit(&self) -> Option<EditInfo> { 855 - self.last_edit.read().clone() 856 } 857 858 // --- Collected refs accessors --- ··· 916 /// Check if there are unsynced changes since the last PDS sync. 917 pub fn has_unsynced_changes(&self) -> bool { 918 match &*self.last_synced_version.read() { 919 - Some(synced_vv) => self.doc.oplog_vv() != *synced_vv, 920 None => true, // Never synced, so there are changes 921 } 922 } ··· 926 /// After successful upload, call `mark_synced()` to update the sync marker. 927 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 928 let from_vv = self.last_synced_version.read().clone().unwrap_or_default(); 929 - let current_vv = self.doc.oplog_vv(); 930 - 931 - // No changes since last sync 932 - if from_vv == current_vv { 933 - return None; 934 - } 935 - 936 - let updates = self 937 - .doc 938 - .export(ExportMode::Updates { 939 - from: Cow::Owned(from_vv), 940 - }) 941 - .ok()?; 942 - 943 - // Don't return empty updates 944 - if updates.is_empty() { 945 - return None; 946 - } 947 - 948 - Some(updates) 949 } 950 951 /// Mark the current state as synced. 952 /// Call this after successfully uploading a diff to the PDS. 953 pub fn mark_synced(&mut self) { 954 - self.last_synced_version.set(Some(self.doc.oplog_vv())); 955 } 956 957 /// Import updates from a PDS diff blob. 958 /// Used when loading edit history from the PDS. 959 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { 960 - let len_before = self.content.len_unicode(); 961 - let vv_before = self.doc.oplog_vv(); 962 963 - self.doc.import(updates)?; 964 965 - let len_after = self.content.len_unicode(); 966 - let vv_after = self.doc.oplog_vv(); 967 let vv_changed = vv_before != vv_after; 968 let len_changed = len_before != len_after; 969 ··· 977 978 // Only trigger re-render if something actually changed 979 if vv_changed { 980 - self.last_edit.set(None); 981 } 982 Ok(()) 983 } ··· 985 /// Export updates since the given version vector. 986 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync. 987 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> { 988 - let current_vv = self.doc.oplog_vv(); 989 - 990 - // No changes since the given version 991 - if *from_vv == current_vv { 992 - return None; 993 - } 994 - 995 - let updates = self 996 - .doc 997 - .export(ExportMode::Updates { 998 - from: Cow::Borrowed(from_vv), 999 - }) 1000 - .ok()?; 1001 - 1002 - if updates.is_empty() { 1003 - return None; 1004 - } 1005 - 1006 - Some(updates) 1007 } 1008 1009 /// Set the sync state when loading from PDS. ··· 1016 ) { 1017 self.edit_root.set(Some(edit_root)); 1018 self.last_diff.set(last_diff); 1019 - self.last_synced_version.set(Some(self.doc.oplog_vv())); 1020 } 1021 1022 - /// Create a new EditorDocument from a binary snapshot. 1023 /// Falls back to empty document if import fails. 1024 /// 1025 /// If `loro_cursor` is provided, it will be used to restore the cursor position. ··· 1037 loro_cursor: Option<Cursor>, 1038 fallback_offset: usize, 1039 ) -> Self { 1040 - let doc = LoroDoc::new(); 1041 - 1042 - if !snapshot.is_empty() { 1043 - if let Err(e) = doc.import(snapshot) { 1044 - tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); 1045 } 1046 - } 1047 1048 - // Get all containers (they will contain data from the snapshot if import succeeded) 1049 - let content = doc.get_text("content"); 1050 let title = doc.get_text("title"); 1051 let path = doc.get_text("path"); 1052 let created_at = doc.get_text("created_at"); 1053 let tags = doc.get_list("tags"); 1054 let embeds = doc.get_map("embeds"); 1055 1056 - // Set up undo manager - tracks operations from this point forward only 1057 - let mut undo_mgr = UndoManager::new(&doc); 1058 - undo_mgr.set_merge_interval(300); 1059 - undo_mgr.set_max_undo_steps(100); 1060 - 1061 // Try to restore cursor from Loro cursor, fall back to offset 1062 - let max_offset = content.len_unicode(); 1063 let cursor_offset = if let Some(ref lc) = loro_cursor { 1064 doc.get_cursor_pos(lc) 1065 .map(|r| r.current.pos) ··· 1073 affinity: Affinity::Before, 1074 }; 1075 1076 - // If no Loro cursor provided, create one at the restored position 1077 - let loro_cursor = 1078 - loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default())); 1079 1080 Self { 1081 - doc, 1082 - content, 1083 title, 1084 path, 1085 created_at, ··· 1091 last_diff: Signal::new(None), 1092 last_synced_version: Signal::new(None), 1093 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 1094 - undo_mgr: Rc::new(RefCell::new(undo_mgr)), 1095 - loro_cursor, 1096 - // Reactive editor state - wrapped in Signals 1097 cursor: Signal::new(cursor_state), 1098 selection: Signal::new(None), 1099 composition: Signal::new(None), 1100 composition_ended_at: Signal::new(None), 1101 - last_edit: Signal::new(None), 1102 pending_snap: Signal::new(None), 1103 collected_refs: Signal::new(Vec::new()), 1104 } 1105 } 1106 1107 - /// Create an EditorDocument from pre-loaded state. 1108 /// 1109 /// Use this when loading from PDS/localStorage merge outside reactive context. 1110 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it ··· 1113 /// # Note 1114 /// This creates Dioxus Signals. Call from within a component using `use_hook`. 1115 pub fn from_loaded_state(state: LoadedDocState) -> Self { 1116 - let doc = state.doc; 1117 1118 - // Get all containers from the loaded doc 1119 - let content = doc.get_text("content"); 1120 let title = doc.get_text("title"); 1121 let path = doc.get_text("path"); 1122 let created_at = doc.get_text("created_at"); 1123 let tags = doc.get_list("tags"); 1124 let embeds = doc.get_map("embeds"); 1125 1126 - // Set up undo manager 1127 - let mut undo_mgr = UndoManager::new(&doc); 1128 - undo_mgr.set_merge_interval(300); 1129 - undo_mgr.set_max_undo_steps(100); 1130 - 1131 // Position cursor at end of content 1132 - let cursor_offset = content.len_unicode(); 1133 let cursor_state = CursorState { 1134 offset: cursor_offset, 1135 affinity: Affinity::Before, 1136 }; 1137 - let loro_cursor = content.get_cursor(cursor_offset, Side::default()); 1138 1139 Self { 1140 - doc, 1141 - content, 1142 title, 1143 path, 1144 created_at, ··· 1151 // Use the synced version from state (tracks the PDS version vector) 1152 last_synced_version: Signal::new(state.synced_version), 1153 last_seen_diffs: Signal::new(state.last_seen_diffs), 1154 - undo_mgr: Rc::new(RefCell::new(undo_mgr)), 1155 - loro_cursor, 1156 cursor: Signal::new(cursor_state), 1157 selection: Signal::new(None), 1158 composition: Signal::new(None), 1159 composition_ended_at: Signal::new(None), 1160 - last_edit: Signal::new(None), 1161 pending_snap: Signal::new(None), 1162 collected_refs: Signal::new(Vec::new()), 1163 } 1164 } 1165 } 1166 1167 - impl PartialEq for EditorDocument { 1168 fn eq(&self, _other: &Self) -> bool { 1169 - // EditorDocument uses interior mutability, so we can't meaningfully compare. 1170 // Return false to ensure components re-render when passed as props. 1171 false 1172 } 1173 } 1174 1175 - impl weaver_editor_crdt::CrdtDocument for EditorDocument { 1176 fn export_snapshot(&self) -> Vec<u8> { 1177 self.export_snapshot() 1178 } ··· 1191 } 1192 1193 fn edit_root(&self) -> Option<StrongRef<'static>> { 1194 - self.edit_root() 1195 } 1196 1197 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 1198 - self.set_edit_root(root); 1199 } 1200 1201 fn last_diff(&self) -> Option<StrongRef<'static>> { 1202 - self.last_diff() 1203 } 1204 1205 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 1206 - self.set_last_diff(diff); 1207 } 1208 1209 fn mark_synced(&mut self) { 1210 - self.mark_synced(); 1211 } 1212 1213 fn has_unsynced_changes(&self) -> bool { 1214 - self.has_unsynced_changes() 1215 } 1216 }
··· 7 //! 8 //! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity: 9 //! - Cursor/selection changes don't trigger content re-renders 10 + //! - Content changes bump `content_changed` Signal to trigger paragraph re-renders 11 //! - The document struct itself is NOT wrapped in a Signal - use `use_hook` 12 13 use dioxus::prelude::*; 14 use loro::{ 15 + Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector, 16 + cursor::Cursor, 17 }; 18 19 use jacquard::IntoStatic; ··· 23 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 24 use weaver_api::sh_weaver::embed::images::Image; 25 use weaver_api::sh_weaver::embed::records::RecordEmbed; 26 use weaver_api::sh_weaver::notebook::entry::Entry; 27 + use weaver_editor_core::EditorDocument; 28 + use weaver_editor_core::TextBuffer; 29 + use weaver_editor_core::UndoManager; 30 + pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection}; 31 + use weaver_editor_crdt::LoroTextBuffer; 32 33 /// Helper for working with editor images. 34 /// Constructed from LoroMap data, NOT serialized directly. ··· 44 45 /// Single source of truth for editor state. 46 /// 47 + /// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position, 48 /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 49 /// schema with CRDT containers for each field. 50 /// ··· 53 /// The document itself is NOT wrapped in a Signal. Instead, individual fields 54 /// that need reactivity are wrapped in Signals: 55 /// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates 56 + /// - `content_changed` - bumped to trigger paragraph re-renders when content changes 57 /// 58 + /// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`. 59 /// 60 /// # Cloning 61 /// 62 + /// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed, 63 /// and Signals are Copy. Closures can capture clones without overhead. 64 #[derive(Clone)] 65 + pub struct SignalEditorDocument { 66 + /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking. 67 + /// Access the underlying LoroDoc via `buffer.doc()`. 68 + buffer: LoroTextBuffer, 69 70 // --- Entry schema containers (Loro handles interior mutability) --- 71 + // These are obtained from buffer.doc() but cached for convenience. 72 /// Entry title (maps to entry.title) 73 title: LoroText, 74 ··· 114 /// Maps root URI -> last diff URI we've imported from that root. 115 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this. 116 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>, 117 118 // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- 119 /// Current cursor position. Signal so cursor changes don't dirty content memos. ··· 129 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 130 pub composition_ended_at: Signal<Option<web_time::Instant>>, 131 132 + /// Bumped when content changes to trigger paragraph re-renders. 133 + /// Actual EditInfo is obtained from `buffer.last_edit()`. 134 + pub content_changed: Signal<()>, 135 136 /// Pending snap direction for cursor restoration after edits. 137 /// Set by input handlers, consumed by cursor restoration. ··· 142 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>, 143 } 144 145 /// Pre-loaded document state that can be created outside of reactive context. 146 /// 147 /// This struct holds the raw LoroDoc (which is safe outside reactive context) 148 + /// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()` 149 + /// inside a `use_hook` to convert this into a reactive SignalEditorDocument. 150 /// 151 /// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). 152 /// PartialEq always returns false since we can't meaningfully compare docs. ··· 182 } 183 } 184 185 + impl SignalEditorDocument { 186 /// Create a new editor document with the given content. 187 /// Sets `created_at` to current time. 188 /// 189 /// # Note 190 /// This creates Dioxus Signals for reactive fields. Call from within 191 + /// a component using `use_hook(|| SignalEditorDocument::new(...))`. 192 pub fn new(initial_content: String) -> Self { 193 + // Create the LoroTextBuffer which owns the LoroDoc 194 + let mut buffer = LoroTextBuffer::new(); 195 + let doc = buffer.doc().clone(); 196 197 + // Get other containers from the doc 198 let title = doc.get_text("title"); 199 let path = doc.get_text("path"); 200 let created_at = doc.get_text("created_at"); ··· 203 204 // Insert initial content if any 205 if !initial_content.is_empty() { 206 + buffer.insert(0, &initial_content); 207 } 208 209 // Set created_at to current time (ISO 8601) ··· 212 .insert(0, &now) 213 .expect("failed to set created_at"); 214 215 Self { 216 + buffer, 217 title, 218 path, 219 created_at, ··· 225 last_diff: Signal::new(None), 226 last_synced_version: Signal::new(None), 227 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 228 cursor: Signal::new(CursorState { 229 offset: 0, 230 affinity: Affinity::Before, ··· 232 selection: Signal::new(None), 233 composition: Signal::new(None), 234 composition_ended_at: Signal::new(None), 235 + content_changed: Signal::new(()), 236 pending_snap: Signal::new(None), 237 collected_refs: Signal::new(Vec::new()), 238 } 239 } 240 241 + /// Create a SignalEditorDocument from a fetched Entry. 242 /// 243 /// MUST be called from within a reactive context (e.g., `use_hook`) to 244 /// properly initialize Dioxus Signals. ··· 295 296 /// Get the underlying LoroText for read operations on content. 297 pub fn loro_text(&self) -> &LoroText { 298 + self.buffer.content() 299 } 300 301 /// Get the underlying LoroDoc for subscriptions and advanced operations. 302 pub fn loro_doc(&self) -> &LoroDoc { 303 + self.buffer.doc() 304 + } 305 + 306 + /// Get direct access to the LoroTextBuffer. 307 + pub fn buffer(&self) -> &LoroTextBuffer { 308 + &self.buffer 309 + } 310 + 311 + /// Get mutable access to the LoroTextBuffer. 312 + pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer { 313 + &mut self.buffer 314 } 315 316 // --- Content accessors --- 317 318 /// Get the markdown content as a string. 319 pub fn content(&self) -> String { 320 + weaver_editor_core::TextBuffer::to_string(&self.buffer) 321 } 322 323 /// Convert the document content to a string (alias for content()). 324 pub fn to_string(&self) -> String { 325 + weaver_editor_core::TextBuffer::to_string(&self.buffer) 326 } 327 328 /// Get the length of the content in characters. 329 pub fn len_chars(&self) -> usize { 330 + weaver_editor_core::TextBuffer::len_chars(&self.buffer) 331 } 332 333 /// Get the length of the content in UTF-8 bytes. 334 pub fn len_bytes(&self) -> usize { 335 + weaver_editor_core::TextBuffer::len_bytes(&self.buffer) 336 } 337 338 /// Get the length of the content in UTF-16 code units. 339 pub fn len_utf16(&self) -> usize { 340 + self.buffer.content().len_utf16() 341 } 342 343 /// Check if the content is empty. 344 pub fn is_empty(&self) -> bool { 345 + weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0 346 } 347 348 // --- Entry metadata accessors --- ··· 616 .map(|r| r.into_static()) 617 } 618 619 + /// Insert text into content and bump content_changed for re-rendering. 620 + /// Edit info is tracked automatically by the buffer. 621 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { 622 + self.buffer.insert(pos, text); 623 + self.content_changed.set(()); 624 + Ok(()) 625 } 626 627 /// Push text to end of content. Faster than insert for appending. 628 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { 629 + let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 630 + self.buffer.insert(pos, text); 631 + self.content_changed.set(()); 632 + Ok(()) 633 } 634 635 + /// Remove text range from content and bump content_changed for re-rendering. 636 + /// Edit info is tracked automatically by the buffer. 637 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 638 + self.buffer.delete(start..start + len); 639 + self.content_changed.set(()); 640 + Ok(()) 641 } 642 643 + /// Replace text in content (atomic splice) and bump content_changed. 644 + /// Edit info is tracked automatically by the buffer. 645 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 646 + self.buffer.replace(start..start + len, text); 647 + self.content_changed.set(()); 648 Ok(()) 649 } 650 ··· 654 // so it tracks through the undo operation 655 self.sync_loro_cursor(); 656 657 + let result = self.buffer.undo(); 658 if result { 659 // After undo, query Loro cursor for new position 660 self.sync_cursor_from_loro(); 661 // Signal content change for re-render 662 + self.content_changed.set(()); 663 } 664 Ok(result) 665 } ··· 669 // Sync Loro cursor to current position BEFORE redo 670 self.sync_loro_cursor(); 671 672 + let result = self.buffer.redo(); 673 if result { 674 // After redo, query Loro cursor for new position 675 self.sync_cursor_from_loro(); 676 // Signal content change for re-render 677 + self.content_changed.set(()); 678 } 679 Ok(result) 680 } 681 682 /// Check if undo is available. 683 pub fn can_undo(&self) -> bool { 684 + UndoManager::can_undo(&self.buffer) 685 } 686 687 /// Check if redo is available. 688 pub fn can_redo(&self) -> bool { 689 + UndoManager::can_redo(&self.buffer) 690 } 691 692 /// Get a slice of the content text. 693 /// Returns None if the range is invalid. 694 + pub fn slice(&self, start: usize, end: usize) -> Option<SmolStr> { 695 + self.buffer.slice(start..end) 696 } 697 698 /// Sync the Loro cursor to the current cursor.offset position. ··· 700 pub fn sync_loro_cursor(&mut self) { 701 let offset = self.cursor.read().offset; 702 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); 703 + self.buffer.sync_cursor(offset); 704 } 705 706 /// Update cursor.offset from the Loro cursor's tracked position. 707 /// Call this after undo/redo or remote edits where the position may have shifted. 708 /// Returns the new offset, or None if the cursor couldn't be resolved. 709 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 710 let old_offset = self.cursor.read().offset; 711 + let new_offset = self.buffer.resolve_cursor()?; 712 + let jump = if new_offset > old_offset { 713 + new_offset - old_offset 714 + } else { 715 + old_offset - new_offset 716 + }; 717 if jump > 100 { 718 tracing::warn!( 719 old_offset, ··· 722 "sync_cursor_from_loro: LARGE CURSOR JUMP detected" 723 ); 724 } 725 + tracing::debug!( 726 + old_offset, 727 + new_offset, 728 + "sync_cursor_from_loro: updating cursor from Loro" 729 + ); 730 self.cursor.with_mut(|c| c.offset = new_offset); 731 Some(new_offset) 732 } 733 734 /// Get the Loro cursor for serialization. 735 + pub fn loro_cursor(&self) -> Option<Cursor> { 736 + self.buffer.loro_cursor() 737 } 738 739 /// Set the Loro cursor (used when restoring from storage). 740 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) { 741 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); 742 + self.buffer.set_loro_cursor(cursor); 743 // Sync cursor.offset from the restored Loro cursor 744 + if self.buffer.loro_cursor().is_some() { 745 self.sync_cursor_from_loro(); 746 } 747 } ··· 749 /// Export the document as a binary snapshot. 750 /// This captures all CRDT state including undo history. 751 pub fn export_snapshot(&self) -> Vec<u8> { 752 + self.buffer.export_snapshot() 753 } 754 755 /// Get the current state frontiers for change detection. 756 /// Frontiers represent the "version" of the document state. 757 pub fn state_frontiers(&self) -> Frontiers { 758 + self.buffer.doc().state_frontiers() 759 } 760 761 /// Get the current version vector. 762 pub fn version_vector(&self) -> VersionVector { 763 + self.buffer.version() 764 } 765 766 /// Get the last edit info for incremental rendering. 767 + /// This comes from the buffer's internal tracking. 768 pub fn last_edit(&self) -> Option<EditInfo> { 769 + self.buffer.last_edit() 770 + } 771 + 772 + /// Bump the content_changed signal to trigger re-renders. 773 + /// Call this after remote imports or other external content changes. 774 + pub fn notify_content_changed(&mut self) { 775 + self.content_changed.set(()); 776 } 777 778 // --- Collected refs accessors --- ··· 836 /// Check if there are unsynced changes since the last PDS sync. 837 pub fn has_unsynced_changes(&self) -> bool { 838 match &*self.last_synced_version.read() { 839 + Some(synced_vv) => self.buffer.version() != *synced_vv, 840 None => true, // Never synced, so there are changes 841 } 842 } ··· 846 /// After successful upload, call `mark_synced()` to update the sync marker. 847 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 848 let from_vv = self.last_synced_version.read().clone().unwrap_or_default(); 849 + self.buffer.export_updates_since(&from_vv) 850 } 851 852 /// Mark the current state as synced. 853 /// Call this after successfully uploading a diff to the PDS. 854 pub fn mark_synced(&mut self) { 855 + self.last_synced_version.set(Some(self.buffer.version())); 856 } 857 858 /// Import updates from a PDS diff blob. 859 /// Used when loading edit history from the PDS. 860 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { 861 + let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 862 + let vv_before = self.buffer.version(); 863 864 + self.buffer 865 + .import(updates) 866 + .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?; 867 868 + let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 869 + let vv_after = self.buffer.version(); 870 let vv_changed = vv_before != vv_after; 871 let len_changed = len_before != len_after; 872 ··· 880 881 // Only trigger re-render if something actually changed 882 if vv_changed { 883 + self.content_changed.set(()); 884 } 885 Ok(()) 886 } ··· 888 /// Export updates since the given version vector. 889 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync. 890 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> { 891 + self.buffer.export_updates_since(from_vv) 892 } 893 894 /// Set the sync state when loading from PDS. ··· 901 ) { 902 self.edit_root.set(Some(edit_root)); 903 self.last_diff.set(last_diff); 904 + self.last_synced_version.set(Some(self.buffer.version())); 905 } 906 907 + /// Create a new SignalEditorDocument from a binary snapshot. 908 /// Falls back to empty document if import fails. 909 /// 910 /// If `loro_cursor` is provided, it will be used to restore the cursor position. ··· 922 loro_cursor: Option<Cursor>, 923 fallback_offset: usize, 924 ) -> Self { 925 + // Create buffer from snapshot 926 + let buffer = if snapshot.is_empty() { 927 + LoroTextBuffer::new() 928 + } else { 929 + match LoroTextBuffer::from_snapshot(snapshot) { 930 + Ok(buf) => buf, 931 + Err(e) => { 932 + tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); 933 + LoroTextBuffer::new() 934 + } 935 } 936 + }; 937 938 + let doc = buffer.doc().clone(); 939 + 940 + // Get other containers from the doc 941 let title = doc.get_text("title"); 942 let path = doc.get_text("path"); 943 let created_at = doc.get_text("created_at"); 944 let tags = doc.get_list("tags"); 945 let embeds = doc.get_map("embeds"); 946 947 // Try to restore cursor from Loro cursor, fall back to offset 948 + let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); 949 let cursor_offset = if let Some(ref lc) = loro_cursor { 950 doc.get_cursor_pos(lc) 951 .map(|r| r.current.pos) ··· 959 affinity: Affinity::Before, 960 }; 961 962 + // Set up the Loro cursor 963 + let buffer = buffer; 964 + if let Some(lc) = loro_cursor { 965 + buffer.set_loro_cursor(Some(lc)); 966 + } else { 967 + buffer.sync_cursor(cursor_state.offset); 968 + } 969 970 Self { 971 + buffer, 972 title, 973 path, 974 created_at, ··· 980 last_diff: Signal::new(None), 981 last_synced_version: Signal::new(None), 982 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 983 cursor: Signal::new(cursor_state), 984 selection: Signal::new(None), 985 composition: Signal::new(None), 986 composition_ended_at: Signal::new(None), 987 + content_changed: Signal::new(()), 988 pending_snap: Signal::new(None), 989 collected_refs: Signal::new(Vec::new()), 990 } 991 } 992 993 + /// Create a SignalEditorDocument from pre-loaded state. 994 /// 995 /// Use this when loading from PDS/localStorage merge outside reactive context. 996 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it ··· 999 /// # Note 1000 /// This creates Dioxus Signals. Call from within a component using `use_hook`. 1001 pub fn from_loaded_state(state: LoadedDocState) -> Self { 1002 + // Create buffer from the loaded doc 1003 + let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content"); 1004 + let doc = buffer.doc().clone(); 1005 1006 + // Get other containers from the doc 1007 let title = doc.get_text("title"); 1008 let path = doc.get_text("path"); 1009 let created_at = doc.get_text("created_at"); 1010 let tags = doc.get_list("tags"); 1011 let embeds = doc.get_map("embeds"); 1012 1013 // Position cursor at end of content 1014 + let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); 1015 let cursor_state = CursorState { 1016 offset: cursor_offset, 1017 affinity: Affinity::Before, 1018 }; 1019 + 1020 + // Set up the Loro cursor 1021 + let buffer = buffer; 1022 + buffer.sync_cursor(cursor_offset); 1023 1024 Self { 1025 + buffer, 1026 title, 1027 path, 1028 created_at, ··· 1035 // Use the synced version from state (tracks the PDS version vector) 1036 last_synced_version: Signal::new(state.synced_version), 1037 last_seen_diffs: Signal::new(state.last_seen_diffs), 1038 cursor: Signal::new(cursor_state), 1039 selection: Signal::new(None), 1040 composition: Signal::new(None), 1041 composition_ended_at: Signal::new(None), 1042 + content_changed: Signal::new(()), 1043 pending_snap: Signal::new(None), 1044 collected_refs: Signal::new(Vec::new()), 1045 } 1046 } 1047 } 1048 1049 + impl PartialEq for SignalEditorDocument { 1050 fn eq(&self, _other: &Self) -> bool { 1051 + // SignalEditorDocument uses interior mutability, so we can't meaningfully compare. 1052 // Return false to ensure components re-render when passed as props. 1053 false 1054 } 1055 } 1056 1057 + impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument { 1058 fn export_snapshot(&self) -> Vec<u8> { 1059 self.export_snapshot() 1060 } ··· 1073 } 1074 1075 fn edit_root(&self) -> Option<StrongRef<'static>> { 1076 + SignalEditorDocument::edit_root(self) 1077 } 1078 1079 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 1080 + SignalEditorDocument::set_edit_root(self, root); 1081 } 1082 1083 fn last_diff(&self) -> Option<StrongRef<'static>> { 1084 + SignalEditorDocument::last_diff(self) 1085 } 1086 1087 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 1088 + SignalEditorDocument::set_last_diff(self, diff); 1089 } 1090 1091 fn mark_synced(&mut self) { 1092 + SignalEditorDocument::mark_synced(self); 1093 } 1094 1095 fn has_unsynced_changes(&self) -> bool { 1096 + SignalEditorDocument::has_unsynced_changes(self) 1097 + } 1098 + } 1099 + 1100 + impl EditorDocument for SignalEditorDocument { 1101 + type Buffer = LoroTextBuffer; 1102 + 1103 + fn buffer(&self) -> &Self::Buffer { 1104 + &self.buffer 1105 + } 1106 + 1107 + fn buffer_mut(&mut self) -> &mut Self::Buffer { 1108 + &mut self.buffer 1109 + } 1110 + 1111 + fn cursor(&self) -> CursorState { 1112 + *self.cursor.read() 1113 + } 1114 + 1115 + fn set_cursor(&mut self, cursor: CursorState) { 1116 + self.cursor.set(cursor); 1117 + } 1118 + 1119 + fn selection(&self) -> Option<Selection> { 1120 + self.selection.read().clone() 1121 + } 1122 + 1123 + fn set_selection(&mut self, selection: Option<Selection>) { 1124 + self.selection.set(selection); 1125 + } 1126 + 1127 + fn last_edit(&self) -> Option<EditInfo> { 1128 + self.buffer.last_edit() 1129 + } 1130 + 1131 + fn set_last_edit(&mut self, _edit: Option<EditInfo>) { 1132 + // Buffer tracks edit info internally. We use this hook to 1133 + // bump content_changed for reactive re-rendering. 1134 + self.content_changed.set(()); 1135 + } 1136 + 1137 + fn composition(&self) -> Option<CompositionState> { 1138 + self.composition.read().clone() 1139 + } 1140 + 1141 + fn set_composition(&mut self, composition: Option<CompositionState>) { 1142 + self.composition.set(composition); 1143 } 1144 }
+10 -10
crates/weaver-app/src/components/editor/dom_sync.rs
··· 8 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 9 use super::cursor::restore_cursor_position; 10 #[allow(unused_imports)] 11 - use super::document::{EditorDocument, Selection}; 12 use super::paragraph::ParagraphRender; 13 #[allow(unused_imports)] 14 use dioxus::prelude::*; ··· 25 /// Pass `SnapDirection::Backward` for left/up arrow keys, `SnapDirection::Forward` for right/down. 26 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 27 pub fn sync_cursor_from_dom( 28 - doc: &mut EditorDocument, 29 editor_id: &str, 30 paragraphs: &[ParagraphRender], 31 ) { ··· 37 /// Use this when handling arrow keys to ensure cursor snaps in the expected direction. 38 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 39 pub fn sync_cursor_from_dom_with_direction( 40 - doc: &mut EditorDocument, 41 editor_id: &str, 42 paragraphs: &[ParagraphRender], 43 direction_hint: Option<SnapDirection>, ··· 133 134 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 135 pub fn sync_cursor_from_dom( 136 - _document: &mut EditorDocument, 137 _editor_id: &str, 138 _paragraphs: &[ParagraphRender], 139 ) { ··· 141 142 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 143 pub fn sync_cursor_from_dom_with_direction( 144 - _document: &mut EditorDocument, 145 _editor_id: &str, 146 _paragraphs: &[ParagraphRender], 147 _direction_hint: Option<SnapDirection>, ··· 332 if is_cursor_para { 333 // Restore cursor synchronously - don't wait for rAF 334 // This prevents race conditions with fast typing 335 - if let Err(e) = restore_cursor_position( 336 - cursor_offset, 337 - &new_para.offset_map, 338 - None, 339 - ) { 340 tracing::warn!("Synchronous cursor restore failed: {:?}", e); 341 } 342 cursor_para_updated = true;
··· 8 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 9 use super::cursor::restore_cursor_position; 10 #[allow(unused_imports)] 11 + use super::document::Selection; 12 + #[allow(unused_imports)] 13 + use super::document::SignalEditorDocument; 14 use super::paragraph::ParagraphRender; 15 #[allow(unused_imports)] 16 use dioxus::prelude::*; ··· 27 /// Pass `SnapDirection::Backward` for left/up arrow keys, `SnapDirection::Forward` for right/down. 28 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 29 pub fn sync_cursor_from_dom( 30 + doc: &mut SignalEditorDocument, 31 editor_id: &str, 32 paragraphs: &[ParagraphRender], 33 ) { ··· 39 /// Use this when handling arrow keys to ensure cursor snaps in the expected direction. 40 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 41 pub fn sync_cursor_from_dom_with_direction( 42 + doc: &mut SignalEditorDocument, 43 editor_id: &str, 44 paragraphs: &[ParagraphRender], 45 direction_hint: Option<SnapDirection>, ··· 135 136 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 137 pub fn sync_cursor_from_dom( 138 + _document: &mut SignalEditorDocument, 139 _editor_id: &str, 140 _paragraphs: &[ParagraphRender], 141 ) { ··· 143 144 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 145 pub fn sync_cursor_from_dom_with_direction( 146 + _document: &mut SignalEditorDocument, 147 _editor_id: &str, 148 _paragraphs: &[ParagraphRender], 149 _direction_hint: Option<SnapDirection>, ··· 334 if is_cursor_para { 335 // Restore cursor synchronously - don't wait for rAF 336 // This prevents race conditions with fast typing 337 + if let Err(e) = 338 + restore_cursor_position(cursor_offset, &new_para.offset_map, None) 339 + { 340 tracing::warn!("Synchronous cursor restore failed: {:?}", e); 341 } 342 cursor_para_updated = true;
+3 -2
crates/weaver-app/src/components/editor/formatting.rs
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 - use super::document::EditorDocument; 4 #[allow(unused_imports)] 5 use super::input::{ListContext, detect_list_context, find_line_end}; 6 use dioxus::prelude::*; ··· 47 /// Apply formatting to document. 48 /// 49 /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 50 - pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) { 51 let cursor_offset = doc.cursor.read().offset; 52 let (start, end) = if let Some(sel) = *doc.selection.read() { 53 // Use selection
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 + use crate::components::editor::SignalEditorDocument; 4 + 5 #[allow(unused_imports)] 6 use super::input::{ListContext, detect_list_context, find_line_end}; 7 use dioxus::prelude::*; ··· 48 /// Apply formatting to document. 49 /// 50 /// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap. 51 + pub fn apply_formatting(doc: &mut SignalEditorDocument, action: FormatAction) { 52 let cursor_offset = doc.cursor.read().offset; 53 let (start, end) = if let Some(sel) = *doc.selection.read() { 54 // Use selection
+6 -11
crates/weaver-app/src/components/editor/input.rs
··· 4 5 use dioxus::prelude::*; 6 7 - use super::document::EditorDocument; 8 use super::formatting::{self, FormatAction}; 9 use weaver_editor_core::SnapDirection; 10 ··· 52 53 /// Handle keyboard events and update document state. 54 #[allow(unused)] 55 - pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 56 use dioxus::prelude::keyboard_types::Key; 57 58 let key = evt.key(); ··· 326 327 // Sync Loro cursor when edits affect paragraph boundaries 328 // This ensures cursor position is tracked correctly through structural changes 329 - if doc 330 - .last_edit 331 - .read() 332 - .as_ref() 333 - .is_some_and(|e| e.contains_newline) 334 - { 335 doc.sync_loro_cursor(); 336 } 337 } 338 339 /// Handle paste events and insert text at cursor. 340 - pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 341 evt.prevent_default(); 342 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 343 let _ = doc; ··· 379 } 380 381 /// Handle cut events - extract text, write to clipboard, then delete. 382 - pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 383 evt.prevent_default(); 384 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 385 let _ = doc; ··· 440 } 441 442 /// Handle copy events - extract text, clean it up, write to clipboard. 443 - pub fn handle_copy(evt: Event<ClipboardData>, doc: &EditorDocument) { 444 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 445 { 446 use dioxus::web::WebEventExt;
··· 4 5 use dioxus::prelude::*; 6 7 + use super::document::SignalEditorDocument; 8 use super::formatting::{self, FormatAction}; 9 use weaver_editor_core::SnapDirection; 10 ··· 52 53 /// Handle keyboard events and update document state. 54 #[allow(unused)] 55 + pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut SignalEditorDocument) { 56 use dioxus::prelude::keyboard_types::Key; 57 58 let key = evt.key(); ··· 326 327 // Sync Loro cursor when edits affect paragraph boundaries 328 // This ensures cursor position is tracked correctly through structural changes 329 + if doc.last_edit().is_some_and(|e| e.contains_newline) { 330 doc.sync_loro_cursor(); 331 } 332 } 333 334 /// Handle paste events and insert text at cursor. 335 + pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) { 336 evt.prevent_default(); 337 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 338 let _ = doc; ··· 374 } 375 376 /// Handle cut events - extract text, write to clipboard, then delete. 377 + pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) { 378 evt.prevent_default(); 379 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 380 let _ = doc; ··· 435 } 436 437 /// Handle copy events - extract text, clean it up, write to clipboard. 438 + pub fn handle_copy(evt: Event<ClipboardData>, doc: &SignalEditorDocument) { 439 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 440 { 441 use dioxus::web::WebEventExt;
+1 -1
crates/weaver-app/src/components/editor/mod.rs
··· 44 // Document types 45 #[allow(unused_imports)] 46 pub use document::{ 47 - Affinity, CompositionState, CursorState, EditorDocument, LoadedDocState, Selection, 48 }; 49 50 // Formatting
··· 44 // Document types 45 #[allow(unused_imports)] 46 pub use document::{ 47 + Affinity, CompositionState, CursorState, LoadedDocState, Selection, SignalEditorDocument, 48 }; 49 50 // Formatting
+3 -3
crates/weaver-app/src/components/editor/publish.rs
··· 55 } 56 57 use crate::auth::AuthState; 58 use crate::fetch::Fetcher; 59 60 - use super::document::EditorDocument; 61 use super::storage::{delete_draft, save_to_storage}; 62 63 /// Result of a publish operation. ··· 161 /// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. 162 pub async fn publish_entry( 163 fetcher: &Fetcher, 164 - doc: &mut EditorDocument, 165 notebook_title: Option<&str>, 166 draft_key: &str, 167 ) -> Result<PublishResult, WeaverError> { ··· 484 #[derive(Props, Clone, PartialEq)] 485 pub struct PublishButtonProps { 486 /// The editor document 487 - pub document: EditorDocument, 488 /// Storage key for the draft 489 pub draft_key: String, 490 /// Pre-selected notebook (from URL param)
··· 55 } 56 57 use crate::auth::AuthState; 58 + use crate::components::editor::SignalEditorDocument; 59 use crate::fetch::Fetcher; 60 61 use super::storage::{delete_draft, save_to_storage}; 62 63 /// Result of a publish operation. ··· 161 /// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. 162 pub async fn publish_entry( 163 fetcher: &Fetcher, 164 + doc: &mut SignalEditorDocument, 165 notebook_title: Option<&str>, 166 draft_key: &str, 167 ) -> Result<PublishResult, WeaverError> { ··· 484 #[derive(Props, Clone, PartialEq)] 485 pub struct PublishButtonProps { 486 /// The editor document 487 + pub document: SignalEditorDocument, 488 /// Storage key for the draft 489 pub draft_key: String, 490 /// Pre-selected notebook (from URL param)
+6 -65
crates/weaver-app/src/components/editor/render.rs
··· 3 //! Phase 2: Paragraph-level incremental rendering with formatting characters visible. 4 //! 5 //! This module provides a thin wrapper around the core rendering logic, 6 - //! adapting it to LoroText and adding app-specific features like timing. 7 8 use super::paragraph::ParagraphRender; 9 use super::writer::embed::EditorImageResolver; 10 - use loro::LoroText; 11 use weaver_common::{EntryIndex, ResolvedContent}; 12 - use weaver_editor_core::{EditInfo, SmolStr, TextBuffer}; 13 14 // Re-export core types. 15 pub use weaver_editor_core::RenderCache; 16 17 - /// Adapter to make LoroText implement TextBuffer-like interface for core. 18 - /// temporary until weaver-editor-crdt crate complete 19 - struct LoroTextAdapter<'a>(&'a LoroText); 20 - 21 - impl TextBuffer for LoroTextAdapter<'_> { 22 - fn len_chars(&self) -> usize { 23 - self.0.len_unicode() 24 - } 25 - 26 - fn len_bytes(&self) -> usize { 27 - self.0.len_utf8() 28 - } 29 - 30 - fn slice(&self, range: std::ops::Range<usize>) -> Option<SmolStr> { 31 - self.0 32 - .slice(range.start, range.end) 33 - .ok() 34 - .map(|s| SmolStr::new(&s)) 35 - } 36 - 37 - fn char_at(&self, offset: usize) -> Option<char> { 38 - self.0 39 - .slice(offset, offset + 1) 40 - .ok() 41 - .and_then(|s| s.chars().next()) 42 - } 43 - 44 - fn char_to_byte(&self, char_offset: usize) -> usize { 45 - // LoroText doesn't expose this directly, so we compute it. 46 - let slice = self.0.slice(0, char_offset).unwrap_or_default(); 47 - slice.len() 48 - } 49 - 50 - fn byte_to_char(&self, byte_offset: usize) -> usize { 51 - // LoroText doesn't expose this directly, so we compute it. 52 - let full = self.0.to_string(); 53 - full[..byte_offset.min(full.len())].chars().count() 54 - } 55 - 56 - fn insert(&mut self, _offset: usize, _text: &str) { 57 - // Read-only adapter - this should never be called during rendering. 58 - panic!("LoroTextAdapter is read-only"); 59 - } 60 - 61 - fn delete(&mut self, _range: std::ops::Range<usize>) { 62 - // Read-only adapter - this should never be called during rendering. 63 - panic!("LoroTextAdapter is read-only"); 64 - } 65 - 66 - fn to_string(&self) -> String { 67 - self.0.to_string() 68 - } 69 - } 70 - 71 /// Render markdown with incremental caching. 72 /// 73 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 74 /// This is a thin wrapper around the core rendering logic that adds timing. 75 /// 76 /// # Parameters 77 - /// - `text`: The LoroText to render 78 /// - `cache`: Optional previous render cache 79 /// - `cursor_offset`: Current cursor position 80 /// - `edit`: Edit info for stable ID assignment ··· 84 /// 85 /// # Returns 86 /// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render 87 - pub fn render_paragraphs_incremental( 88 - text: &LoroText, 89 cache: Option<&RenderCache>, 90 cursor_offset: usize, 91 edit: Option<&EditInfo>, ··· 99 ) { 100 let fn_start = crate::perf::now(); 101 102 - // Create adapter for LoroText to use with core rendering. 103 - let adapter = LoroTextAdapter(text); 104 - 105 - // Call the core rendering function. 106 let result = weaver_editor_core::render_paragraphs_incremental( 107 - &adapter, 108 cache, 109 cursor_offset, 110 edit,
··· 3 //! Phase 2: Paragraph-level incremental rendering with formatting characters visible. 4 //! 5 //! This module provides a thin wrapper around the core rendering logic, 6 + //! adding app-specific features like timing instrumentation. 7 8 use super::paragraph::ParagraphRender; 9 use super::writer::embed::EditorImageResolver; 10 use weaver_common::{EntryIndex, ResolvedContent}; 11 + use weaver_editor_core::{EditInfo, TextBuffer}; 12 13 // Re-export core types. 14 pub use weaver_editor_core::RenderCache; 15 16 /// Render markdown with incremental caching. 17 /// 18 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 19 /// This is a thin wrapper around the core rendering logic that adds timing. 20 /// 21 /// # Parameters 22 + /// - `text`: Any TextBuffer implementation (LoroTextBuffer, EditorRope, etc.) 23 /// - `cache`: Optional previous render cache 24 /// - `cursor_offset`: Current cursor position 25 /// - `edit`: Edit info for stable ID assignment ··· 29 /// 30 /// # Returns 31 /// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render 32 + pub fn render_paragraphs_incremental<T: TextBuffer>( 33 + text: &T, 34 cache: Option<&RenderCache>, 35 cursor_offset: usize, 36 edit: Option<&EditInfo>, ··· 44 ) { 45 let fn_start = crate::perf::now(); 46 47 let result = weaver_editor_core::render_paragraphs_incremental( 48 + text, 49 cache, 50 cursor_offset, 51 edit,
+10 -10
crates/weaver-app/src/components/editor/storage.rs
··· 29 use serde::{Deserialize, Serialize}; 30 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 31 32 - use super::document::EditorDocument; 33 34 /// Prefix for all draft storage keys. 35 pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; ··· 89 /// Save editor state to LocalStorage (WASM only). 90 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 91 pub fn save_to_storage( 92 - doc: &EditorDocument, 93 key: &str, 94 ) -> Result<(), gloo_storage::errors::StorageError> { 95 let export_start = crate::perf::now(); ··· 108 content: doc.content(), 109 title: doc.title().into(), 110 snapshot: snapshot_b64, 111 - cursor: doc.loro_cursor().cloned(), 112 cursor_offset: doc.cursor.read().offset, 113 editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), 114 editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), ··· 132 133 /// Load editor state from LocalStorage (WASM only). 134 /// 135 - /// Returns an EditorDocument restored from CRDT snapshot if available, 136 /// otherwise falls back to just the text content. 137 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 138 - pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 139 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 140 141 // Parse entry_ref from the snapshot (requires both URI and CID) ··· 152 // Try to restore from CRDT snapshot first 153 if let Some(ref snapshot_b64) = snapshot.snapshot { 154 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 155 - let mut doc = EditorDocument::from_snapshot( 156 &snapshot_bytes, 157 snapshot.cursor.clone(), 158 snapshot.cursor_offset, ··· 170 } 171 172 // Fallback: create new doc from text content 173 - let mut doc = EditorDocument::new(snapshot.content); 174 doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); 175 doc.sync_loro_cursor(); 176 doc.set_entry_ref(entry_ref); ··· 192 193 /// Load snapshot data from LocalStorage (WASM only). 194 /// 195 - /// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe 196 /// to call outside of reactive context. Use with `load_and_merge_document`. 197 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 198 pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { ··· 344 345 // Stub implementations for non-WASM targets 346 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 347 - pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> { 348 Ok(()) 349 } 350 351 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 352 - pub fn load_from_storage(_key: &str) -> Option<EditorDocument> { 353 None 354 } 355
··· 29 use serde::{Deserialize, Serialize}; 30 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 31 32 + use super::document::SignalEditorDocument; 33 34 /// Prefix for all draft storage keys. 35 pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; ··· 89 /// Save editor state to LocalStorage (WASM only). 90 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 91 pub fn save_to_storage( 92 + doc: &SignalEditorDocument, 93 key: &str, 94 ) -> Result<(), gloo_storage::errors::StorageError> { 95 let export_start = crate::perf::now(); ··· 108 content: doc.content(), 109 title: doc.title().into(), 110 snapshot: snapshot_b64, 111 + cursor: doc.loro_cursor(), 112 cursor_offset: doc.cursor.read().offset, 113 editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), 114 editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), ··· 132 133 /// Load editor state from LocalStorage (WASM only). 134 /// 135 + /// Returns an SignalEditorDocument restored from CRDT snapshot if available, 136 /// otherwise falls back to just the text content. 137 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 138 + pub fn load_from_storage(key: &str) -> Option<SignalEditorDocument> { 139 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 140 141 // Parse entry_ref from the snapshot (requires both URI and CID) ··· 152 // Try to restore from CRDT snapshot first 153 if let Some(ref snapshot_b64) = snapshot.snapshot { 154 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 155 + let mut doc = SignalEditorDocument::from_snapshot( 156 &snapshot_bytes, 157 snapshot.cursor.clone(), 158 snapshot.cursor_offset, ··· 170 } 171 172 // Fallback: create new doc from text content 173 + let mut doc = SignalEditorDocument::new(snapshot.content); 174 doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); 175 doc.sync_loro_cursor(); 176 doc.set_entry_ref(entry_ref); ··· 192 193 /// Load snapshot data from LocalStorage (WASM only). 194 /// 195 + /// Unlike `load_from_storage`, this doesn't create an SignalEditorDocument and is safe 196 /// to call outside of reactive context. Use with `load_and_merge_document`. 197 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 198 pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { ··· 344 345 // Stub implementations for non-WASM targets 346 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 347 + pub fn save_to_storage(_doc: &SignalEditorDocument, _key: &str) -> Result<(), String> { 348 Ok(()) 349 } 350 351 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 352 + pub fn load_from_storage(_key: &str) -> Option<SignalEditorDocument> { 353 None 354 } 355
+8 -8
crates/weaver-app/src/components/editor/sync.rs
··· 9 10 use std::collections::HashMap; 11 12 - use super::document::{EditorDocument, LoadedDocState}; 13 use crate::fetch::Fetcher; 14 use jacquard::IntoStatic; 15 use jacquard::prelude::*; ··· 214 /// Wraps the crdt crate's create_edit_root with Fetcher support. 215 pub async fn create_edit_root( 216 fetcher: &Fetcher, 217 - doc: &EditorDocument, 218 draft_key: &str, 219 entry_uri: Option<&AtUri<'_>>, 220 entry_cid: Option<&Cid<'_>>, ··· 235 /// Wraps the crdt crate's create_diff with Fetcher support. 236 pub async fn create_diff( 237 fetcher: &Fetcher, 238 - doc: &EditorDocument, 239 root_uri: &AtUri<'_>, 240 root_cid: &Cid<'_>, 241 prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>, ··· 264 /// Updates the document's sync state on success. 265 pub async fn sync_to_pds( 266 fetcher: &Fetcher, 267 - doc: &mut EditorDocument, 268 draft_key: &str, 269 ) -> Result<SyncResult, WeaverError> { 270 let fn_start = crate::perf::now(); ··· 415 /// 416 /// Loads from localStorage and PDS (if available), then merges both using Loro's 417 /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an 418 - /// EditorDocument inside a reactive context using `use_hook`. 419 /// 420 /// For unpublished drafts, attempts to discover edit state via Constellation 421 /// using the synthetic draft URI. ··· 603 #[derive(Props, Clone, PartialEq)] 604 pub struct SyncStatusProps { 605 /// The editor document to sync 606 - pub document: EditorDocument, 607 /// Draft key for this document 608 pub draft_key: String, 609 /// Auto-sync interval in milliseconds (0 to disable, default disabled) ··· 707 // Note: We use peek() to avoid creating a reactive dependency on sync_state 708 let doc_for_effect = doc.clone(); 709 use_effect(move || { 710 - // Check for unsynced changes (reads last_edit signal for reactivity) 711 - let _edit = doc_for_effect.last_edit(); 712 713 // Use peek to avoid reactive loop 714 let current_state = *sync_state.peek();
··· 9 10 use std::collections::HashMap; 11 12 + use super::document::{LoadedDocState, SignalEditorDocument}; 13 use crate::fetch::Fetcher; 14 use jacquard::IntoStatic; 15 use jacquard::prelude::*; ··· 214 /// Wraps the crdt crate's create_edit_root with Fetcher support. 215 pub async fn create_edit_root( 216 fetcher: &Fetcher, 217 + doc: &SignalEditorDocument, 218 draft_key: &str, 219 entry_uri: Option<&AtUri<'_>>, 220 entry_cid: Option<&Cid<'_>>, ··· 235 /// Wraps the crdt crate's create_diff with Fetcher support. 236 pub async fn create_diff( 237 fetcher: &Fetcher, 238 + doc: &SignalEditorDocument, 239 root_uri: &AtUri<'_>, 240 root_cid: &Cid<'_>, 241 prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>, ··· 264 /// Updates the document's sync state on success. 265 pub async fn sync_to_pds( 266 fetcher: &Fetcher, 267 + doc: &mut SignalEditorDocument, 268 draft_key: &str, 269 ) -> Result<SyncResult, WeaverError> { 270 let fn_start = crate::perf::now(); ··· 415 /// 416 /// Loads from localStorage and PDS (if available), then merges both using Loro's 417 /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an 418 + /// SignalEditorDocument inside a reactive context using `use_hook`. 419 /// 420 /// For unpublished drafts, attempts to discover edit state via Constellation 421 /// using the synthetic draft URI. ··· 603 #[derive(Props, Clone, PartialEq)] 604 pub struct SyncStatusProps { 605 /// The editor document to sync 606 + pub document: SignalEditorDocument, 607 /// Draft key for this document 608 pub draft_key: String, 609 /// Auto-sync interval in milliseconds (0 to disable, default disabled) ··· 707 // Note: We use peek() to avoid creating a reactive dependency on sync_state 708 let doc_for_effect = doc.clone(); 709 use_effect(move || { 710 + // Read content_changed to create reactive dependency on document changes 711 + let _ = doc_for_effect.content_changed.read(); 712 713 // Use peek to avoid reactive loop 714 let current_state = *sync_state.peek();
+20 -26
crates/weaver-app/src/components/editor/tests.rs
··· 1 //! Snapshot tests for the markdown editor rendering pipeline. 2 3 - use weaver_editor_core::{OffsetMapping, find_mapping_for_char}; 4 use super::paragraph::ParagraphRender; 5 use super::render::render_paragraphs_incremental; 6 - use loro::LoroDoc; 7 use serde::Serialize; 8 use weaver_common::ResolvedContent; 9 10 /// Serializable version of ParagraphRender for snapshot testing. 11 #[derive(Debug, Serialize)] ··· 55 56 /// Helper: render markdown and convert to serializable test output. 57 fn render_test(input: &str) -> Vec<TestParagraph> { 58 - let doc = LoroDoc::new(); 59 - let text = doc.get_text("content"); 60 - text.insert(0, input).unwrap(); 61 let (paragraphs, _cache, _refs) = 62 - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); 63 paragraphs.iter().map(TestParagraph::from).collect() 64 } 65 ··· 640 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 641 // This tests that the syntax spans are correctly updated on content change. 642 use super::render::render_paragraphs_incremental; 643 - use loro::LoroDoc; 644 645 - let doc = LoroDoc::new(); 646 - let text = doc.get_text("content"); 647 648 // Initial state: "#" is a valid empty heading 649 - text.insert(0, "#").unwrap(); 650 let (paras1, cache1, _refs1) = 651 - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); 652 653 eprintln!("State 1 ('#'): {}", paras1[0].html); 654 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 658 ); 659 660 // Transition: add "t" to make "#t" - no longer a heading 661 - text.insert(1, "t").unwrap(); 662 let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 663 - &text, 664 Some(&cache1), 665 0, 666 None, ··· 773 // cursor snaps to adjacent paragraphs for standard breaks. 774 // Only EXTRA whitespace beyond \n\n gets gap elements. 775 let input = "Hello\n\nWorld"; 776 - let doc = LoroDoc::new(); 777 - let text = doc.get_text("content"); 778 - text.insert(0, input).unwrap(); 779 let (paragraphs, _cache, _refs) = 780 - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); 781 782 // With standard \n\n break, we expect 2 paragraphs (no gap element) 783 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 803 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 804 // Plain paragraphs don't consume trailing newlines like headings do 805 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 806 - let doc = LoroDoc::new(); 807 - let text = doc.get_text("content"); 808 - text.insert(0, input).unwrap(); 809 let (paragraphs, _cache, _refs) = 810 - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); 811 812 // With extra newlines, we expect 3 elements: para, gap, para 813 assert_eq!( ··· 903 fn test_incremental_cache_reuse() { 904 // Verify cache is populated and can be reused 905 let input = "First para\n\nSecond para"; 906 - let doc = LoroDoc::new(); 907 - let text = doc.get_text("content"); 908 - text.insert(0, input).unwrap(); 909 910 let (paras1, cache1, _refs1) = 911 - render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default()); 912 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 913 914 // Second render with same content should reuse cache 915 let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 916 - &text, 917 Some(&cache1), 918 0, 919 None,
··· 1 //! Snapshot tests for the markdown editor rendering pipeline. 2 3 use super::paragraph::ParagraphRender; 4 use super::render::render_paragraphs_incremental; 5 use serde::Serialize; 6 use weaver_common::ResolvedContent; 7 + use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char}; 8 + use weaver_editor_crdt::LoroTextBuffer; 9 10 /// Serializable version of ParagraphRender for snapshot testing. 11 #[derive(Debug, Serialize)] ··· 55 56 /// Helper: render markdown and convert to serializable test output. 57 fn render_test(input: &str) -> Vec<TestParagraph> { 58 + let mut buffer = LoroTextBuffer::new(); 59 + buffer.insert(0, input); 60 let (paragraphs, _cache, _refs) = 61 + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 62 paragraphs.iter().map(TestParagraph::from).collect() 63 } 64 ··· 639 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 640 // This tests that the syntax spans are correctly updated on content change. 641 use super::render::render_paragraphs_incremental; 642 643 + let mut buffer = LoroTextBuffer::new(); 644 645 // Initial state: "#" is a valid empty heading 646 + buffer.insert(0, "#"); 647 let (paras1, cache1, _refs1) = 648 + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 649 650 eprintln!("State 1 ('#'): {}", paras1[0].html); 651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading"); ··· 655 ); 656 657 // Transition: add "t" to make "#t" - no longer a heading 658 + buffer.insert(1, "t"); 659 let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 660 + &buffer, 661 Some(&cache1), 662 0, 663 None, ··· 770 // cursor snaps to adjacent paragraphs for standard breaks. 771 // Only EXTRA whitespace beyond \n\n gets gap elements. 772 let input = "Hello\n\nWorld"; 773 + let mut buffer = LoroTextBuffer::new(); 774 + buffer.insert(0, input); 775 let (paragraphs, _cache, _refs) = 776 + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 777 778 // With standard \n\n break, we expect 2 paragraphs (no gap element) 779 // Paragraph ranges include some trailing whitespace from markdown parsing ··· 799 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements 800 // Plain paragraphs don't consume trailing newlines like headings do 801 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2 802 + let mut buffer = LoroTextBuffer::new(); 803 + buffer.insert(0, input); 804 let (paragraphs, _cache, _refs) = 805 + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 806 807 // With extra newlines, we expect 3 elements: para, gap, para 808 assert_eq!( ··· 898 fn test_incremental_cache_reuse() { 899 // Verify cache is populated and can be reused 900 let input = "First para\n\nSecond para"; 901 + let mut buffer = LoroTextBuffer::new(); 902 + buffer.insert(0, input); 903 904 let (paras1, cache1, _refs1) = 905 + render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default()); 906 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated"); 907 908 // Second render with same content should reuse cache 909 let (paras2, _cache2, _refs2) = render_paragraphs_incremental( 910 + &buffer, 911 Some(&cache1), 912 0, 913 None,
+25
crates/weaver-editor-crdt/src/buffer.rs
··· 227 }); 228 } 229 230 fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> { 231 if char_range.end > self.content.len_unicode() { 232 return None;
··· 227 }); 228 } 229 230 + fn replace(&mut self, char_range: Range<usize>, text: &str) { 231 + let in_block_syntax_zone = self.is_in_block_syntax_zone(char_range.start); 232 + let delete_has_newline = self 233 + .slice(char_range.clone()) 234 + .map(|s| s.contains('\n')) 235 + .unwrap_or(false); 236 + let deleted_len = char_range.len(); 237 + let inserted_len = text.chars().count(); 238 + 239 + // Use Loro's atomic splice operation 240 + self.content 241 + .splice(char_range.start, deleted_len, text) 242 + .ok(); 243 + 244 + self.inner.borrow_mut().last_edit = Some(EditInfo { 245 + edit_char_pos: char_range.start, 246 + inserted_len, 247 + deleted_len, 248 + contains_newline: delete_has_newline || text.contains('\n'), 249 + in_block_syntax_zone, 250 + doc_len_after: self.content.len_unicode(), 251 + timestamp: Instant::now(), 252 + }); 253 + } 254 + 255 fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> { 256 if char_range.end > self.content.len_unicode() { 257 return None;
+77
docs/graph-data.json
··· 1781 "created_at": "2026-01-06T15:54:32.396727943-05:00", 1782 "updated_at": "2026-01-06T15:54:32.396727943-05:00", 1783 "metadata_json": "{\"confidence\":95}" 1784 } 1785 ], 1786 "edges": [ ··· 3620 "weight": 1.0, 3621 "rationale": "Refactoring complete with tests", 3622 "created_at": "2026-01-06T15:54:32.412922730-05:00" 3623 } 3624 ] 3625 }
··· 1781 "created_at": "2026-01-06T15:54:32.396727943-05:00", 1782 "updated_at": "2026-01-06T15:54:32.396727943-05:00", 1783 "metadata_json": "{\"confidence\":95}" 1784 + }, 1785 + { 1786 + "id": 164, 1787 + "change_id": "9f555968-9c6c-4db6-bda1-6223c92acbec", 1788 + "node_type": "goal", 1789 + "title": "Refactor EditorDocument to wrap LoroTextBuffer and impl TextBuffer+UndoManager", 1790 + "description": null, 1791 + "status": "pending", 1792 + "created_at": "2026-01-06T16:00:43.381698744-05:00", 1793 + "updated_at": "2026-01-06T16:00:43.381698744-05:00", 1794 + "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.\"}" 1795 + }, 1796 + { 1797 + "id": 165, 1798 + "change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", 1799 + "node_type": "action", 1800 + "title": "Analyze EditorDocument refactor scope", 1801 + "description": null, 1802 + "status": "pending", 1803 + "created_at": "2026-01-06T16:01:15.257884614-05:00", 1804 + "updated_at": "2026-01-06T16:01:15.257884614-05:00", 1805 + "metadata_json": "{\"confidence\":95}" 1806 + }, 1807 + { 1808 + "id": 166, 1809 + "change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", 1810 + "node_type": "action", 1811 + "title": "Rename EditorDocument to SignalEditorDocument and wrap LoroTextBuffer", 1812 + "description": null, 1813 + "status": "pending", 1814 + "created_at": "2026-01-06T16:01:49.255692341-05:00", 1815 + "updated_at": "2026-01-06T16:01:49.255692341-05:00", 1816 + "metadata_json": "{\"confidence\":90}" 1817 + }, 1818 + { 1819 + "id": 167, 1820 + "change_id": "8921c6ea-9bf0-4c2a-8b1e-02fd290dde68", 1821 + "node_type": "outcome", 1822 + "title": "SignalEditorDocument refactor compiles - wraps LoroTextBuffer, uses content_changed signal", 1823 + "description": null, 1824 + "status": "pending", 1825 + "created_at": "2026-01-06T16:13:31.725544621-05:00", 1826 + "updated_at": "2026-01-06T16:13:31.725544621-05:00", 1827 + "metadata_json": "{\"confidence\":95}" 1828 } 1829 ], 1830 "edges": [ ··· 3664 "weight": 1.0, 3665 "rationale": "Refactoring complete with tests", 3666 "created_at": "2026-01-06T15:54:32.412922730-05:00" 3667 + }, 3668 + { 3669 + "id": 169, 3670 + "from_node_id": 164, 3671 + "to_node_id": 165, 3672 + "from_change_id": "9f555968-9c6c-4db6-bda1-6223c92acbec", 3673 + "to_change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", 3674 + "edge_type": "leads_to", 3675 + "weight": 1.0, 3676 + "rationale": "Analysis step of refactor goal", 3677 + "created_at": "2026-01-06T16:01:15.381825313-05:00" 3678 + }, 3679 + { 3680 + "id": 170, 3681 + "from_node_id": 165, 3682 + "to_node_id": 166, 3683 + "from_change_id": "aded9525-4d0b-426a-a86a-39e28635dbbf", 3684 + "to_change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", 3685 + "edge_type": "leads_to", 3686 + "weight": 1.0, 3687 + "rationale": "Analysis complete, starting implementation", 3688 + "created_at": "2026-01-06T16:01:53.140032559-05:00" 3689 + }, 3690 + { 3691 + "id": 171, 3692 + "from_node_id": 166, 3693 + "to_node_id": 167, 3694 + "from_change_id": "8d2c762d-490e-4783-bb9e-ebcfa32f7be0", 3695 + "to_change_id": "8921c6ea-9bf0-4c2a-8b1e-02fd290dde68", 3696 + "edge_type": "leads_to", 3697 + "weight": 1.0, 3698 + "rationale": "Refactor implementation complete", 3699 + "created_at": "2026-01-06T16:13:31.873150501-05:00" 3700 } 3701 ] 3702 }