diff --git a/crates/weaver-editor-core/src/text.rs b/crates/weaver-editor-core/src/text.rs index 7a02d02..eb1a135 100644 --- a/crates/weaver-editor-core/src/text.rs +++ b/crates/weaver-editor-core/src/text.rs @@ -56,7 +56,7 @@ pub trait TextBuffer { fn byte_to_char(&self, byte_offset: usize) -> usize; /// Get info about the last edit operation, if any. - fn last_edit(&self) -> Option<&EditInfo>; + fn last_edit(&self) -> Option; /// Check if a char offset is in the block-syntax zone (first few chars of a line). fn is_in_block_syntax_zone(&self, offset: usize) -> bool { @@ -198,8 +198,8 @@ impl TextBuffer for EditorRope { self.rope.byte_to_char(byte_offset) } - fn last_edit(&self) -> Option<&EditInfo> { - self.last_edit.as_ref() + fn last_edit(&self) -> Option { + self.last_edit } fn is_in_block_syntax_zone(&self, offset: usize) -> bool { diff --git a/crates/weaver-editor-core/src/types.rs b/crates/weaver-editor-core/src/types.rs index 756b754..075ccc8 100644 --- a/crates/weaver-editor-core/src/types.rs +++ b/crates/weaver-editor-core/src/types.rs @@ -165,7 +165,7 @@ impl CompositionState { /// /// This tracks enough information to determine which paragraphs need re-rendering /// after an edit, enabling efficient incremental updates instead of full re-renders. -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug)] pub struct EditInfo { /// Character offset where the edit occurred pub edit_char_pos: usize, diff --git a/crates/weaver-editor-core/src/undo.rs b/crates/weaver-editor-core/src/undo.rs index f7f32f3..f5129af 100644 --- a/crates/weaver-editor-core/src/undo.rs +++ b/crates/weaver-editor-core/src/undo.rs @@ -159,7 +159,7 @@ impl TextBuffer for UndoableBuffer { self.buffer.byte_to_char(byte_offset) } - fn last_edit(&self) -> Option<&crate::types::EditInfo> { + fn last_edit(&self) -> Option { self.buffer.last_edit() } } diff --git a/crates/weaver-editor-crdt/src/buffer.rs b/crates/weaver-editor-crdt/src/buffer.rs index 1b43892..65438a4 100644 --- a/crates/weaver-editor-crdt/src/buffer.rs +++ b/crates/weaver-editor-crdt/src/buffer.rs @@ -4,23 +4,38 @@ use std::cell::RefCell; use std::ops::Range; use std::rc::Rc; -use loro::{cursor::PosType, LoroDoc, LoroText, UndoManager as LoroUndoManager, VersionVector}; +use loro::{ + cursor::{Cursor, PosType, Side}, + LoroDoc, LoroText, UndoManager as LoroUndoManager, VersionVector, +}; use smol_str::{SmolStr, ToSmolStr}; use web_time::Instant; use weaver_editor_core::{EditInfo, TextBuffer, UndoManager}; use crate::CrdtError; +/// Mutable state that must be shared across clones. +struct LoroTextBufferInner { + undo_mgr: LoroUndoManager, + last_edit: Option, + loro_cursor: Option, +} + /// Loro-backed text buffer with undo/redo support. /// /// Wraps a `LoroDoc` with a text container and provides implementations /// of the `TextBuffer` and `UndoManager` traits from weaver-editor-core. +/// +/// Also provides CRDT-aware cursor tracking that survives remote edits +/// and undo/redo operations. +/// +/// Cloning is cheap and clones share all mutable state (undo history, +/// last edit info, cursor position). #[derive(Clone)] pub struct LoroTextBuffer { doc: LoroDoc, content: LoroText, - undo_mgr: Rc>, - last_edit: Option, + inner: Rc>, } impl LoroTextBuffer { @@ -28,13 +43,16 @@ impl LoroTextBuffer { pub fn new() -> Self { let doc = LoroDoc::new(); let content = doc.get_text("content"); - let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); + let loro_cursor = content.get_cursor(0, Side::default()); Self { + inner: Rc::new(RefCell::new(LoroTextBufferInner { + undo_mgr: LoroUndoManager::new(&doc), + last_edit: None, + loro_cursor, + })), doc, content, - undo_mgr, - last_edit: None, } } @@ -43,16 +61,38 @@ impl LoroTextBuffer { let doc = LoroDoc::new(); doc.import(snapshot)?; let content = doc.get_text("content"); - let undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&doc))); + let loro_cursor = content.get_cursor(0, Side::default()); Ok(Self { + inner: Rc::new(RefCell::new(LoroTextBufferInner { + undo_mgr: LoroUndoManager::new(&doc), + last_edit: None, + loro_cursor, + })), doc, content, - undo_mgr, - last_edit: None, }) } + /// Create a buffer from an existing LoroDoc with a specific text container key. + /// + /// Useful for shared documents where multiple text fields exist in the same doc. + /// The doc is cloned (cheap - Arc-backed) so the buffer shares state with the original. + pub fn from_doc(doc: LoroDoc, key: &str) -> Self { + let content = doc.get_text(key); + let loro_cursor = content.get_cursor(0, Side::default()); + + Self { + inner: Rc::new(RefCell::new(LoroTextBufferInner { + undo_mgr: LoroUndoManager::new(&doc), + last_edit: None, + loro_cursor, + })), + doc, + content, + } + } + /// Get the underlying Loro document. pub fn doc(&self) -> &LoroDoc { &self.doc @@ -104,6 +144,34 @@ impl LoroTextBuffer { pub fn version(&self) -> VersionVector { self.doc.oplog_vv() } + + // --- Cursor management --- + + /// Sync the Loro cursor to track a specific char offset. + /// Call this after local edits where you know the new cursor position. + pub fn sync_cursor(&self, offset: usize) { + self.inner.borrow_mut().loro_cursor = self.content.get_cursor(offset, Side::default()); + } + + /// Resolve the Loro cursor to its current char offset. + /// Call this after undo/redo or remote edits where the position may have shifted. + /// Returns None if no cursor is set or resolution fails. + pub fn resolve_cursor(&self) -> Option { + let inner = self.inner.borrow(); + let cursor = inner.loro_cursor.as_ref()?; + let result = self.doc.get_cursor_pos(cursor).ok()?; + Some(result.current.pos.min(self.content.len_unicode())) + } + + /// Get a clone of the Loro cursor for serialization. + pub fn loro_cursor(&self) -> Option { + self.inner.borrow().loro_cursor.clone() + } + + /// Set the Loro cursor (used when restoring from storage). + pub fn set_loro_cursor(&self, cursor: Option) { + self.inner.borrow_mut().loro_cursor = cursor; + } } impl Default for LoroTextBuffer { @@ -127,7 +195,7 @@ impl TextBuffer for LoroTextBuffer { self.content.insert(char_offset, text).ok(); - self.last_edit = Some(EditInfo { + self.inner.borrow_mut().last_edit = Some(EditInfo { edit_char_pos: char_offset, inserted_len: text.chars().count(), deleted_len: 0, @@ -148,7 +216,7 @@ impl TextBuffer for LoroTextBuffer { self.content.delete(char_range.start, deleted_len).ok(); - self.last_edit = Some(EditInfo { + self.inner.borrow_mut().last_edit = Some(EditInfo { edit_char_pos: char_range.start, inserted_len: 0, deleted_len, @@ -189,32 +257,30 @@ impl TextBuffer for LoroTextBuffer { .unwrap_or(self.content.len_unicode()) } - fn last_edit(&self) -> Option<&EditInfo> { - self.last_edit.as_ref() + fn last_edit(&self) -> Option { + self.inner.borrow().last_edit } } impl UndoManager for LoroTextBuffer { fn can_undo(&self) -> bool { - self.undo_mgr.borrow().can_undo() + self.inner.borrow().undo_mgr.can_undo() } fn can_redo(&self) -> bool { - self.undo_mgr.borrow().can_redo() + self.inner.borrow().undo_mgr.can_redo() } fn undo(&mut self) -> bool { - self.undo_mgr.borrow_mut().undo().is_ok() + self.inner.borrow_mut().undo_mgr.undo().is_ok() } fn redo(&mut self) -> bool { - self.undo_mgr.borrow_mut().redo().is_ok() + self.inner.borrow_mut().undo_mgr.redo().is_ok() } fn clear_history(&mut self) { - // Loro's UndoManager doesn't have a clear method - // Create a new one to effectively clear history - self.undo_mgr = Rc::new(RefCell::new(LoroUndoManager::new(&self.doc))); + self.inner.borrow_mut().undo_mgr = LoroUndoManager::new(&self.doc); } } @@ -268,4 +334,38 @@ mod tests { assert_eq!(buffer.char_to_byte(6), 6); // before emoji assert_eq!(buffer.char_to_byte(7), 10); // after emoji } + + #[test] + fn test_clone_shares_state() { + let mut buffer1 = LoroTextBuffer::new(); + buffer1.insert(0, "Hello"); + + let buffer2 = buffer1.clone(); + + // Both should see the same last_edit + assert_eq!(buffer1.last_edit(), buffer2.last_edit()); + + // Edit through buffer1 + buffer1.insert(5, " World"); + + // buffer2 should see the updated last_edit (shared state) + assert_eq!(buffer1.last_edit(), buffer2.last_edit()); + assert_eq!(buffer2.last_edit().unwrap().inserted_len, 6); + } + + #[test] + fn test_cursor_management() { + let mut buffer = LoroTextBuffer::new(); + buffer.insert(0, "Hello World"); + + // Sync cursor to position 5 + buffer.sync_cursor(5); + assert_eq!(buffer.resolve_cursor(), Some(5)); + + // Insert text before cursor - cursor should shift + buffer.insert(0, "Hi "); + // After insert, cursor tracked by Loro should have shifted + let pos = buffer.resolve_cursor().unwrap(); + assert_eq!(pos, 8); // 5 + 3 = 8 + } } diff --git a/docs/graph-data.json b/docs/graph-data.json index 8467891..1839841 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1748,6 +1748,39 @@ "created_at": "2026-01-06T15:16:33.936978250-05:00", "updated_at": "2026-01-06T15:16:33.936978250-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 161, + "change_id": "95b49ba0-0387-4bcb-b9c1-1aeea141aa46", + "node_type": "action", + "title": "Adding EditInfo tracking to LoroTextBuffer and EditorRope", + "description": null, + "status": "pending", + "created_at": "2026-01-06T15:48:29.751031863-05:00", + "updated_at": "2026-01-06T15:48:29.751031863-05:00", + "metadata_json": "{\"confidence\":85}" + }, + { + "id": 162, + "change_id": "85236b61-2d3f-4de6-bc90-71187810ca73", + "node_type": "outcome", + "title": "EditInfo tracking added to both EditorRope and LoroTextBuffer, with optimized is_in_block_syntax_zone using ropey line functions", + "description": null, + "status": "pending", + "created_at": "2026-01-06T15:49:15.888613855-05:00", + "updated_at": "2026-01-06T15:49:15.888613855-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 163, + "change_id": "5fff4fb8-ea78-4e46-9167-d3f72eea1532", + "node_type": "outcome", + "title": "LoroTextBuffer refactored with Rc> for shared mutable state, cursor management, from_doc constructor - clones now share all state", + "description": null, + "status": "pending", + "created_at": "2026-01-06T15:54:32.396727943-05:00", + "updated_at": "2026-01-06T15:54:32.396727943-05:00", + "metadata_json": "{\"confidence\":95}" } ], "edges": [ @@ -3565,6 +3598,28 @@ "weight": 1.0, "rationale": "refactor completed successfully", "created_at": "2026-01-06T15:16:39.426610084-05:00" + }, + { + "id": 167, + "from_node_id": 161, + "to_node_id": 162, + "from_change_id": "95b49ba0-0387-4bcb-b9c1-1aeea141aa46", + "to_change_id": "85236b61-2d3f-4de6-bc90-71187810ca73", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "EditInfo implementation complete", + "created_at": "2026-01-06T15:49:15.905323972-05:00" + }, + { + "id": 168, + "from_node_id": 161, + "to_node_id": 163, + "from_change_id": "95b49ba0-0387-4bcb-b9c1-1aeea141aa46", + "to_change_id": "5fff4fb8-ea78-4e46-9167-d3f72eea1532", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Refactoring complete with tests", + "created_at": "2026-01-06T15:54:32.412922730-05:00" } ] } \ No newline at end of file