//! Core data structures for the markdown editor. //! //! Uses Loro CRDT for text storage with built-in undo/redo support. //! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. //! //! # Reactive Architecture //! //! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity: //! - Cursor/selection changes don't trigger content re-renders //! - Content changes bump `content_changed` Signal to trigger paragraph re-renders //! - The document struct itself is NOT wrapped in a Signal - use `use_hook` use dioxus::prelude::*; use loro::{ Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector, cursor::Cursor, }; use jacquard::IntoStatic; use jacquard::from_json_value; use jacquard::smol_str::SmolStr; use jacquard::types::string::AtUri; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::embed::images::Image; use weaver_api::sh_weaver::embed::records::RecordEmbed; use weaver_api::sh_weaver::notebook::entry::Entry; use weaver_editor_core::EditorDocument; use weaver_editor_core::TextBuffer; use weaver_editor_core::UndoManager; pub use weaver_editor_core::{ Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, }; use weaver_editor_crdt::LoroTextBuffer; /// Single source of truth for editor state. /// /// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position, /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` /// schema with CRDT containers for each field. /// /// # Reactive Architecture /// /// The document itself is NOT wrapped in a Signal. Instead, individual fields /// that need reactivity are wrapped in Signals: /// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates /// - `content_changed` - bumped to trigger paragraph re-renders when content changes /// /// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`. /// /// # Cloning /// /// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed, /// and Signals are Copy. Closures can capture clones without overhead. #[derive(Clone)] pub struct SignalEditorDocument { /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking. /// Access the underlying LoroDoc via `buffer.doc()`. buffer: LoroTextBuffer, // --- Entry schema containers (Loro handles interior mutability) --- // These are obtained from buffer.doc() but cached for convenience. /// Entry title (maps to entry.title) title: LoroText, /// URL path/slug (maps to entry.path) path: LoroText, /// ISO datetime string (maps to entry.createdAt) created_at: LoroText, /// Tags list (maps to entry.tags) tags: LoroList, /// Embeds container (maps to entry.embeds) /// Contains nested containers: images (LoroList), externals (LoroList), etc. embeds: LoroMap, // --- Entry tracking (reactive) --- /// StrongRef to the entry if editing an existing record. /// None for new entries that haven't been published yet. /// Signal so cloned docs share the same state after publish. pub entry_ref: Signal>>, /// AT-URI of the notebook this draft belongs to (for re-publishing) pub notebook_uri: Signal>, // --- Edit sync state (for PDS sync) --- /// StrongRef to the sh.weaver.edit.root record for this edit session. /// None if we haven't synced to PDS yet. pub edit_root: Signal>>, /// StrongRef to the most recent sh.weaver.edit.diff record. /// Used for the `prev` field when creating new diffs. /// None if no diffs have been created yet (only root exists). pub last_diff: Signal>>, /// Version vector at the time of last sync to PDS. /// Used to export only changes since last sync. /// None if never synced. /// Signal so cloned docs share the same sync state. last_synced_version: Signal>, /// Last seen diff URI per collaborator root. /// Maps root URI -> last diff URI we've imported from that root. /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this. pub last_seen_diffs: Signal, AtUri<'static>>>, // --- Reactive editor state (Signal-wrapped for fine-grained updates) --- /// Current cursor position. Signal so cursor changes don't dirty content memos. pub cursor: Signal, /// Active selection if any. Signal for same reason as cursor. pub selection: Signal>, /// IME composition state. Signal so composition updates are isolated. pub composition: Signal>, /// Timestamp when the last composition ended. /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend. pub composition_ended_at: Signal>, /// Bumped when content changes to trigger paragraph re-renders. /// Actual EditInfo is obtained from `buffer.last_edit()`. pub content_changed: Signal<()>, /// Pending snap direction for cursor restoration after edits. /// Set by input handlers, consumed by cursor restoration. pub pending_snap: Signal>, /// Collected refs (wikilinks, AT embeds) from the most recent render. /// Updated by the render pipeline, read by publish for populating records. pub collected_refs: Signal>, } /// Pre-loaded document state that can be created outside of reactive context. /// /// This struct holds the raw LoroDoc (which is safe outside reactive context) /// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()` /// inside a `use_hook` to convert this into a reactive SignalEditorDocument. /// /// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed). /// PartialEq always returns false since we can't meaningfully compare docs. #[derive(Clone)] pub struct LoadedDocState { /// The Loro document with all content already loaded/merged. pub doc: LoroDoc, /// StrongRef to the entry if editing an existing record. pub entry_ref: Option>, /// StrongRef to the sh.weaver.edit.root record (for PDS sync). pub edit_root: Option>, /// StrongRef to the most recent sh.weaver.edit.diff record. pub last_diff: Option>, /// Version vector of the last known PDS state. /// Used to determine what changes need to be synced. /// None if never synced to PDS. pub synced_version: Option, /// Last seen diff URIs per collaborator root. /// Used for incremental sync on subsequent refreshes. pub last_seen_diffs: std::collections::HashMap, AtUri<'static>>, /// Pre-resolved embed content fetched during load. /// Avoids embed pop-in on initial render. pub resolved_content: weaver_common::ResolvedContent, /// Notebook URI for re-publishing to the same notebook. pub notebook_uri: Option, } impl PartialEq for LoadedDocState { fn eq(&self, _other: &Self) -> bool { // LoadedDocState contains LoroDoc which can't be meaningfully compared. // Return false to ensure components re-render when passed as props. false } } impl SignalEditorDocument { /// Create a new editor document with the given content. /// Sets `created_at` to current time. /// /// # Note /// This creates Dioxus Signals for reactive fields. Call from within /// a component using `use_hook(|| SignalEditorDocument::new(...))`. pub fn new(initial_content: String) -> Self { // Create the LoroTextBuffer which owns the LoroDoc let mut buffer = LoroTextBuffer::new(); let doc = buffer.doc().clone(); // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); let tags = doc.get_list("tags"); let embeds = doc.get_map("embeds"); // Insert initial content if any if !initial_content.is_empty() { buffer.insert(0, &initial_content); } // Set created_at to current time (ISO 8601) let now = Self::current_datetime_string(); created_at .insert(0, &now) .expect("failed to set created_at"); Self { buffer, title, path, created_at, tags, embeds, entry_ref: Signal::new(None), notebook_uri: Signal::new(None), edit_root: Signal::new(None), last_diff: Signal::new(None), last_synced_version: Signal::new(None), last_seen_diffs: Signal::new(std::collections::HashMap::new()), cursor: Signal::new(CursorState { offset: 0, affinity: Affinity::Before, }), selection: Signal::new(None), composition: Signal::new(None), composition_ended_at: Signal::new(None), content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } /// Create a SignalEditorDocument from a fetched Entry. /// /// MUST be called from within a reactive context (e.g., `use_hook`) to /// properly initialize Dioxus Signals. pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self { let mut doc = Self::new(entry.content.to_string()); // Set metadata doc.set_title(&entry.title); doc.set_path(&entry.path); doc.set_created_at(&entry.created_at.to_string()); // Add tags if let Some(ref tags) = entry.tags { for tag in tags.iter() { doc.add_tag(tag.as_ref()); } } // Add existing images (no published_blob_uri needed - they're already in the entry) if let Some(ref embeds) = entry.embeds { if let Some(ref images) = embeds.images { for img in &images.images { doc.add_image(&img.clone().into_static(), None); } } if let Some(ref records) = embeds.records { for record in &records.records { doc.add_record(&record.clone().into_static()); } } } // Set the entry_ref so subsequent publishes update this record doc.set_entry_ref(Some(entry_ref)); doc } /// Generate current datetime as ISO 8601 string. #[cfg(target_family = "wasm")] fn current_datetime_string() -> String { js_sys::Date::new_0() .to_iso_string() .as_string() .unwrap_or_default() } #[cfg(not(target_family = "wasm"))] fn current_datetime_string() -> String { // Fallback for non-wasm (tests, etc.) chrono::Utc::now().to_rfc3339() } /// Get the underlying LoroText for read operations on content. pub fn loro_text(&self) -> &LoroText { self.buffer.content() } /// Get the underlying LoroDoc for subscriptions and advanced operations. pub fn loro_doc(&self) -> &LoroDoc { self.buffer.doc() } /// Get direct access to the LoroTextBuffer. pub fn buffer(&self) -> &LoroTextBuffer { &self.buffer } /// Get mutable access to the LoroTextBuffer. pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer { &mut self.buffer } // --- Content accessors --- /// Get the markdown content as a string. pub fn content(&self) -> String { weaver_editor_core::TextBuffer::to_string(&self.buffer) } /// Convert the document content to a string (alias for content()). pub fn to_string(&self) -> String { weaver_editor_core::TextBuffer::to_string(&self.buffer) } /// Get the length of the content in characters. pub fn len_chars(&self) -> usize { weaver_editor_core::TextBuffer::len_chars(&self.buffer) } /// Get the length of the content in UTF-8 bytes. pub fn len_bytes(&self) -> usize { weaver_editor_core::TextBuffer::len_bytes(&self.buffer) } /// Get the length of the content in UTF-16 code units. pub fn len_utf16(&self) -> usize { self.buffer.content().len_utf16() } /// Check if the content is empty. pub fn is_empty(&self) -> bool { weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0 } // --- Entry metadata accessors --- /// Get the entry title. pub fn title(&self) -> String { self.title.to_string() } /// Set the entry title (replaces existing). /// Takes &self because Loro has interior mutability. pub fn set_title(&self, new_title: &str) { let current_len = self.title.len_unicode(); if current_len > 0 { self.title.delete(0, current_len).ok(); } self.title.insert(0, new_title).ok(); } /// Get the URL path/slug. pub fn path(&self) -> String { self.path.to_string() } /// Set the URL path/slug (replaces existing). /// Takes &self because Loro has interior mutability. pub fn set_path(&self, new_path: &str) { let current_len = self.path.len_unicode(); if current_len > 0 { self.path.delete(0, current_len).ok(); } self.path.insert(0, new_path).ok(); } /// Get the created_at timestamp (ISO 8601 string). pub fn created_at(&self) -> String { self.created_at.to_string() } /// Set the created_at timestamp (usually only called once on creation or when loading). /// Takes &self because Loro has interior mutability. pub fn set_created_at(&self, datetime: &str) { let current_len = self.created_at.len_unicode(); if current_len > 0 { self.created_at.delete(0, current_len).ok(); } self.created_at.insert(0, datetime).ok(); } // --- Entry ref accessors --- /// Get the StrongRef to the entry if editing an existing record. pub fn entry_ref(&self) -> Option> { self.entry_ref.read().clone() } /// Set the StrongRef when editing an existing entry. pub fn set_entry_ref(&mut self, entry: Option>) { self.entry_ref.set(entry); } /// Get the notebook URI if this draft belongs to a notebook. pub fn notebook_uri(&self) -> Option { self.notebook_uri.read().clone() } /// Set the notebook URI for re-publishing to the same notebook. pub fn set_notebook_uri(&mut self, uri: Option) { self.notebook_uri.set(uri); } // --- Tags accessors --- /// Get all tags as a vector of strings. pub fn tags(&self) -> Vec { let len = self.tags.len(); (0..len) .filter_map(|i| match self.tags.get(i)? { loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()), _ => None, }) .collect() } /// Add a tag (if not already present). /// Takes &self because Loro has interior mutability. pub fn add_tag(&self, tag: &str) { let existing = self.tags(); if !existing.iter().any(|t| t == tag) { self.tags.push(LoroValue::String(tag.into())).ok(); } } /// Remove a tag by value. /// Takes &self because Loro has interior mutability. pub fn remove_tag(&self, tag: &str) { let len = self.tags.len(); for i in (0..len).rev() { if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { if s.as_str() == tag { self.tags.delete(i, 1).ok(); break; } } } } /// Clear all tags. /// Takes &self because Loro has interior mutability. pub fn clear_tags(&self) { let len = self.tags.len(); if len > 0 { self.tags.delete(0, len).ok(); } } // --- Images accessors --- /// Get the images LoroList from embeds, creating it if needed. fn get_images_list(&self) -> LoroList { self.embeds .get_or_create_container("images", LoroList::new()) .unwrap() } /// Get all images as a Vec. pub fn images(&self) -> Vec { let images_list = self.get_images_list(); let mut result = Vec::new(); for i in 0..images_list.len() { if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) { result.push(editor_image); } } result } /// Convert a LoroValue at the given index to an EditorImage. fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option { let value = list.get(index)?; // Extract LoroValue from ValueOrContainer let loro_value = value.as_value()?; // Convert LoroValue to serde_json::Value let json = loro_value.to_json_value(); // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data let image: Image<'static> = from_json_value::(json).ok()?; // Extract our tracking field from extra_data let published_blob_uri = image .extra_data .as_ref() .and_then(|m| m.get("publishedBlobUri")) .and_then(|d| d.as_str()) .and_then(|s| AtUri::new(s).ok()) .map(|uri| uri.into_static()); Some(EditorImage { image, published_blob_uri, }) } /// Add an image to the embeds. /// The Image is serialized to JSON with our publishedBlobUri added. pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) { // Serialize the Image to serde_json::Value let mut json = serde_json::to_value(image).expect("Image serializes"); // Add our tracking field (not part of lexicon, stored in extra_data on deserialize) if let Some(uri) = published_blob_uri { json.as_object_mut() .unwrap() .insert("publishedBlobUri".into(), uri.as_str().into()); } // Insert into the images list let images_list = self.get_images_list(); images_list.push(json).ok(); } pub fn add_record(&mut self, record: &RecordEmbed<'_>) { // Serialize the Record embed to serde_json::Value let json = serde_json::to_value(record).expect("Record serializes"); // Insert into the record list let record_list = self.get_records_list(); record_list.push(json).ok(); } pub fn remove_record(&mut self, index: usize) { let record_list = self.get_records_list(); if index < record_list.len() { record_list.delete(index, 1).ok(); } } /// Remove an image by index. pub fn remove_image(&mut self, index: usize) { let images_list = self.get_images_list(); if index < images_list.len() { images_list.delete(index, 1).ok(); } } /// Get a single image by index. pub fn get_image(&self, index: usize) -> Option { let images_list = self.get_images_list(); self.loro_value_to_editor_image(&images_list, index) } /// Get the number of images. pub fn images_len(&self) -> usize { self.get_images_list().len() } /// Update the alt text of an image at the given index. pub fn update_image_alt(&mut self, index: usize, alt: &str) { let images_list = self.get_images_list(); if let Some(value) = images_list.get(index) { if let Some(loro_value) = value.as_value() { let mut json = loro_value.to_json_value(); if let Some(obj) = json.as_object_mut() { obj.insert("alt".into(), alt.into()); // Replace the entire value at this index images_list.delete(index, 1).ok(); images_list.insert(index, json).ok(); } } } } // --- Record embed methods --- /// Get the records LoroList from embeds, creating it if needed. fn get_records_list(&self) -> LoroList { self.embeds .get_or_create_container("records", LoroList::new()) .unwrap() } /// Get all record embeds as a Vec. pub fn record_embeds(&self) -> Vec> { let records_list = self.get_records_list(); let mut result = Vec::new(); for i in 0..records_list.len() { if let Some(record_embed) = self.loro_value_to_record_embed(&records_list, i) { result.push(record_embed); } } result } /// Convert a LoroValue at the given index to a RecordEmbed. fn loro_value_to_record_embed( &self, list: &LoroList, index: usize, ) -> Option> { let value = list.get(index)?; let loro_value = value.as_value()?; let json = loro_value.to_json_value(); from_json_value::(json) .ok() .map(|r| r.into_static()) } /// Insert text into content and bump content_changed for re-rendering. /// Edit info is tracked automatically by the buffer. pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { self.buffer.insert(pos, text); self.content_changed.set(()); Ok(()) } /// Push text to end of content. Faster than insert for appending. pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer); self.buffer.insert(pos, text); self.content_changed.set(()); Ok(()) } /// Remove text range from content and bump content_changed for re-rendering. /// Edit info is tracked automatically by the buffer. pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { self.buffer.delete(start..start + len); self.content_changed.set(()); Ok(()) } /// Replace text in content (atomic splice) and bump content_changed. /// Edit info is tracked automatically by the buffer. pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { self.buffer.replace(start..start + len, text); self.content_changed.set(()); Ok(()) } /// Undo the last operation. Automatically updates cursor position. pub fn undo(&mut self) -> LoroResult { // Sync Loro cursor to current position BEFORE undo // so it tracks through the undo operation self.sync_loro_cursor(); let result = self.buffer.undo(); if result { // After undo, query Loro cursor for new position self.sync_cursor_from_loro(); // Signal content change for re-render self.content_changed.set(()); } Ok(result) } /// Redo the last undone operation. Automatically updates cursor position. pub fn redo(&mut self) -> LoroResult { // Sync Loro cursor to current position BEFORE redo self.sync_loro_cursor(); let result = self.buffer.redo(); if result { // After redo, query Loro cursor for new position self.sync_cursor_from_loro(); // Signal content change for re-render self.content_changed.set(()); } Ok(result) } /// Check if undo is available. pub fn can_undo(&self) -> bool { UndoManager::can_undo(&self.buffer) } /// Check if redo is available. pub fn can_redo(&self) -> bool { UndoManager::can_redo(&self.buffer) } /// Get a slice of the content text. /// Returns None if the range is invalid. pub fn slice(&self, start: usize, end: usize) -> Option { self.buffer.slice(start..end) } /// Sync the Loro cursor to the current cursor.offset position. /// Call this after OUR edits where we know the new cursor position. pub fn sync_loro_cursor(&mut self) { let offset = self.cursor.read().offset; tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro"); self.buffer.sync_cursor(offset); } /// Update cursor.offset from the Loro cursor's tracked position. /// Call this after undo/redo or remote edits where the position may have shifted. /// Returns the new offset, or None if the cursor couldn't be resolved. pub fn sync_cursor_from_loro(&mut self) -> Option { let old_offset = self.cursor.read().offset; let new_offset = self.buffer.resolve_cursor()?; let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset }; if jump > 100 { tracing::warn!( old_offset, new_offset, jump, "sync_cursor_from_loro: LARGE CURSOR JUMP detected" ); } tracing::debug!( old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro" ); self.cursor.with_mut(|c| c.offset = new_offset); Some(new_offset) } /// Get the Loro cursor for serialization. pub fn loro_cursor(&self) -> Option { self.buffer.loro_cursor() } /// Set the Loro cursor (used when restoring from storage). pub fn set_loro_cursor(&mut self, cursor: Option) { tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called"); self.buffer.set_loro_cursor(cursor); // Sync cursor.offset from the restored Loro cursor if self.buffer.loro_cursor().is_some() { self.sync_cursor_from_loro(); } } /// Export the document as a binary snapshot. /// This captures all CRDT state including undo history. pub fn export_snapshot(&self) -> Vec { self.buffer.export_snapshot() } /// Get the current state frontiers for change detection. /// Frontiers represent the "version" of the document state. pub fn state_frontiers(&self) -> Frontiers { self.buffer.doc().state_frontiers() } /// Get the current version vector. pub fn version_vector(&self) -> VersionVector { self.buffer.version() } /// Get the last edit info for incremental rendering. /// This comes from the buffer's internal tracking. pub fn last_edit(&self) -> Option { self.buffer.last_edit() } /// Bump the content_changed signal to trigger re-renders. /// Call this after remote imports or other external content changes. pub fn notify_content_changed(&mut self) { self.content_changed.set(()); } // --- Collected refs accessors --- /// Update collected refs from the render pipeline. pub fn set_collected_refs(&mut self, refs: Vec) { self.collected_refs.set(refs); } /// Get AT URIs from collected embeds for populating entry.embeds.records. /// /// Filters for AtEmbed refs and parses to AtUri. Invalid URIs are skipped. pub fn at_embed_uris(&self) -> Vec> { self.collected_refs .read() .iter() .filter_map(|r| match r { weaver_common::ExtractedRef::AtEmbed { uri, .. } => { AtUri::new(uri).ok().map(|u| u.into_static()) } _ => None, }) .collect() } // --- Edit sync methods --- /// Get the edit root StrongRef if set. pub fn edit_root(&self) -> Option> { self.edit_root.read().clone() } /// Set the edit root after creating or finding the root record. pub fn set_edit_root(&mut self, root: Option>) { self.edit_root.set(root); } /// Get the last diff StrongRef if set. pub fn last_diff(&self) -> Option> { self.last_diff.read().clone() } /// Set the last diff after creating a new diff record. pub fn set_last_diff(&mut self, diff: Option>) { self.last_diff.set(diff); } /// Get the last seen diff URI for a collaborator root. pub fn last_seen_diff_for_root(&self, root_uri: &AtUri<'_>) -> Option> { self.last_seen_diffs .read() .get(&root_uri.clone().into_static()) .cloned() } /// Update the last seen diff for a collaborator root. pub fn set_last_seen_diff(&mut self, root_uri: AtUri<'static>, diff_uri: AtUri<'static>) { self.last_seen_diffs.write().insert(root_uri, diff_uri); } /// Check if there are unsynced changes since the last PDS sync. pub fn has_unsynced_changes(&self) -> bool { match &*self.last_synced_version.read() { Some(synced_vv) => self.buffer.version() != *synced_vv, None => true, // Never synced, so there are changes } } /// Export updates since the last sync. /// Returns None if there are no changes to export. /// After successful upload, call `mark_synced()` to update the sync marker. pub fn export_updates_since_sync(&self) -> Option> { let from_vv = self.last_synced_version.read().clone().unwrap_or_default(); self.buffer.export_updates_since(&from_vv) } /// Mark the current state as synced. /// Call this after successfully uploading a diff to the PDS. pub fn mark_synced(&mut self) { self.last_synced_version.set(Some(self.buffer.version())); } /// Import updates from a PDS diff blob. /// Used when loading edit history from the PDS. pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> { let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer); let vv_before = self.buffer.version(); self.buffer .import(updates) .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?; let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer); let vv_after = self.buffer.version(); let vv_changed = vv_before != vv_after; let len_changed = len_before != len_after; tracing::debug!( len_before, len_after, len_changed, vv_changed, "import_updates: merge result" ); // Only trigger re-render if something actually changed if vv_changed { self.content_changed.set(()); } Ok(()) } /// Export updates since the given version vector. /// Used for real-time P2P sync where we track broadcast version separately from PDS sync. pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option> { self.buffer.export_updates_since(from_vv) } /// Set the sync state when loading from PDS. /// This sets the version marker to the current state so we don't /// re-upload what we just downloaded. pub fn set_synced_from_pds( &mut self, edit_root: StrongRef<'static>, last_diff: Option>, ) { self.edit_root.set(Some(edit_root)); self.last_diff.set(last_diff); self.last_synced_version.set(Some(self.buffer.version())); } /// Create a new SignalEditorDocument from a binary snapshot. /// Falls back to empty document if import fails. /// /// If `loro_cursor` is provided, it will be used to restore the cursor position. /// Otherwise, falls back to `fallback_offset`. /// /// Note: Undo/redo is session-only. The UndoManager tracks operations as they /// happen in real-time; it cannot rebuild history from imported CRDT ops. /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. /// /// # Note /// This creates Dioxus Signals for reactive fields. Call from within /// a component using `use_hook`. pub fn from_snapshot( snapshot: &[u8], loro_cursor: Option, fallback_offset: usize, ) -> Self { // Create buffer from snapshot let buffer = if snapshot.is_empty() { LoroTextBuffer::new() } else { match LoroTextBuffer::from_snapshot(snapshot) { Ok(buf) => buf, Err(e) => { tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); LoroTextBuffer::new() } } }; let doc = buffer.doc().clone(); // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); let tags = doc.get_list("tags"); let embeds = doc.get_map("embeds"); // Try to restore cursor from Loro cursor, fall back to offset let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); let cursor_offset = if let Some(ref lc) = loro_cursor { doc.get_cursor_pos(lc) .map(|r| r.current.pos) .unwrap_or(fallback_offset) } else { fallback_offset }; let cursor_state = CursorState { offset: cursor_offset.min(max_offset), affinity: Affinity::Before, }; // Set up the Loro cursor let buffer = buffer; if let Some(lc) = loro_cursor { buffer.set_loro_cursor(Some(lc)); } else { buffer.sync_cursor(cursor_state.offset); } Self { buffer, title, path, created_at, tags, embeds, entry_ref: Signal::new(None), notebook_uri: Signal::new(None), edit_root: Signal::new(None), last_diff: Signal::new(None), last_synced_version: Signal::new(None), last_seen_diffs: Signal::new(std::collections::HashMap::new()), cursor: Signal::new(cursor_state), selection: Signal::new(None), composition: Signal::new(None), composition_ended_at: Signal::new(None), content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } /// Create a SignalEditorDocument from pre-loaded state. /// /// Use this when loading from PDS/localStorage merge outside reactive context. /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it /// with the reactive Signals needed for the editor UI. /// /// # Note /// This creates Dioxus Signals. Call from within a component using `use_hook`. pub fn from_loaded_state(state: LoadedDocState) -> Self { // Create buffer from the loaded doc let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content"); let doc = buffer.doc().clone(); // Get other containers from the doc let title = doc.get_text("title"); let path = doc.get_text("path"); let created_at = doc.get_text("created_at"); let tags = doc.get_list("tags"); let embeds = doc.get_map("embeds"); // Position cursor at end of content let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer); let cursor_state = CursorState { offset: cursor_offset, affinity: Affinity::Before, }; // Set up the Loro cursor let buffer = buffer; buffer.sync_cursor(cursor_offset); Self { buffer, title, path, created_at, tags, embeds, entry_ref: Signal::new(state.entry_ref), notebook_uri: Signal::new(state.notebook_uri), edit_root: Signal::new(state.edit_root), last_diff: Signal::new(state.last_diff), // Use the synced version from state (tracks the PDS version vector) last_synced_version: Signal::new(state.synced_version), last_seen_diffs: Signal::new(state.last_seen_diffs), cursor: Signal::new(cursor_state), selection: Signal::new(None), composition: Signal::new(None), composition_ended_at: Signal::new(None), content_changed: Signal::new(()), pending_snap: Signal::new(None), collected_refs: Signal::new(Vec::new()), } } } impl PartialEq for SignalEditorDocument { fn eq(&self, _other: &Self) -> bool { // SignalEditorDocument uses interior mutability, so we can't meaningfully compare. // Return false to ensure components re-render when passed as props. false } } impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument { fn export_snapshot(&self) -> Vec { self.export_snapshot() } fn export_updates_since_sync(&self) -> Option> { self.export_updates_since_sync() } fn import(&mut self, data: &[u8]) -> Result<(), weaver_editor_crdt::CrdtError> { self.import_updates(data) .map_err(|e| weaver_editor_crdt::CrdtError::Import(e.to_string())) } fn version(&self) -> VersionVector { self.version_vector() } fn edit_root(&self) -> Option> { SignalEditorDocument::edit_root(self) } fn set_edit_root(&mut self, root: Option>) { SignalEditorDocument::set_edit_root(self, root); } fn last_diff(&self) -> Option> { SignalEditorDocument::last_diff(self) } fn set_last_diff(&mut self, diff: Option>) { SignalEditorDocument::set_last_diff(self, diff); } fn mark_synced(&mut self) { SignalEditorDocument::mark_synced(self); } fn has_unsynced_changes(&self) -> bool { SignalEditorDocument::has_unsynced_changes(self) } } impl EditorDocument for SignalEditorDocument { type Buffer = LoroTextBuffer; fn buffer(&self) -> &Self::Buffer { &self.buffer } fn buffer_mut(&mut self) -> &mut Self::Buffer { &mut self.buffer } fn cursor(&self) -> CursorState { *self.cursor.read() } fn set_cursor(&mut self, cursor: CursorState) { self.cursor.set(cursor); } fn selection(&self) -> Option { self.selection.read().clone() } fn set_selection(&mut self, selection: Option) { self.selection.set(selection); } fn last_edit(&self) -> Option { self.buffer.last_edit() } fn set_last_edit(&mut self, _edit: Option) { // Buffer tracks edit info internally. We use this hook to // bump content_changed for reactive re-rendering. self.content_changed.set(()); } fn composition(&self) -> Option { self.composition.read().clone() } fn set_composition(&mut self, composition: Option) { self.composition.set(composition); } fn composition_ended_at(&self) -> Option { *self.composition_ended_at.read() } fn set_composition_ended_now(&mut self) { self.composition_ended_at.set(Some(web_time::Instant::now())); } fn undo(&mut self) -> bool { // Sync Loro cursor to current position BEFORE undo // so it tracks through the undo operation. self.sync_loro_cursor(); let result = self.buffer.undo(); if result { // After undo, query Loro cursor for new position. self.sync_cursor_from_loro(); // Signal content change for re-render. self.content_changed.set(()); } result } fn redo(&mut self) -> bool { // Sync Loro cursor to current position BEFORE redo. self.sync_loro_cursor(); let result = self.buffer.redo(); if result { // After redo, query Loro cursor for new position. self.sync_cursor_from_loro(); // Signal content change for re-render. self.content_changed.set(()); } result } fn pending_snap(&self) -> Option { *self.pending_snap.read() } fn set_pending_snap(&mut self, snap: Option) { self.pending_snap.set(snap); } }