at main 1179 lines 41 kB view raw
1//! Core data structures for the markdown editor. 2//! 3//! Uses Loro CRDT for text storage with built-in undo/redo support. 4//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. 5//! 6//! # Reactive Architecture 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 13use dioxus::prelude::*; 14use loro::{ 15 Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector, 16 cursor::Cursor, 17}; 18 19use jacquard::IntoStatic; 20use jacquard::from_json_value; 21use jacquard::smol_str::SmolStr; 22use jacquard::types::string::AtUri; 23use weaver_api::com_atproto::repo::strong_ref::StrongRef; 24use weaver_api::sh_weaver::embed::images::Image; 25use weaver_api::sh_weaver::embed::records::RecordEmbed; 26use weaver_api::sh_weaver::notebook::entry::Entry; 27use weaver_editor_core::EditorDocument; 28use weaver_editor_core::TextBuffer; 29use weaver_editor_core::UndoManager; 30pub use weaver_editor_core::{ 31 Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, 32}; 33use weaver_editor_crdt::LoroTextBuffer; 34 35/// Single source of truth for editor state. 36/// 37/// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position, 38/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 39/// schema with CRDT containers for each field. 40/// 41/// # Reactive Architecture 42/// 43/// The document itself is NOT wrapped in a Signal. Instead, individual fields 44/// that need reactivity are wrapped in Signals: 45/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates 46/// - `content_changed` - bumped to trigger paragraph re-renders when content changes 47/// 48/// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`. 49/// 50/// # Cloning 51/// 52/// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed, 53/// and Signals are Copy. Closures can capture clones without overhead. 54#[derive(Clone)] 55pub struct SignalEditorDocument { 56 /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking. 57 /// Access the underlying LoroDoc via `buffer.doc()`. 58 buffer: LoroTextBuffer, 59 60 // --- Entry schema containers (Loro handles interior mutability) --- 61 // These are obtained from buffer.doc() but cached for convenience. 62 /// Entry title (maps to entry.title) 63 title: LoroText, 64 65 /// URL path/slug (maps to entry.path) 66 path: LoroText, 67 68 /// ISO datetime string (maps to entry.createdAt) 69 created_at: LoroText, 70 71 /// Tags list (maps to entry.tags) 72 tags: LoroList, 73 74 /// Embeds container (maps to entry.embeds) 75 /// Contains nested containers: images (LoroList), externals (LoroList), etc. 76 embeds: LoroMap, 77 78 // --- Entry tracking (reactive) --- 79 /// StrongRef to the entry if editing an existing record. 80 /// None for new entries that haven't been published yet. 81 /// Signal so cloned docs share the same state after publish. 82 pub entry_ref: Signal<Option<StrongRef<'static>>>, 83 84 /// AT-URI of the notebook this draft belongs to (for re-publishing) 85 pub notebook_uri: Signal<Option<SmolStr>>, 86 87 // --- Edit sync state (for PDS sync) --- 88 /// StrongRef to the sh.weaver.edit.root record for this edit session. 89 /// None if we haven't synced to PDS yet. 90 pub edit_root: Signal<Option<StrongRef<'static>>>, 91 92 /// StrongRef to the most recent sh.weaver.edit.diff record. 93 /// Used for the `prev` field when creating new diffs. 94 /// None if no diffs have been created yet (only root exists). 95 pub last_diff: Signal<Option<StrongRef<'static>>>, 96 97 /// Version vector at the time of last sync to PDS. 98 /// Used to export only changes since last sync. 99 /// None if never synced. 100 /// Signal so cloned docs share the same sync state. 101 last_synced_version: Signal<Option<VersionVector>>, 102 103 /// Last seen diff URI per collaborator root. 104 /// Maps root URI -> last diff URI we've imported from that root. 105 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this. 106 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>, 107 108 // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- 109 /// Current cursor position. Signal so cursor changes don't dirty content memos. 110 pub cursor: Signal<CursorState>, 111 112 /// Active selection if any. Signal for same reason as cursor. 113 pub selection: Signal<Option<Selection>>, 114 115 /// IME composition state. Signal so composition updates are isolated. 116 pub composition: Signal<Option<CompositionState>>, 117 118 /// Timestamp when the last composition ended. 119 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. 120 pub composition_ended_at: Signal<Option<web_time::Instant>>, 121 122 /// Bumped when content changes to trigger paragraph re-renders. 123 /// Actual EditInfo is obtained from `buffer.last_edit()`. 124 pub content_changed: Signal<()>, 125 126 /// Pending snap direction for cursor restoration after edits. 127 /// Set by input handlers, consumed by cursor restoration. 128 pub pending_snap: Signal<Option<weaver_editor_core::SnapDirection>>, 129 130 /// Collected refs (wikilinks, AT embeds) from the most recent render. 131 /// Updated by the render pipeline, read by publish for populating records. 132 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>, 133} 134 135/// Pre-loaded document state that can be created outside of reactive context. 136/// 137/// This struct holds the raw LoroDoc (which is safe outside reactive context) 138/// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()` 139/// inside a `use_hook` to convert this into a reactive SignalEditorDocument. 140/// 141/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). 142/// PartialEq always returns false since we can't meaningfully compare docs. 143#[derive(Clone)] 144pub struct LoadedDocState { 145 /// The Loro document with all content already loaded/merged. 146 pub doc: LoroDoc, 147 /// StrongRef to the entry if editing an existing record. 148 pub entry_ref: Option<StrongRef<'static>>, 149 /// StrongRef to the sh.weaver.edit.root record (for PDS sync). 150 pub edit_root: Option<StrongRef<'static>>, 151 /// StrongRef to the most recent sh.weaver.edit.diff record. 152 pub last_diff: Option<StrongRef<'static>>, 153 /// Version vector of the last known PDS state. 154 /// Used to determine what changes need to be synced. 155 /// None if never synced to PDS. 156 pub synced_version: Option<VersionVector>, 157 /// Last seen diff URIs per collaborator root. 158 /// Used for incremental sync on subsequent refreshes. 159 pub last_seen_diffs: std::collections::HashMap<AtUri<'static>, AtUri<'static>>, 160 /// Pre-resolved embed content fetched during load. 161 /// Avoids embed pop-in on initial render. 162 pub resolved_content: weaver_common::ResolvedContent, 163 /// Notebook URI for re-publishing to the same notebook. 164 pub notebook_uri: Option<SmolStr>, 165} 166 167impl PartialEq for LoadedDocState { 168 fn eq(&self, _other: &Self) -> bool { 169 // LoadedDocState contains LoroDoc which can't be meaningfully compared. 170 // Return false to ensure components re-render when passed as props. 171 false 172 } 173} 174 175impl SignalEditorDocument { 176 /// Create a new editor document with the given content. 177 /// Sets `created_at` to current time. 178 /// 179 /// # Note 180 /// This creates Dioxus Signals for reactive fields. Call from within 181 /// a component using `use_hook(|| SignalEditorDocument::new(...))`. 182 pub fn new(initial_content: String) -> Self { 183 // Create the LoroTextBuffer which owns the LoroDoc 184 let mut buffer = LoroTextBuffer::new(); 185 let doc = buffer.doc().clone(); 186 187 // Get other containers from the doc 188 let title = doc.get_text("title"); 189 let path = doc.get_text("path"); 190 let created_at = doc.get_text("created_at"); 191 let tags = doc.get_list("tags"); 192 let embeds = doc.get_map("embeds"); 193 194 // Insert initial content if any 195 if !initial_content.is_empty() { 196 buffer.insert(0, &initial_content); 197 } 198 199 // Set created_at to current time (ISO 8601) 200 let now = Self::current_datetime_string(); 201 created_at 202 .insert(0, &now) 203 .expect("failed to set created_at"); 204 205 Self { 206 buffer, 207 title, 208 path, 209 created_at, 210 tags, 211 embeds, 212 entry_ref: Signal::new(None), 213 notebook_uri: Signal::new(None), 214 edit_root: Signal::new(None), 215 last_diff: Signal::new(None), 216 last_synced_version: Signal::new(None), 217 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 218 cursor: Signal::new(CursorState { 219 offset: 0, 220 affinity: Affinity::Before, 221 }), 222 selection: Signal::new(None), 223 composition: Signal::new(None), 224 composition_ended_at: Signal::new(None), 225 content_changed: Signal::new(()), 226 pending_snap: Signal::new(None), 227 collected_refs: Signal::new(Vec::new()), 228 } 229 } 230 231 /// Create a SignalEditorDocument from a fetched Entry. 232 /// 233 /// MUST be called from within a reactive context (e.g., `use_hook`) to 234 /// properly initialize Dioxus Signals. 235 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self { 236 let mut doc = Self::new(entry.content.to_string()); 237 238 // Set metadata 239 doc.set_title(&entry.title); 240 doc.set_path(&entry.path); 241 doc.set_created_at(&entry.created_at.to_string()); 242 243 // Add tags 244 if let Some(ref tags) = entry.tags { 245 for tag in tags.iter() { 246 doc.add_tag(tag.as_ref()); 247 } 248 } 249 250 // Add existing images (no published_blob_uri needed - they're already in the entry) 251 if let Some(ref embeds) = entry.embeds { 252 if let Some(ref images) = embeds.images { 253 for img in &images.images { 254 doc.add_image(&img.clone().into_static(), None); 255 } 256 } 257 258 if let Some(ref records) = embeds.records { 259 for record in &records.records { 260 doc.add_record(&record.clone().into_static()); 261 } 262 } 263 } 264 265 // Set the entry_ref so subsequent publishes update this record 266 doc.set_entry_ref(Some(entry_ref)); 267 268 doc 269 } 270 271 /// Generate current datetime as ISO 8601 string. 272 #[cfg(target_family = "wasm")] 273 fn current_datetime_string() -> String { 274 js_sys::Date::new_0() 275 .to_iso_string() 276 .as_string() 277 .unwrap_or_default() 278 } 279 280 #[cfg(not(target_family = "wasm"))] 281 fn current_datetime_string() -> String { 282 // Fallback for non-wasm (tests, etc.) 283 chrono::Utc::now().to_rfc3339() 284 } 285 286 /// Get the underlying LoroText for read operations on content. 287 pub fn loro_text(&self) -> &LoroText { 288 self.buffer.content() 289 } 290 291 /// Get the underlying LoroDoc for subscriptions and advanced operations. 292 pub fn loro_doc(&self) -> &LoroDoc { 293 self.buffer.doc() 294 } 295 296 /// Get direct access to the LoroTextBuffer. 297 pub fn buffer(&self) -> &LoroTextBuffer { 298 &self.buffer 299 } 300 301 /// Get mutable access to the LoroTextBuffer. 302 pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer { 303 &mut self.buffer 304 } 305 306 // --- Content accessors --- 307 308 /// Get the markdown content as a string. 309 pub fn content(&self) -> String { 310 weaver_editor_core::TextBuffer::to_string(&self.buffer) 311 } 312 313 /// Convert the document content to a string (alias for content()). 314 pub fn to_string(&self) -> String { 315 weaver_editor_core::TextBuffer::to_string(&self.buffer) 316 } 317 318 /// Get the length of the content in characters. 319 pub fn len_chars(&self) -> usize { 320 weaver_editor_core::TextBuffer::len_chars(&self.buffer) 321 } 322 323 /// Get the length of the content in UTF-8 bytes. 324 pub fn len_bytes(&self) -> usize { 325 weaver_editor_core::TextBuffer::len_bytes(&self.buffer) 326 } 327 328 /// Get the length of the content in UTF-16 code units. 329 pub fn len_utf16(&self) -> usize { 330 self.buffer.content().len_utf16() 331 } 332 333 /// Check if the content is empty. 334 pub fn is_empty(&self) -> bool { 335 weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0 336 } 337 338 // --- Entry metadata accessors --- 339 340 /// Get the entry title. 341 pub fn title(&self) -> String { 342 self.title.to_string() 343 } 344 345 /// Set the entry title (replaces existing). 346 /// Takes &self because Loro has interior mutability. 347 pub fn set_title(&self, new_title: &str) { 348 let current_len = self.title.len_unicode(); 349 if current_len > 0 { 350 self.title.delete(0, current_len).ok(); 351 } 352 self.title.insert(0, new_title).ok(); 353 } 354 355 /// Get the URL path/slug. 356 pub fn path(&self) -> String { 357 self.path.to_string() 358 } 359 360 /// Set the URL path/slug (replaces existing). 361 /// Takes &self because Loro has interior mutability. 362 pub fn set_path(&self, new_path: &str) { 363 let current_len = self.path.len_unicode(); 364 if current_len > 0 { 365 self.path.delete(0, current_len).ok(); 366 } 367 self.path.insert(0, new_path).ok(); 368 } 369 370 /// Get the created_at timestamp (ISO 8601 string). 371 pub fn created_at(&self) -> String { 372 self.created_at.to_string() 373 } 374 375 /// Set the created_at timestamp (usually only called once on creation or when loading). 376 /// Takes &self because Loro has interior mutability. 377 pub fn set_created_at(&self, datetime: &str) { 378 let current_len = self.created_at.len_unicode(); 379 if current_len > 0 { 380 self.created_at.delete(0, current_len).ok(); 381 } 382 self.created_at.insert(0, datetime).ok(); 383 } 384 385 // --- Entry ref accessors --- 386 387 /// Get the StrongRef to the entry if editing an existing record. 388 pub fn entry_ref(&self) -> Option<StrongRef<'static>> { 389 self.entry_ref.read().clone() 390 } 391 392 /// Set the StrongRef when editing an existing entry. 393 pub fn set_entry_ref(&mut self, entry: Option<StrongRef<'static>>) { 394 self.entry_ref.set(entry); 395 } 396 397 /// Get the notebook URI if this draft belongs to a notebook. 398 pub fn notebook_uri(&self) -> Option<SmolStr> { 399 self.notebook_uri.read().clone() 400 } 401 402 /// Set the notebook URI for re-publishing to the same notebook. 403 pub fn set_notebook_uri(&mut self, uri: Option<SmolStr>) { 404 self.notebook_uri.set(uri); 405 } 406 407 // --- Tags accessors --- 408 409 /// Get all tags as a vector of strings. 410 pub fn tags(&self) -> Vec<String> { 411 let len = self.tags.len(); 412 (0..len) 413 .filter_map(|i| match self.tags.get(i)? { 414 loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()), 415 _ => None, 416 }) 417 .collect() 418 } 419 420 /// Add a tag (if not already present). 421 /// Takes &self because Loro has interior mutability. 422 pub fn add_tag(&self, tag: &str) { 423 let existing = self.tags(); 424 if !existing.iter().any(|t| t == tag) { 425 self.tags.push(LoroValue::String(tag.into())).ok(); 426 } 427 } 428 429 /// Remove a tag by value. 430 /// Takes &self because Loro has interior mutability. 431 pub fn remove_tag(&self, tag: &str) { 432 let len = self.tags.len(); 433 for i in (0..len).rev() { 434 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { 435 if s.as_str() == tag { 436 self.tags.delete(i, 1).ok(); 437 break; 438 } 439 } 440 } 441 } 442 443 /// Clear all tags. 444 /// Takes &self because Loro has interior mutability. 445 pub fn clear_tags(&self) { 446 let len = self.tags.len(); 447 if len > 0 { 448 self.tags.delete(0, len).ok(); 449 } 450 } 451 452 // --- Images accessors --- 453 454 /// Get the images LoroList from embeds, creating it if needed. 455 fn get_images_list(&self) -> LoroList { 456 self.embeds 457 .get_or_create_container("images", LoroList::new()) 458 .unwrap() 459 } 460 461 /// Get all images as a Vec. 462 pub fn images(&self) -> Vec<EditorImage> { 463 let images_list = self.get_images_list(); 464 let mut result = Vec::new(); 465 466 for i in 0..images_list.len() { 467 if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) { 468 result.push(editor_image); 469 } 470 } 471 472 result 473 } 474 475 /// Convert a LoroValue at the given index to an EditorImage. 476 fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option<EditorImage> { 477 let value = list.get(index)?; 478 479 // Extract LoroValue from ValueOrContainer 480 let loro_value = value.as_value()?; 481 482 // Convert LoroValue to serde_json::Value 483 let json = loro_value.to_json_value(); 484 485 // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data 486 let image: Image<'static> = from_json_value::<Image>(json).ok()?; 487 488 // Extract our tracking field from extra_data 489 let published_blob_uri = image 490 .extra_data 491 .as_ref() 492 .and_then(|m| m.get("publishedBlobUri")) 493 .and_then(|d| d.as_str()) 494 .and_then(|s| AtUri::new(s).ok()) 495 .map(|uri| uri.into_static()); 496 497 Some(EditorImage { 498 image, 499 published_blob_uri, 500 }) 501 } 502 503 /// Add an image to the embeds. 504 /// The Image is serialized to JSON with our publishedBlobUri added. 505 pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) { 506 // Serialize the Image to serde_json::Value 507 let mut json = serde_json::to_value(image).expect("Image serializes"); 508 509 // Add our tracking field (not part of lexicon, stored in extra_data on deserialize) 510 if let Some(uri) = published_blob_uri { 511 json.as_object_mut() 512 .unwrap() 513 .insert("publishedBlobUri".into(), uri.as_str().into()); 514 } 515 516 // Insert into the images list 517 let images_list = self.get_images_list(); 518 images_list.push(json).ok(); 519 } 520 521 pub fn add_record(&mut self, record: &RecordEmbed<'_>) { 522 // Serialize the Record embed to serde_json::Value 523 let json = serde_json::to_value(record).expect("Record serializes"); 524 525 // Insert into the record list 526 let record_list = self.get_records_list(); 527 record_list.push(json).ok(); 528 } 529 530 pub fn remove_record(&mut self, index: usize) { 531 let record_list = self.get_records_list(); 532 if index < record_list.len() { 533 record_list.delete(index, 1).ok(); 534 } 535 } 536 537 /// Remove an image by index. 538 pub fn remove_image(&mut self, index: usize) { 539 let images_list = self.get_images_list(); 540 if index < images_list.len() { 541 images_list.delete(index, 1).ok(); 542 } 543 } 544 545 /// Get a single image by index. 546 pub fn get_image(&self, index: usize) -> Option<EditorImage> { 547 let images_list = self.get_images_list(); 548 self.loro_value_to_editor_image(&images_list, index) 549 } 550 551 /// Get the number of images. 552 pub fn images_len(&self) -> usize { 553 self.get_images_list().len() 554 } 555 556 /// Update the alt text of an image at the given index. 557 pub fn update_image_alt(&mut self, index: usize, alt: &str) { 558 let images_list = self.get_images_list(); 559 if let Some(value) = images_list.get(index) { 560 if let Some(loro_value) = value.as_value() { 561 let mut json = loro_value.to_json_value(); 562 if let Some(obj) = json.as_object_mut() { 563 obj.insert("alt".into(), alt.into()); 564 // Replace the entire value at this index 565 images_list.delete(index, 1).ok(); 566 images_list.insert(index, json).ok(); 567 } 568 } 569 } 570 } 571 572 // --- Record embed methods --- 573 574 /// Get the records LoroList from embeds, creating it if needed. 575 fn get_records_list(&self) -> LoroList { 576 self.embeds 577 .get_or_create_container("records", LoroList::new()) 578 .unwrap() 579 } 580 581 /// Get all record embeds as a Vec. 582 pub fn record_embeds(&self) -> Vec<RecordEmbed<'static>> { 583 let records_list = self.get_records_list(); 584 let mut result = Vec::new(); 585 586 for i in 0..records_list.len() { 587 if let Some(record_embed) = self.loro_value_to_record_embed(&records_list, i) { 588 result.push(record_embed); 589 } 590 } 591 592 result 593 } 594 595 /// Convert a LoroValue at the given index to a RecordEmbed. 596 fn loro_value_to_record_embed( 597 &self, 598 list: &LoroList, 599 index: usize, 600 ) -> Option<RecordEmbed<'static>> { 601 let value = list.get(index)?; 602 let loro_value = value.as_value()?; 603 let json = loro_value.to_json_value(); 604 from_json_value::<RecordEmbed>(json) 605 .ok() 606 .map(|r| r.into_static()) 607 } 608 609 /// Insert text into content and bump content_changed for re-rendering. 610 /// Edit info is tracked automatically by the buffer. 611 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { 612 self.buffer.insert(pos, text); 613 self.content_changed.set(()); 614 Ok(()) 615 } 616 617 /// Push text to end of content. Faster than insert for appending. 618 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { 619 let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 620 self.buffer.insert(pos, text); 621 self.content_changed.set(()); 622 Ok(()) 623 } 624 625 /// Remove text range from content and bump content_changed for re-rendering. 626 /// Edit info is tracked automatically by the buffer. 627 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 628 self.buffer.delete(start..start + len); 629 self.content_changed.set(()); 630 Ok(()) 631 } 632 633 /// Replace text in content (atomic splice) and bump content_changed. 634 /// Edit info is tracked automatically by the buffer. 635 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 636 self.buffer.replace(start..start + len, text); 637 self.content_changed.set(()); 638 Ok(()) 639 } 640 641 /// Undo the last operation. Automatically updates cursor position. 642 pub fn undo(&mut self) -> LoroResult<bool> { 643 // Sync Loro cursor to current position BEFORE undo 644 // so it tracks through the undo operation 645 self.sync_loro_cursor(); 646 647 let result = self.buffer.undo(); 648 if result { 649 // After undo, query Loro cursor for new position 650 self.sync_cursor_from_loro(); 651 // Signal content change for re-render 652 self.content_changed.set(()); 653 } 654 Ok(result) 655 } 656 657 /// Redo the last undone operation. Automatically updates cursor position. 658 pub fn redo(&mut self) -> LoroResult<bool> { 659 // Sync Loro cursor to current position BEFORE redo 660 self.sync_loro_cursor(); 661 662 let result = self.buffer.redo(); 663 if result { 664 // After redo, query Loro cursor for new position 665 self.sync_cursor_from_loro(); 666 // Signal content change for re-render 667 self.content_changed.set(()); 668 } 669 Ok(result) 670 } 671 672 /// Check if undo is available. 673 pub fn can_undo(&self) -> bool { 674 UndoManager::can_undo(&self.buffer) 675 } 676 677 /// Check if redo is available. 678 pub fn can_redo(&self) -> bool { 679 UndoManager::can_redo(&self.buffer) 680 } 681 682 /// Get a slice of the content text. 683 /// Returns None if the range is invalid. 684 pub fn slice(&self, start: usize, end: usize) -> Option<SmolStr> { 685 self.buffer.slice(start..end) 686 } 687 688 /// Sync the Loro cursor to the current cursor.offset position. 689 /// Call this after OUR edits where we know the new cursor position. 690 pub fn sync_loro_cursor(&mut self) { 691 let offset = self.cursor.read().offset; 692 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); 693 self.buffer.sync_cursor(offset); 694 } 695 696 /// Update cursor.offset from the Loro cursor's tracked position. 697 /// Call this after undo/redo or remote edits where the position may have shifted. 698 /// Returns the new offset, or None if the cursor couldn't be resolved. 699 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 700 let old_offset = self.cursor.read().offset; 701 let new_offset = self.buffer.resolve_cursor()?; 702 let jump = if new_offset > old_offset { 703 new_offset - old_offset 704 } else { 705 old_offset - new_offset 706 }; 707 if jump > 100 { 708 tracing::warn!( 709 old_offset, 710 new_offset, 711 jump, 712 "sync_cursor_from_loro: LARGE CURSOR JUMP detected" 713 ); 714 } 715 tracing::debug!( 716 old_offset, 717 new_offset, 718 "sync_cursor_from_loro: updating cursor from Loro" 719 ); 720 self.cursor.with_mut(|c| c.offset = new_offset); 721 Some(new_offset) 722 } 723 724 /// Get the Loro cursor for serialization. 725 pub fn loro_cursor(&self) -> Option<Cursor> { 726 self.buffer.loro_cursor() 727 } 728 729 /// Set the Loro cursor (used when restoring from storage). 730 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) { 731 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); 732 self.buffer.set_loro_cursor(cursor); 733 // Sync cursor.offset from the restored Loro cursor 734 if self.buffer.loro_cursor().is_some() { 735 self.sync_cursor_from_loro(); 736 } 737 } 738 739 /// Export the document as a binary snapshot. 740 /// This captures all CRDT state including undo history. 741 pub fn export_snapshot(&self) -> Vec<u8> { 742 self.buffer.export_snapshot() 743 } 744 745 /// Get the current state frontiers for change detection. 746 /// Frontiers represent the "version" of the document state. 747 pub fn state_frontiers(&self) -> Frontiers { 748 self.buffer.doc().state_frontiers() 749 } 750 751 /// Get the current version vector. 752 pub fn version_vector(&self) -> VersionVector { 753 self.buffer.version() 754 } 755 756 /// Get the last edit info for incremental rendering. 757 /// This comes from the buffer's internal tracking. 758 pub fn last_edit(&self) -> Option<EditInfo> { 759 self.buffer.last_edit() 760 } 761 762 /// Bump the content_changed signal to trigger re-renders. 763 /// Call this after remote imports or other external content changes. 764 pub fn notify_content_changed(&mut self) { 765 self.content_changed.set(()); 766 } 767 768 // --- Collected refs accessors --- 769 770 /// Update collected refs from the render pipeline. 771 pub fn set_collected_refs(&mut self, refs: Vec<weaver_common::ExtractedRef>) { 772 self.collected_refs.set(refs); 773 } 774 775 /// Get AT URIs from collected embeds for populating entry.embeds.records. 776 /// 777 /// Filters for AtEmbed refs and parses to AtUri. Invalid URIs are skipped. 778 pub fn at_embed_uris(&self) -> Vec<AtUri<'static>> { 779 self.collected_refs 780 .read() 781 .iter() 782 .filter_map(|r| match r { 783 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 784 AtUri::new(uri).ok().map(|u| u.into_static()) 785 } 786 _ => None, 787 }) 788 .collect() 789 } 790 791 // --- Edit sync methods --- 792 793 /// Get the edit root StrongRef if set. 794 pub fn edit_root(&self) -> Option<StrongRef<'static>> { 795 self.edit_root.read().clone() 796 } 797 798 /// Set the edit root after creating or finding the root record. 799 pub fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 800 self.edit_root.set(root); 801 } 802 803 /// Get the last diff StrongRef if set. 804 pub fn last_diff(&self) -> Option<StrongRef<'static>> { 805 self.last_diff.read().clone() 806 } 807 808 /// Set the last diff after creating a new diff record. 809 pub fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 810 self.last_diff.set(diff); 811 } 812 813 /// Get the last seen diff URI for a collaborator root. 814 pub fn last_seen_diff_for_root(&self, root_uri: &AtUri<'_>) -> Option<AtUri<'static>> { 815 self.last_seen_diffs 816 .read() 817 .get(&root_uri.clone().into_static()) 818 .cloned() 819 } 820 821 /// Update the last seen diff for a collaborator root. 822 pub fn set_last_seen_diff(&mut self, root_uri: AtUri<'static>, diff_uri: AtUri<'static>) { 823 self.last_seen_diffs.write().insert(root_uri, diff_uri); 824 } 825 826 /// Check if there are unsynced changes since the last PDS sync. 827 pub fn has_unsynced_changes(&self) -> bool { 828 match &*self.last_synced_version.read() { 829 Some(synced_vv) => self.buffer.version() != *synced_vv, 830 None => true, // Never synced, so there are changes 831 } 832 } 833 834 /// Export updates since the last sync. 835 /// Returns None if there are no changes to export. 836 /// After successful upload, call `mark_synced()` to update the sync marker. 837 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 838 let from_vv = self.last_synced_version.read().clone().unwrap_or_default(); 839 self.buffer.export_updates_since(&from_vv) 840 } 841 842 /// Mark the current state as synced. 843 /// Call this after successfully uploading a diff to the PDS. 844 pub fn mark_synced(&mut self) { 845 self.last_synced_version.set(Some(self.buffer.version())); 846 } 847 848 /// Import updates from a PDS diff blob. 849 /// Used when loading edit history from the PDS. 850 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { 851 let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 852 let vv_before = self.buffer.version(); 853 854 self.buffer 855 .import(updates) 856 .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?; 857 858 let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer); 859 let vv_after = self.buffer.version(); 860 let vv_changed = vv_before != vv_after; 861 let len_changed = len_before != len_after; 862 863 tracing::debug!( 864 len_before, 865 len_after, 866 len_changed, 867 vv_changed, 868 "import_updates: merge result" 869 ); 870 871 // Only trigger re-render if something actually changed 872 if vv_changed { 873 self.content_changed.set(()); 874 } 875 Ok(()) 876 } 877 878 /// Export updates since the given version vector. 879 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync. 880 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> { 881 self.buffer.export_updates_since(from_vv) 882 } 883 884 /// Set the sync state when loading from PDS. 885 /// This sets the version marker to the current state so we don't 886 /// re-upload what we just downloaded. 887 pub fn set_synced_from_pds( 888 &mut self, 889 edit_root: StrongRef<'static>, 890 last_diff: Option<StrongRef<'static>>, 891 ) { 892 self.edit_root.set(Some(edit_root)); 893 self.last_diff.set(last_diff); 894 self.last_synced_version.set(Some(self.buffer.version())); 895 } 896 897 /// Create a new SignalEditorDocument from a binary snapshot. 898 /// Falls back to empty document if import fails. 899 /// 900 /// If `loro_cursor` is provided, it will be used to restore the cursor position. 901 /// Otherwise, falls back to `fallback_offset`. 902 /// 903 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they 904 /// happen in real-time; it cannot rebuild history from imported CRDT ops. 905 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. 906 /// 907 /// # Note 908 /// This creates Dioxus Signals for reactive fields. Call from within 909 /// a component using `use_hook`. 910 pub fn from_snapshot( 911 snapshot: &[u8], 912 loro_cursor: Option<Cursor>, 913 fallback_offset: usize, 914 ) -> Self { 915 // Create buffer from snapshot 916 let buffer = if snapshot.is_empty() { 917 LoroTextBuffer::new() 918 } else { 919 match LoroTextBuffer::from_snapshot(snapshot) { 920 Ok(buf) => buf, 921 Err(e) => { 922 tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); 923 LoroTextBuffer::new() 924 } 925 } 926 }; 927 928 let doc = buffer.doc().clone(); 929 930 // Get other containers from the doc 931 let title = doc.get_text("title"); 932 let path = doc.get_text("path"); 933 let created_at = doc.get_text("created_at"); 934 let tags = doc.get_list("tags"); 935 let embeds = doc.get_map("embeds"); 936 937 // Try to restore cursor from Loro cursor, fall back to offset 938 let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); 939 let cursor_offset = if let Some(ref lc) = loro_cursor { 940 doc.get_cursor_pos(lc) 941 .map(|r| r.current.pos) 942 .unwrap_or(fallback_offset) 943 } else { 944 fallback_offset 945 }; 946 947 let cursor_state = CursorState { 948 offset: cursor_offset.min(max_offset), 949 affinity: Affinity::Before, 950 }; 951 952 // Set up the Loro cursor 953 let buffer = buffer; 954 if let Some(lc) = loro_cursor { 955 buffer.set_loro_cursor(Some(lc)); 956 } else { 957 buffer.sync_cursor(cursor_state.offset); 958 } 959 960 Self { 961 buffer, 962 title, 963 path, 964 created_at, 965 tags, 966 embeds, 967 entry_ref: Signal::new(None), 968 notebook_uri: Signal::new(None), 969 edit_root: Signal::new(None), 970 last_diff: Signal::new(None), 971 last_synced_version: Signal::new(None), 972 last_seen_diffs: Signal::new(std::collections::HashMap::new()), 973 cursor: Signal::new(cursor_state), 974 selection: Signal::new(None), 975 composition: Signal::new(None), 976 composition_ended_at: Signal::new(None), 977 content_changed: Signal::new(()), 978 pending_snap: Signal::new(None), 979 collected_refs: Signal::new(Vec::new()), 980 } 981 } 982 983 /// Create a SignalEditorDocument from pre-loaded state. 984 /// 985 /// Use this when loading from PDS/localStorage merge outside reactive context. 986 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it 987 /// with the reactive Signals needed for the editor UI. 988 /// 989 /// # Note 990 /// This creates Dioxus Signals. Call from within a component using `use_hook`. 991 pub fn from_loaded_state(state: LoadedDocState) -> Self { 992 // Create buffer from the loaded doc 993 let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content"); 994 let doc = buffer.doc().clone(); 995 996 // Get other containers from the doc 997 let title = doc.get_text("title"); 998 let path = doc.get_text("path"); 999 let created_at = doc.get_text("created_at"); 1000 let tags = doc.get_list("tags"); 1001 let embeds = doc.get_map("embeds"); 1002 1003 // Position cursor at end of content 1004 let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); 1005 let cursor_state = CursorState { 1006 offset: cursor_offset, 1007 affinity: Affinity::Before, 1008 }; 1009 1010 // Set up the Loro cursor 1011 let buffer = buffer; 1012 buffer.sync_cursor(cursor_offset); 1013 1014 Self { 1015 buffer, 1016 title, 1017 path, 1018 created_at, 1019 tags, 1020 embeds, 1021 entry_ref: Signal::new(state.entry_ref), 1022 notebook_uri: Signal::new(state.notebook_uri), 1023 edit_root: Signal::new(state.edit_root), 1024 last_diff: Signal::new(state.last_diff), 1025 // Use the synced version from state (tracks the PDS version vector) 1026 last_synced_version: Signal::new(state.synced_version), 1027 last_seen_diffs: Signal::new(state.last_seen_diffs), 1028 cursor: Signal::new(cursor_state), 1029 selection: Signal::new(None), 1030 composition: Signal::new(None), 1031 composition_ended_at: Signal::new(None), 1032 content_changed: Signal::new(()), 1033 pending_snap: Signal::new(None), 1034 collected_refs: Signal::new(Vec::new()), 1035 } 1036 } 1037} 1038 1039impl PartialEq for SignalEditorDocument { 1040 fn eq(&self, _other: &Self) -> bool { 1041 // SignalEditorDocument uses interior mutability, so we can't meaningfully compare. 1042 // Return false to ensure components re-render when passed as props. 1043 false 1044 } 1045} 1046 1047impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument { 1048 fn export_snapshot(&self) -> Vec<u8> { 1049 self.export_snapshot() 1050 } 1051 1052 fn export_updates_since_sync(&self) -> Option<Vec<u8>> { 1053 self.export_updates_since_sync() 1054 } 1055 1056 fn import(&mut self, data: &[u8]) -> Result<(), weaver_editor_crdt::CrdtError> { 1057 self.import_updates(data) 1058 .map_err(|e| weaver_editor_crdt::CrdtError::Import(e.to_string())) 1059 } 1060 1061 fn version(&self) -> VersionVector { 1062 self.version_vector() 1063 } 1064 1065 fn edit_root(&self) -> Option<StrongRef<'static>> { 1066 SignalEditorDocument::edit_root(self) 1067 } 1068 1069 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) { 1070 SignalEditorDocument::set_edit_root(self, root); 1071 } 1072 1073 fn last_diff(&self) -> Option<StrongRef<'static>> { 1074 SignalEditorDocument::last_diff(self) 1075 } 1076 1077 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) { 1078 SignalEditorDocument::set_last_diff(self, diff); 1079 } 1080 1081 fn mark_synced(&mut self) { 1082 SignalEditorDocument::mark_synced(self); 1083 } 1084 1085 fn has_unsynced_changes(&self) -> bool { 1086 SignalEditorDocument::has_unsynced_changes(self) 1087 } 1088} 1089 1090impl EditorDocument for SignalEditorDocument { 1091 type Buffer = LoroTextBuffer; 1092 1093 fn buffer(&self) -> &Self::Buffer { 1094 &self.buffer 1095 } 1096 1097 fn buffer_mut(&mut self) -> &mut Self::Buffer { 1098 &mut self.buffer 1099 } 1100 1101 fn cursor(&self) -> CursorState { 1102 *self.cursor.read() 1103 } 1104 1105 fn set_cursor(&mut self, cursor: CursorState) { 1106 self.cursor.set(cursor); 1107 } 1108 1109 fn selection(&self) -> Option<Selection> { 1110 self.selection.read().clone() 1111 } 1112 1113 fn set_selection(&mut self, selection: Option<Selection>) { 1114 self.selection.set(selection); 1115 } 1116 1117 fn last_edit(&self) -> Option<EditInfo> { 1118 self.buffer.last_edit() 1119 } 1120 1121 fn set_last_edit(&mut self, _edit: Option<EditInfo>) { 1122 // Buffer tracks edit info internally. We use this hook to 1123 // bump content_changed for reactive re-rendering. 1124 self.content_changed.set(()); 1125 } 1126 1127 fn composition(&self) -> Option<CompositionState> { 1128 self.composition.read().clone() 1129 } 1130 1131 fn set_composition(&mut self, composition: Option<CompositionState>) { 1132 self.composition.set(composition); 1133 } 1134 1135 fn composition_ended_at(&self) -> Option<web_time::Instant> { 1136 *self.composition_ended_at.read() 1137 } 1138 1139 fn set_composition_ended_now(&mut self) { 1140 self.composition_ended_at.set(Some(web_time::Instant::now())); 1141 } 1142 1143 fn undo(&mut self) -> bool { 1144 // Sync Loro cursor to current position BEFORE undo 1145 // so it tracks through the undo operation. 1146 self.sync_loro_cursor(); 1147 1148 let result = self.buffer.undo(); 1149 if result { 1150 // After undo, query Loro cursor for new position. 1151 self.sync_cursor_from_loro(); 1152 // Signal content change for re-render. 1153 self.content_changed.set(()); 1154 } 1155 result 1156 } 1157 1158 fn redo(&mut self) -> bool { 1159 // Sync Loro cursor to current position BEFORE redo. 1160 self.sync_loro_cursor(); 1161 1162 let result = self.buffer.redo(); 1163 if result { 1164 // After redo, query Loro cursor for new position. 1165 self.sync_cursor_from_loro(); 1166 // Signal content change for re-render. 1167 self.content_changed.set(()); 1168 } 1169 result 1170 } 1171 1172 fn pending_snap(&self) -> Option<weaver_editor_core::SnapDirection> { 1173 *self.pending_snap.read() 1174 } 1175 1176 fn set_pending_snap(&mut self, snap: Option<weaver_editor_core::SnapDirection>) { 1177 self.pending_snap.set(snap); 1178 } 1179}