trait refactor

Orual 25329504 2aa819db

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