···1717use dioxus::prelude::*;
18181919use super::actions::{EditorAction, execute_action};
2020-use super::document::EditorDocument;
2020+use super::document::SignalEditorDocument;
2121use super::platform::Platform;
22222323// Re-export types from extracted crates.
···3333/// Returns whether the event was handled and default should be prevented.
3434#[allow(dead_code)]
3535pub fn handle_beforeinput(
3636- doc: &mut EditorDocument,
3636+ doc: &mut SignalEditorDocument,
3737 ctx: BeforeInputContext<'_>,
3838) -> BeforeInputResult {
3939 // During composition, let the browser handle most things.
···316316}
317317318318/// Get the current range based on cursor and selection state.
319319-fn get_current_range(doc: &EditorDocument) -> Range {
319319+fn get_current_range(doc: &SignalEditorDocument) -> Range {
320320 if let Some(sel) = *doc.selection.read() {
321321 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
322322 Range::new(start, end)
+47-24
crates/weaver-app/src/components/editor/collab.rs
···11111212// Only compile for WASM - no-op stub provided at end
13131414-use super::document::EditorDocument;
1414+use super::document::SignalEditorDocument;
15151616use dioxus::prelude::*;
17171818#[cfg(target_arch = "wasm32")]
1919-use jacquard::smol_str::{format_smolstr, SmolStr};
1919+use jacquard::smol_str::{SmolStr, format_smolstr};
2020#[cfg(target_arch = "wasm32")]
2121use jacquard::types::string::AtUri;
2222···3838#[derive(Props, Clone, PartialEq)]
3939pub struct CollabCoordinatorProps {
4040 /// The editor document to sync
4141- pub document: EditorDocument,
4141+ pub document: SignalEditorDocument,
4242 /// Resource URI for the document being edited
4343 pub resource_uri: String,
4444 /// Presence state signal (updated by coordinator)
···239239 let strong_ref = match fetcher.confirm_record_ref(&uri).await {
240240 Ok(r) => r,
241241 Err(e) => {
242242- let err = format_smolstr!("Failed to get resource ref: {e}");
242242+ let err =
243243+ format_smolstr!("Failed to get resource ref: {e}");
243244 debug_state
244245 .with_mut(|ds| ds.last_error = Some(err.clone()));
245246 state.set(CoordinatorState::Error(err));
···336337 })
337338 .await
338339 {
339339- tracing::error!("CollabCoordinator: AddPeers send failed: {e}");
340340+ tracing::error!(
341341+ "CollabCoordinator: AddPeers send failed: {e}"
342342+ );
340343 }
341344 } else {
342345 tracing::error!("CollabCoordinator: sink is None!");
···395398 let fetcher = fetcher.clone();
396399397400 // Get our profile info and send BroadcastJoin
398398- let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await {
401401+ let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher
402402+ .current_did()
403403+ .await
404404+ {
399405 Some(did) => {
400400- let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await {
401401- Ok(profile) => {
402402- match &profile.inner {
403403- ProfileDataViewInner::ProfileView(p) => {
404404- p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into())
405405- }
406406- ProfileDataViewInner::ProfileViewDetailed(p) => {
407407- p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into())
408408- }
406406+ let display_name: SmolStr =
407407+ match fetcher.fetch_profile(&did.clone().into()).await {
408408+ Ok(profile) => match &profile.inner {
409409+ ProfileDataViewInner::ProfileView(p) => p
410410+ .display_name
411411+ .as_ref()
412412+ .map(|s| s.as_ref().into())
413413+ .unwrap_or_else(|| did.as_ref().into()),
414414+ ProfileDataViewInner::ProfileViewDetailed(p) => p
415415+ .display_name
416416+ .as_ref()
417417+ .map(|s| s.as_ref().into())
418418+ .unwrap_or_else(|| did.as_ref().into()),
409419 ProfileDataViewInner::TangledProfileView(p) => {
410420 p.handle.as_ref().into()
411421 }
412422 _ => did.as_ref().into(),
413413- }
414414- }
415415- Err(_) => did.as_ref().into(),
416416- };
423423+ },
424424+ Err(_) => did.as_ref().into(),
425425+ };
417426 (did.as_ref().into(), display_name)
418427 }
419428 None => {
420420- tracing::warn!("CollabCoordinator: no current DID for Join message");
429429+ tracing::warn!(
430430+ "CollabCoordinator: no current DID for Join message"
431431+ );
421432 ("unknown".into(), "Anonymous".into())
422433 }
423434 };
···430441 })
431442 .await
432443 {
433433- tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}");
444444+ tracing::error!(
445445+ "CollabCoordinator: BroadcastJoin send failed: {e}"
446446+ );
434447 }
435448 }
436449 }
···460473 let position = cursor.offset;
461474 let sel = selection.map(|s| (s.anchor, s.head));
462475463463- tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting");
476476+ tracing::debug!(
477477+ position,
478478+ ?sel,
479479+ "CollabCoordinator: cursor changed, broadcasting"
480480+ );
464481465482 spawn(async move {
466483 if let Some(ref mut s) = *worker_sink.write() {
467467- tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker");
484484+ tracing::debug!(
485485+ position,
486486+ "CollabCoordinator: sending BroadcastCursor to worker"
487487+ );
468488 if let Err(e) = s
469489 .send(WorkerInput::BroadcastCursor {
470490 position,
···475495 tracing::warn!("Failed to send BroadcastCursor to worker: {e}");
476496 }
477497 } else {
478478- tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast");
498498+ tracing::debug!(
499499+ position,
500500+ "CollabCoordinator: worker sink not ready, skipping cursor broadcast"
501501+ );
479502 }
480503 });
481504 });
···77//!
88//! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity:
99//! - Cursor/selection changes don't trigger content re-renders
1010-//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation
1010+//! - Content changes bump `content_changed` Signal to trigger paragraph re-renders
1111//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
12121313-use std::borrow::Cow;
1414-use std::cell::RefCell;
1515-use std::rc::Rc;
1616-1713use dioxus::prelude::*;
1814use loro::{
1919- ExportMode, Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson,
2020- UndoManager, VersionVector,
2121- cursor::{Cursor, Side},
1515+ Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector,
1616+ cursor::Cursor,
2217};
23182419use jacquard::IntoStatic;
···2823use weaver_api::com_atproto::repo::strong_ref::StrongRef;
2924use weaver_api::sh_weaver::embed::images::Image;
3025use weaver_api::sh_weaver::embed::records::RecordEmbed;
3131-pub use weaver_editor_core::{
3232- Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
3333-};
3426use weaver_api::sh_weaver::notebook::entry::Entry;
2727+use weaver_editor_core::EditorDocument;
2828+use weaver_editor_core::TextBuffer;
2929+use weaver_editor_core::UndoManager;
3030+pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection};
3131+use weaver_editor_crdt::LoroTextBuffer;
35323633/// Helper for working with editor images.
3734/// Constructed from LoroMap data, NOT serialized directly.
···47444845/// Single source of truth for editor state.
4946///
5050-/// Contains the document text (backed by Loro CRDT), cursor position,
4747+/// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position,
5148/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
5249/// schema with CRDT containers for each field.
5350///
···5653/// The document itself is NOT wrapped in a Signal. Instead, individual fields
5754/// that need reactivity are wrapped in Signals:
5855/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
5959-/// - `last_edit` - triggers paragraph re-renders when content changes
5656+/// - `content_changed` - bumped to trigger paragraph re-renders when content changes
6057///
6161-/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`.
5858+/// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`.
6259///
6360/// # Cloning
6461///
6565-/// EditorDocument is cheap to clone - Loro types are Arc-backed handles,
6262+/// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed,
6663/// and Signals are Copy. Closures can capture clones without overhead.
6764#[derive(Clone)]
6868-pub struct EditorDocument {
6969- /// The Loro document containing all editor state.
7070- doc: LoroDoc,
6565+pub struct SignalEditorDocument {
6666+ /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking.
6767+ /// Access the underlying LoroDoc via `buffer.doc()`.
6868+ buffer: LoroTextBuffer,
71697270 // --- Entry schema containers (Loro handles interior mutability) ---
7373- /// Markdown content (maps to entry.content)
7474- content: LoroText,
7575-7171+ // These are obtained from buffer.doc() but cached for convenience.
7672 /// Entry title (maps to entry.title)
7773 title: LoroText,
7874···118114 /// Maps root URI -> last diff URI we've imported from that root.
119115 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this.
120116 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>,
121121-122122- // --- Editor state (non-reactive) ---
123123- /// Undo manager for the document.
124124- undo_mgr: Rc<RefCell<UndoManager>>,
125125-126126- /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
127127- /// Recreated after our own edits, queried after undo/redo/remote edits.
128128- loro_cursor: Option<Cursor>,
129117130118 // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
131119 /// Current cursor position. Signal so cursor changes don't dirty content memos.
···141129 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
142130 pub composition_ended_at: Signal<Option<web_time::Instant>>,
143131144144- /// Most recent edit info for incremental rendering optimization.
145145- /// Signal so paragraphs memo can subscribe to content changes only.
146146- pub last_edit: Signal<Option<EditInfo>>,
132132+ /// Bumped when content changes to trigger paragraph re-renders.
133133+ /// Actual EditInfo is obtained from `buffer.last_edit()`.
134134+ pub content_changed: Signal<()>,
147135148136 /// Pending snap direction for cursor restoration after edits.
149137 /// Set by input handlers, consumed by cursor restoration.
···154142 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
155143}
156144157157-// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE
158158-// are imported from weaver_editor_core.
159159-160145/// Pre-loaded document state that can be created outside of reactive context.
161146///
162147/// This struct holds the raw LoroDoc (which is safe outside reactive context)
163163-/// along with sync state metadata. Use `EditorDocument::from_loaded_state()`
164164-/// inside a `use_hook` to convert this into a reactive EditorDocument.
148148+/// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()`
149149+/// inside a `use_hook` to convert this into a reactive SignalEditorDocument.
165150///
166151/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed).
167152/// PartialEq always returns false since we can't meaningfully compare docs.
···197182 }
198183}
199184200200-impl EditorDocument {
201201- /// Check if a character position is within the block-syntax zone of its line.
202202- fn is_in_block_syntax_zone(&self, pos: usize) -> bool {
203203- if pos <= BLOCK_SYNTAX_ZONE {
204204- return true;
205205- }
206206-207207- // Search backwards from pos-1, only need to check BLOCK_SYNTAX_ZONE + 1 chars.
208208- let search_start = pos.saturating_sub(BLOCK_SYNTAX_ZONE + 1);
209209- for i in (search_start..pos).rev() {
210210- if self.content.char_at(i).ok() == Some('\n') {
211211- // Found newline at i, distance from line start is pos - i - 1.
212212- return (pos - i - 1) <= BLOCK_SYNTAX_ZONE;
213213- }
214214- }
215215- // No newline found in search range, and pos > BLOCK_SYNTAX_ZONE, so not in zone.
216216- false
217217- }
218218-185185+impl SignalEditorDocument {
219186 /// Create a new editor document with the given content.
220187 /// Sets `created_at` to current time.
221188 ///
222189 /// # Note
223190 /// This creates Dioxus Signals for reactive fields. Call from within
224224- /// a component using `use_hook(|| EditorDocument::new(...))`.
191191+ /// a component using `use_hook(|| SignalEditorDocument::new(...))`.
225192 pub fn new(initial_content: String) -> Self {
226226- let doc = LoroDoc::new();
193193+ // Create the LoroTextBuffer which owns the LoroDoc
194194+ let mut buffer = LoroTextBuffer::new();
195195+ let doc = buffer.doc().clone();
227196228228- // Get all containers
229229- let content = doc.get_text("content");
197197+ // Get other containers from the doc
230198 let title = doc.get_text("title");
231199 let path = doc.get_text("path");
232200 let created_at = doc.get_text("created_at");
···235203236204 // Insert initial content if any
237205 if !initial_content.is_empty() {
238238- content
239239- .insert(0, &initial_content)
240240- .expect("failed to insert initial content");
206206+ buffer.insert(0, &initial_content);
241207 }
242208243209 // Set created_at to current time (ISO 8601)
···246212 .insert(0, &now)
247213 .expect("failed to set created_at");
248214249249- // Set up undo manager with merge interval for batching keystrokes
250250- let mut undo_mgr = UndoManager::new(&doc);
251251- undo_mgr.set_merge_interval(300); // 300ms merge window
252252- undo_mgr.set_max_undo_steps(100);
253253-254254- // Create initial Loro cursor at position 0
255255- let loro_cursor = content.get_cursor(0, Side::default());
256256-257215 Self {
258258- doc,
259259- content,
216216+ buffer,
260217 title,
261218 path,
262219 created_at,
···268225 last_diff: Signal::new(None),
269226 last_synced_version: Signal::new(None),
270227 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
271271- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
272272- loro_cursor,
273273- // Reactive editor state - wrapped in Signals
274228 cursor: Signal::new(CursorState {
275229 offset: 0,
276230 affinity: Affinity::Before,
···278232 selection: Signal::new(None),
279233 composition: Signal::new(None),
280234 composition_ended_at: Signal::new(None),
281281- last_edit: Signal::new(None),
235235+ content_changed: Signal::new(()),
282236 pending_snap: Signal::new(None),
283237 collected_refs: Signal::new(Vec::new()),
284238 }
285239 }
286240287287- /// Create an EditorDocument from a fetched Entry.
241241+ /// Create a SignalEditorDocument from a fetched Entry.
288242 ///
289243 /// MUST be called from within a reactive context (e.g., `use_hook`) to
290244 /// properly initialize Dioxus Signals.
···341295342296 /// Get the underlying LoroText for read operations on content.
343297 pub fn loro_text(&self) -> &LoroText {
344344- &self.content
298298+ self.buffer.content()
345299 }
346300347301 /// Get the underlying LoroDoc for subscriptions and advanced operations.
348302 pub fn loro_doc(&self) -> &LoroDoc {
349349- &self.doc
303303+ self.buffer.doc()
304304+ }
305305+306306+ /// Get direct access to the LoroTextBuffer.
307307+ pub fn buffer(&self) -> &LoroTextBuffer {
308308+ &self.buffer
309309+ }
310310+311311+ /// Get mutable access to the LoroTextBuffer.
312312+ pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer {
313313+ &mut self.buffer
350314 }
351315352316 // --- Content accessors ---
353317354318 /// Get the markdown content as a string.
355319 pub fn content(&self) -> String {
356356- self.content.to_string()
320320+ weaver_editor_core::TextBuffer::to_string(&self.buffer)
357321 }
358322359323 /// Convert the document content to a string (alias for content()).
360324 pub fn to_string(&self) -> String {
361361- self.content.to_string()
325325+ weaver_editor_core::TextBuffer::to_string(&self.buffer)
362326 }
363327364328 /// Get the length of the content in characters.
365329 pub fn len_chars(&self) -> usize {
366366- self.content.len_unicode()
330330+ weaver_editor_core::TextBuffer::len_chars(&self.buffer)
367331 }
368332369333 /// Get the length of the content in UTF-8 bytes.
370334 pub fn len_bytes(&self) -> usize {
371371- self.content.len_utf8()
335335+ weaver_editor_core::TextBuffer::len_bytes(&self.buffer)
372336 }
373337374338 /// Get the length of the content in UTF-16 code units.
375339 pub fn len_utf16(&self) -> usize {
376376- self.content.len_utf16()
340340+ self.buffer.content().len_utf16()
377341 }
378342379343 /// Check if the content is empty.
380344 pub fn is_empty(&self) -> bool {
381381- self.content.len_unicode() == 0
345345+ weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0
382346 }
383347384348 // --- Entry metadata accessors ---
···652616 .map(|r| r.into_static())
653617 }
654618655655- /// Insert text into content and record edit info for incremental rendering.
619619+ /// Insert text into content and bump content_changed for re-rendering.
620620+ /// Edit info is tracked automatically by the buffer.
656621 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
657657- let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
658658- let len_before = self.content.len_unicode();
659659- let result = self.content.insert(pos, text);
660660- let len_after = self.content.len_unicode();
661661- self.last_edit.set(Some(EditInfo {
662662- edit_char_pos: pos,
663663- inserted_len: len_after.saturating_sub(len_before),
664664- deleted_len: 0,
665665- contains_newline: text.contains('\n'),
666666- in_block_syntax_zone,
667667- doc_len_after: len_after,
668668- timestamp: web_time::Instant::now(),
669669- }));
670670- result
622622+ self.buffer.insert(pos, text);
623623+ self.content_changed.set(());
624624+ Ok(())
671625 }
672626673627 /// Push text to end of content. Faster than insert for appending.
674628 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
675675- let pos = self.content.len_unicode();
676676- let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
677677- let result = self.content.push_str(text);
678678- let len_after = self.content.len_unicode();
679679- self.last_edit.set(Some(EditInfo {
680680- edit_char_pos: pos,
681681- inserted_len: text.chars().count(),
682682- deleted_len: 0,
683683- contains_newline: text.contains('\n'),
684684- in_block_syntax_zone,
685685- doc_len_after: len_after,
686686- timestamp: web_time::Instant::now(),
687687- }));
688688- result
629629+ let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
630630+ self.buffer.insert(pos, text);
631631+ self.content_changed.set(());
632632+ Ok(())
689633 }
690634691691- /// Remove text range from content and record edit info for incremental rendering.
635635+ /// Remove text range from content and bump content_changed for re-rendering.
636636+ /// Edit info is tracked automatically by the buffer.
692637 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
693693- let contains_newline = self
694694- .content
695695- .slice(start, start + len)
696696- .map(|s| s.contains('\n'))
697697- .unwrap_or(false);
698698- let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
699699-700700- let result = self.content.delete(start, len);
701701- self.last_edit.set(Some(EditInfo {
702702- edit_char_pos: start,
703703- inserted_len: 0,
704704- deleted_len: len,
705705- contains_newline,
706706- in_block_syntax_zone,
707707- doc_len_after: self.content.len_unicode(),
708708- timestamp: web_time::Instant::now(),
709709- }));
710710- result
638638+ self.buffer.delete(start..start + len);
639639+ self.content_changed.set(());
640640+ Ok(())
711641 }
712642713713- /// Replace text in content (delete then insert) and record combined edit info.
643643+ /// Replace text in content (atomic splice) and bump content_changed.
644644+ /// Edit info is tracked automatically by the buffer.
714645 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
715715- let delete_has_newline = self
716716- .content
717717- .slice(start, start + len)
718718- .map(|s| s.contains('\n'))
719719- .unwrap_or(false);
720720- let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
721721-722722- let len_before = self.content.len_unicode();
723723- // Use splice for atomic replace
724724- self.content.splice(start, len, text)?;
725725- let len_after = self.content.len_unicode();
726726-727727- // inserted_len = (len_after - len_before) + deleted_len
728728- // because: len_after = len_before - deleted + inserted
729729- let inserted_len = (len_after + len).saturating_sub(len_before);
730730-731731- self.last_edit.set(Some(EditInfo {
732732- edit_char_pos: start,
733733- inserted_len,
734734- deleted_len: len,
735735- contains_newline: delete_has_newline || text.contains('\n'),
736736- in_block_syntax_zone,
737737- doc_len_after: len_after,
738738- timestamp: web_time::Instant::now(),
739739- }));
646646+ self.buffer.replace(start..start + len, text);
647647+ self.content_changed.set(());
740648 Ok(())
741649 }
742650···746654 // so it tracks through the undo operation
747655 self.sync_loro_cursor();
748656749749- let result = self.undo_mgr.borrow_mut().undo()?;
657657+ let result = self.buffer.undo();
750658 if result {
751659 // After undo, query Loro cursor for new position
752660 self.sync_cursor_from_loro();
753661 // Signal content change for re-render
754754- self.last_edit.set(None);
662662+ self.content_changed.set(());
755663 }
756664 Ok(result)
757665 }
···761669 // Sync Loro cursor to current position BEFORE redo
762670 self.sync_loro_cursor();
763671764764- let result = self.undo_mgr.borrow_mut().redo()?;
672672+ let result = self.buffer.redo();
765673 if result {
766674 // After redo, query Loro cursor for new position
767675 self.sync_cursor_from_loro();
768676 // Signal content change for re-render
769769- self.last_edit.set(None);
677677+ self.content_changed.set(());
770678 }
771679 Ok(result)
772680 }
773681774682 /// Check if undo is available.
775683 pub fn can_undo(&self) -> bool {
776776- self.undo_mgr.borrow().can_undo()
684684+ UndoManager::can_undo(&self.buffer)
777685 }
778686779687 /// Check if redo is available.
780688 pub fn can_redo(&self) -> bool {
781781- self.undo_mgr.borrow().can_redo()
689689+ UndoManager::can_redo(&self.buffer)
782690 }
783691784692 /// Get a slice of the content text.
785693 /// Returns None if the range is invalid.
786786- pub fn slice(&self, start: usize, end: usize) -> Option<String> {
787787- self.content.slice(start, end).ok()
694694+ pub fn slice(&self, start: usize, end: usize) -> Option<SmolStr> {
695695+ self.buffer.slice(start..end)
788696 }
789697790698 /// Sync the Loro cursor to the current cursor.offset position.
···792700 pub fn sync_loro_cursor(&mut self) {
793701 let offset = self.cursor.read().offset;
794702 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro");
795795- self.loro_cursor = self.content.get_cursor(offset, Side::default());
703703+ self.buffer.sync_cursor(offset);
796704 }
797705798706 /// Update cursor.offset from the Loro cursor's tracked position.
799707 /// Call this after undo/redo or remote edits where the position may have shifted.
800708 /// Returns the new offset, or None if the cursor couldn't be resolved.
801709 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
802802- let loro_cursor = self.loro_cursor.as_ref()?;
803803- let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
804710 let old_offset = self.cursor.read().offset;
805805- let new_offset = result.current.pos.min(self.len_chars());
806806- let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset };
711711+ let new_offset = self.buffer.resolve_cursor()?;
712712+ let jump = if new_offset > old_offset {
713713+ new_offset - old_offset
714714+ } else {
715715+ old_offset - new_offset
716716+ };
807717 if jump > 100 {
808718 tracing::warn!(
809719 old_offset,
···812722 "sync_cursor_from_loro: LARGE CURSOR JUMP detected"
813723 );
814724 }
815815- tracing::debug!(old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro");
725725+ tracing::debug!(
726726+ old_offset,
727727+ new_offset,
728728+ "sync_cursor_from_loro: updating cursor from Loro"
729729+ );
816730 self.cursor.with_mut(|c| c.offset = new_offset);
817731 Some(new_offset)
818732 }
819733820734 /// Get the Loro cursor for serialization.
821821- pub fn loro_cursor(&self) -> Option<&Cursor> {
822822- self.loro_cursor.as_ref()
735735+ pub fn loro_cursor(&self) -> Option<Cursor> {
736736+ self.buffer.loro_cursor()
823737 }
824738825739 /// Set the Loro cursor (used when restoring from storage).
826740 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) {
827741 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called");
828828- self.loro_cursor = cursor;
742742+ self.buffer.set_loro_cursor(cursor);
829743 // Sync cursor.offset from the restored Loro cursor
830830- if self.loro_cursor.is_some() {
744744+ if self.buffer.loro_cursor().is_some() {
831745 self.sync_cursor_from_loro();
832746 }
833747 }
···835749 /// Export the document as a binary snapshot.
836750 /// This captures all CRDT state including undo history.
837751 pub fn export_snapshot(&self) -> Vec<u8> {
838838- self.doc.export(ExportMode::Snapshot).unwrap_or_default()
752752+ self.buffer.export_snapshot()
839753 }
840754841755 /// Get the current state frontiers for change detection.
842756 /// Frontiers represent the "version" of the document state.
843757 pub fn state_frontiers(&self) -> Frontiers {
844844- self.doc.state_frontiers()
758758+ self.buffer.doc().state_frontiers()
845759 }
846760847761 /// Get the current version vector.
848762 pub fn version_vector(&self) -> VersionVector {
849849- self.doc.oplog_vv()
763763+ self.buffer.version()
850764 }
851765852766 /// Get the last edit info for incremental rendering.
853853- /// Reading this creates a reactive dependency on content changes.
767767+ /// This comes from the buffer's internal tracking.
854768 pub fn last_edit(&self) -> Option<EditInfo> {
855855- self.last_edit.read().clone()
769769+ self.buffer.last_edit()
770770+ }
771771+772772+ /// Bump the content_changed signal to trigger re-renders.
773773+ /// Call this after remote imports or other external content changes.
774774+ pub fn notify_content_changed(&mut self) {
775775+ self.content_changed.set(());
856776 }
857777858778 // --- Collected refs accessors ---
···916836 /// Check if there are unsynced changes since the last PDS sync.
917837 pub fn has_unsynced_changes(&self) -> bool {
918838 match &*self.last_synced_version.read() {
919919- Some(synced_vv) => self.doc.oplog_vv() != *synced_vv,
839839+ Some(synced_vv) => self.buffer.version() != *synced_vv,
920840 None => true, // Never synced, so there are changes
921841 }
922842 }
···926846 /// After successful upload, call `mark_synced()` to update the sync marker.
927847 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
928848 let from_vv = self.last_synced_version.read().clone().unwrap_or_default();
929929- let current_vv = self.doc.oplog_vv();
930930-931931- // No changes since last sync
932932- if from_vv == current_vv {
933933- return None;
934934- }
935935-936936- let updates = self
937937- .doc
938938- .export(ExportMode::Updates {
939939- from: Cow::Owned(from_vv),
940940- })
941941- .ok()?;
942942-943943- // Don't return empty updates
944944- if updates.is_empty() {
945945- return None;
946946- }
947947-948948- Some(updates)
849849+ self.buffer.export_updates_since(&from_vv)
949850 }
950851951852 /// Mark the current state as synced.
952853 /// Call this after successfully uploading a diff to the PDS.
953854 pub fn mark_synced(&mut self) {
954954- self.last_synced_version.set(Some(self.doc.oplog_vv()));
855855+ self.last_synced_version.set(Some(self.buffer.version()));
955856 }
956857957858 /// Import updates from a PDS diff blob.
958859 /// Used when loading edit history from the PDS.
959860 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> {
960960- let len_before = self.content.len_unicode();
961961- let vv_before = self.doc.oplog_vv();
861861+ let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
862862+ let vv_before = self.buffer.version();
962863963963- self.doc.import(updates)?;
864864+ self.buffer
865865+ .import(updates)
866866+ .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?;
964867965965- let len_after = self.content.len_unicode();
966966- let vv_after = self.doc.oplog_vv();
868868+ let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
869869+ let vv_after = self.buffer.version();
967870 let vv_changed = vv_before != vv_after;
968871 let len_changed = len_before != len_after;
969872···977880978881 // Only trigger re-render if something actually changed
979882 if vv_changed {
980980- self.last_edit.set(None);
883883+ self.content_changed.set(());
981884 }
982885 Ok(())
983886 }
···985888 /// Export updates since the given version vector.
986889 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync.
987890 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> {
988988- let current_vv = self.doc.oplog_vv();
989989-990990- // No changes since the given version
991991- if *from_vv == current_vv {
992992- return None;
993993- }
994994-995995- let updates = self
996996- .doc
997997- .export(ExportMode::Updates {
998998- from: Cow::Borrowed(from_vv),
999999- })
10001000- .ok()?;
10011001-10021002- if updates.is_empty() {
10031003- return None;
10041004- }
10051005-10061006- Some(updates)
891891+ self.buffer.export_updates_since(from_vv)
1007892 }
10088931009894 /// Set the sync state when loading from PDS.
···1016901 ) {
1017902 self.edit_root.set(Some(edit_root));
1018903 self.last_diff.set(last_diff);
10191019- self.last_synced_version.set(Some(self.doc.oplog_vv()));
904904+ self.last_synced_version.set(Some(self.buffer.version()));
1020905 }
102190610221022- /// Create a new EditorDocument from a binary snapshot.
907907+ /// Create a new SignalEditorDocument from a binary snapshot.
1023908 /// Falls back to empty document if import fails.
1024909 ///
1025910 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
···1037922 loro_cursor: Option<Cursor>,
1038923 fallback_offset: usize,
1039924 ) -> Self {
10401040- let doc = LoroDoc::new();
10411041-10421042- if !snapshot.is_empty() {
10431043- if let Err(e) = doc.import(snapshot) {
10441044- tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
925925+ // Create buffer from snapshot
926926+ let buffer = if snapshot.is_empty() {
927927+ LoroTextBuffer::new()
928928+ } else {
929929+ match LoroTextBuffer::from_snapshot(snapshot) {
930930+ Ok(buf) => buf,
931931+ Err(e) => {
932932+ tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
933933+ LoroTextBuffer::new()
934934+ }
1045935 }
10461046- }
936936+ };
104793710481048- // Get all containers (they will contain data from the snapshot if import succeeded)
10491049- let content = doc.get_text("content");
938938+ let doc = buffer.doc().clone();
939939+940940+ // Get other containers from the doc
1050941 let title = doc.get_text("title");
1051942 let path = doc.get_text("path");
1052943 let created_at = doc.get_text("created_at");
1053944 let tags = doc.get_list("tags");
1054945 let embeds = doc.get_map("embeds");
105594610561056- // Set up undo manager - tracks operations from this point forward only
10571057- let mut undo_mgr = UndoManager::new(&doc);
10581058- undo_mgr.set_merge_interval(300);
10591059- undo_mgr.set_max_undo_steps(100);
10601060-1061947 // Try to restore cursor from Loro cursor, fall back to offset
10621062- let max_offset = content.len_unicode();
948948+ let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
1063949 let cursor_offset = if let Some(ref lc) = loro_cursor {
1064950 doc.get_cursor_pos(lc)
1065951 .map(|r| r.current.pos)
···1073959 affinity: Affinity::Before,
1074960 };
107596110761076- // If no Loro cursor provided, create one at the restored position
10771077- let loro_cursor =
10781078- loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default()));
962962+ // Set up the Loro cursor
963963+ let buffer = buffer;
964964+ if let Some(lc) = loro_cursor {
965965+ buffer.set_loro_cursor(Some(lc));
966966+ } else {
967967+ buffer.sync_cursor(cursor_state.offset);
968968+ }
10799691080970 Self {
10811081- doc,
10821082- content,
971971+ buffer,
1083972 title,
1084973 path,
1085974 created_at,
···1091980 last_diff: Signal::new(None),
1092981 last_synced_version: Signal::new(None),
1093982 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
10941094- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
10951095- loro_cursor,
10961096- // Reactive editor state - wrapped in Signals
1097983 cursor: Signal::new(cursor_state),
1098984 selection: Signal::new(None),
1099985 composition: Signal::new(None),
1100986 composition_ended_at: Signal::new(None),
11011101- last_edit: Signal::new(None),
987987+ content_changed: Signal::new(()),
1102988 pending_snap: Signal::new(None),
1103989 collected_refs: Signal::new(Vec::new()),
1104990 }
1105991 }
110699211071107- /// Create an EditorDocument from pre-loaded state.
993993+ /// Create a SignalEditorDocument from pre-loaded state.
1108994 ///
1109995 /// Use this when loading from PDS/localStorage merge outside reactive context.
1110996 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it
···1113999 /// # Note
11141000 /// This creates Dioxus Signals. Call from within a component using `use_hook`.
11151001 pub fn from_loaded_state(state: LoadedDocState) -> Self {
11161116- let doc = state.doc;
10021002+ // Create buffer from the loaded doc
10031003+ let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content");
10041004+ let doc = buffer.doc().clone();
1117100511181118- // Get all containers from the loaded doc
11191119- let content = doc.get_text("content");
10061006+ // Get other containers from the doc
11201007 let title = doc.get_text("title");
11211008 let path = doc.get_text("path");
11221009 let created_at = doc.get_text("created_at");
11231010 let tags = doc.get_list("tags");
11241011 let embeds = doc.get_map("embeds");
1125101211261126- // Set up undo manager
11271127- let mut undo_mgr = UndoManager::new(&doc);
11281128- undo_mgr.set_merge_interval(300);
11291129- undo_mgr.set_max_undo_steps(100);
11301130-11311013 // Position cursor at end of content
11321132- let cursor_offset = content.len_unicode();
10141014+ let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
11331015 let cursor_state = CursorState {
11341016 offset: cursor_offset,
11351017 affinity: Affinity::Before,
11361018 };
11371137- let loro_cursor = content.get_cursor(cursor_offset, Side::default());
10191019+10201020+ // Set up the Loro cursor
10211021+ let buffer = buffer;
10221022+ buffer.sync_cursor(cursor_offset);
1138102311391024 Self {
11401140- doc,
11411141- content,
10251025+ buffer,
11421026 title,
11431027 path,
11441028 created_at,
···11511035 // Use the synced version from state (tracks the PDS version vector)
11521036 last_synced_version: Signal::new(state.synced_version),
11531037 last_seen_diffs: Signal::new(state.last_seen_diffs),
11541154- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
11551155- loro_cursor,
11561038 cursor: Signal::new(cursor_state),
11571039 selection: Signal::new(None),
11581040 composition: Signal::new(None),
11591041 composition_ended_at: Signal::new(None),
11601160- last_edit: Signal::new(None),
10421042+ content_changed: Signal::new(()),
11611043 pending_snap: Signal::new(None),
11621044 collected_refs: Signal::new(Vec::new()),
11631045 }
11641046 }
11651047}
1166104811671167-impl PartialEq for EditorDocument {
10491049+impl PartialEq for SignalEditorDocument {
11681050 fn eq(&self, _other: &Self) -> bool {
11691169- // EditorDocument uses interior mutability, so we can't meaningfully compare.
10511051+ // SignalEditorDocument uses interior mutability, so we can't meaningfully compare.
11701052 // Return false to ensure components re-render when passed as props.
11711053 false
11721054 }
11731055}
1174105611751175-impl weaver_editor_crdt::CrdtDocument for EditorDocument {
10571057+impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument {
11761058 fn export_snapshot(&self) -> Vec<u8> {
11771059 self.export_snapshot()
11781060 }
···11911073 }
1192107411931075 fn edit_root(&self) -> Option<StrongRef<'static>> {
11941194- self.edit_root()
10761076+ SignalEditorDocument::edit_root(self)
11951077 }
1196107811971079 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
11981198- self.set_edit_root(root);
10801080+ SignalEditorDocument::set_edit_root(self, root);
11991081 }
1200108212011083 fn last_diff(&self) -> Option<StrongRef<'static>> {
12021202- self.last_diff()
10841084+ SignalEditorDocument::last_diff(self)
12031085 }
1204108612051087 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
12061206- self.set_last_diff(diff);
10881088+ SignalEditorDocument::set_last_diff(self, diff);
12071089 }
1208109012091091 fn mark_synced(&mut self) {
12101210- self.mark_synced();
10921092+ SignalEditorDocument::mark_synced(self);
12111093 }
1212109412131095 fn has_unsynced_changes(&self) -> bool {
12141214- self.has_unsynced_changes()
10961096+ SignalEditorDocument::has_unsynced_changes(self)
10971097+ }
10981098+}
10991099+11001100+impl EditorDocument for SignalEditorDocument {
11011101+ type Buffer = LoroTextBuffer;
11021102+11031103+ fn buffer(&self) -> &Self::Buffer {
11041104+ &self.buffer
11051105+ }
11061106+11071107+ fn buffer_mut(&mut self) -> &mut Self::Buffer {
11081108+ &mut self.buffer
11091109+ }
11101110+11111111+ fn cursor(&self) -> CursorState {
11121112+ *self.cursor.read()
11131113+ }
11141114+11151115+ fn set_cursor(&mut self, cursor: CursorState) {
11161116+ self.cursor.set(cursor);
11171117+ }
11181118+11191119+ fn selection(&self) -> Option<Selection> {
11201120+ self.selection.read().clone()
11211121+ }
11221122+11231123+ fn set_selection(&mut self, selection: Option<Selection>) {
11241124+ self.selection.set(selection);
11251125+ }
11261126+11271127+ fn last_edit(&self) -> Option<EditInfo> {
11281128+ self.buffer.last_edit()
11291129+ }
11301130+11311131+ fn set_last_edit(&mut self, _edit: Option<EditInfo>) {
11321132+ // Buffer tracks edit info internally. We use this hook to
11331133+ // bump content_changed for reactive re-rendering.
11341134+ self.content_changed.set(());
11351135+ }
11361136+11371137+ fn composition(&self) -> Option<CompositionState> {
11381138+ self.composition.read().clone()
11391139+ }
11401140+11411141+ fn set_composition(&mut self, composition: Option<CompositionState>) {
11421142+ self.composition.set(composition);
12151143 }
12161144}