atproto blogging
1//! Loro-backed text buffer implementing core editor traits.
2
3use std::cell::RefCell;
4use std::ops::Range;
5use std::rc::Rc;
6
7use loro::{
8 cursor::{Cursor, PosType, Side},
9 LoroDoc, LoroText, UndoManager as LoroUndoManager, VersionVector,
10};
11use smol_str::{SmolStr, ToSmolStr};
12use web_time::Instant;
13use weaver_editor_core::{EditInfo, TextBuffer, UndoManager};
14
15use crate::CrdtError;
16
17/// Mutable state that must be shared across clones.
18struct LoroTextBufferInner {
19 undo_mgr: LoroUndoManager,
20 last_edit: Option<EditInfo>,
21 loro_cursor: Option<Cursor>,
22}
23
24/// Loro-backed text buffer with undo/redo support.
25///
26/// Wraps a `LoroDoc` with a text container and provides implementations
27/// of the `TextBuffer` and `UndoManager` traits from weaver-editor-core.
28///
29/// Also provides CRDT-aware cursor tracking that survives remote edits
30/// and undo/redo operations.
31///
32/// Cloning is cheap and clones share all mutable state (undo history,
33/// last edit info, cursor position).
34#[derive(Clone)]
35pub struct LoroTextBuffer {
36 doc: LoroDoc,
37 content: LoroText,
38 inner: Rc<RefCell<LoroTextBufferInner>>,
39}
40
41impl LoroTextBuffer {
42 /// Create a new empty buffer.
43 pub fn new() -> Self {
44 let doc = LoroDoc::new();
45 let content = doc.get_text("content");
46 let loro_cursor = content.get_cursor(0, Side::default());
47
48 Self {
49 inner: Rc::new(RefCell::new(LoroTextBufferInner {
50 undo_mgr: LoroUndoManager::new(&doc),
51 last_edit: None,
52 loro_cursor,
53 })),
54 doc,
55 content,
56 }
57 }
58
59 /// Create a buffer from an existing Loro snapshot.
60 pub fn from_snapshot(snapshot: &[u8]) -> Result<Self, CrdtError> {
61 let doc = LoroDoc::new();
62 doc.import(snapshot)?;
63 let content = doc.get_text("content");
64 let loro_cursor = content.get_cursor(0, Side::default());
65
66 Ok(Self {
67 inner: Rc::new(RefCell::new(LoroTextBufferInner {
68 undo_mgr: LoroUndoManager::new(&doc),
69 last_edit: None,
70 loro_cursor,
71 })),
72 doc,
73 content,
74 })
75 }
76
77 /// Create a buffer from an existing LoroDoc with a specific text container key.
78 ///
79 /// Useful for shared documents where multiple text fields exist in the same doc.
80 /// The doc is cloned (cheap - Arc-backed) so the buffer shares state with the original.
81 pub fn from_doc(doc: LoroDoc, key: &str) -> Self {
82 let content = doc.get_text(key);
83 let loro_cursor = content.get_cursor(0, Side::default());
84
85 Self {
86 inner: Rc::new(RefCell::new(LoroTextBufferInner {
87 undo_mgr: LoroUndoManager::new(&doc),
88 last_edit: None,
89 loro_cursor,
90 })),
91 doc,
92 content,
93 }
94 }
95
96 /// Get the underlying Loro document.
97 pub fn doc(&self) -> &LoroDoc {
98 &self.doc
99 }
100
101 /// Get the text container.
102 pub fn content(&self) -> &LoroText {
103 &self.content
104 }
105
106 /// Export full snapshot.
107 pub fn export_snapshot(&self) -> Vec<u8> {
108 self.doc
109 .export(loro::ExportMode::Snapshot)
110 .expect("snapshot export should not fail")
111 }
112
113 /// Export updates since given version.
114 pub fn export_updates_since(&self, version: &VersionVector) -> Option<Vec<u8>> {
115 use std::borrow::Cow;
116
117 let current_vv = self.doc.oplog_vv();
118
119 if *version == current_vv {
120 return None;
121 }
122
123 let updates = self
124 .doc
125 .export(loro::ExportMode::Updates {
126 from: Cow::Owned(version.clone()),
127 })
128 .ok()?;
129
130 if updates.is_empty() {
131 return None;
132 }
133
134 Some(updates)
135 }
136
137 /// Import remote changes.
138 pub fn import(&mut self, data: &[u8]) -> Result<(), CrdtError> {
139 self.doc.import(data)?;
140 Ok(())
141 }
142
143 /// Get current version vector.
144 pub fn version(&self) -> VersionVector {
145 self.doc.oplog_vv()
146 }
147
148 // --- Cursor management ---
149
150 /// Sync the Loro cursor to track a specific char offset.
151 /// Call this after local edits where you know the new cursor position.
152 pub fn sync_cursor(&self, offset: usize) {
153 self.inner.borrow_mut().loro_cursor = self.content.get_cursor(offset, Side::default());
154 }
155
156 /// Resolve the Loro cursor to its current char offset.
157 /// Call this after undo/redo or remote edits where the position may have shifted.
158 /// Returns None if no cursor is set or resolution fails.
159 pub fn resolve_cursor(&self) -> Option<usize> {
160 let inner = self.inner.borrow();
161 let cursor = inner.loro_cursor.as_ref()?;
162 let result = self.doc.get_cursor_pos(cursor).ok()?;
163 Some(result.current.pos.min(self.content.len_unicode()))
164 }
165
166 /// Get a clone of the Loro cursor for serialization.
167 pub fn loro_cursor(&self) -> Option<Cursor> {
168 self.inner.borrow().loro_cursor.clone()
169 }
170
171 /// Set the Loro cursor (used when restoring from storage).
172 pub fn set_loro_cursor(&self, cursor: Option<Cursor>) {
173 self.inner.borrow_mut().loro_cursor = cursor;
174 }
175}
176
177impl Default for LoroTextBuffer {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183impl TextBuffer for LoroTextBuffer {
184 fn len_bytes(&self) -> usize {
185 self.content.len_utf8()
186 }
187
188 fn len_chars(&self) -> usize {
189 self.content.len_unicode()
190 }
191
192 fn insert(&mut self, char_offset: usize, text: &str) {
193 let in_block_syntax_zone = self.is_in_block_syntax_zone(char_offset);
194 let contains_newline = text.contains('\n');
195
196 self.content.insert(char_offset, text).ok();
197
198 self.inner.borrow_mut().last_edit = Some(EditInfo {
199 edit_char_pos: char_offset,
200 inserted_len: text.chars().count(),
201 deleted_len: 0,
202 contains_newline,
203 in_block_syntax_zone,
204 doc_len_after: self.content.len_unicode(),
205 timestamp: Instant::now(),
206 });
207 }
208
209 fn delete(&mut self, char_range: Range<usize>) {
210 let in_block_syntax_zone = self.is_in_block_syntax_zone(char_range.start);
211 let contains_newline = self
212 .slice(char_range.clone())
213 .map(|s| s.contains('\n'))
214 .unwrap_or(false);
215 let deleted_len = char_range.len();
216
217 self.content.delete(char_range.start, deleted_len).ok();
218
219 self.inner.borrow_mut().last_edit = Some(EditInfo {
220 edit_char_pos: char_range.start,
221 inserted_len: 0,
222 deleted_len,
223 contains_newline,
224 in_block_syntax_zone,
225 doc_len_after: self.content.len_unicode(),
226 timestamp: Instant::now(),
227 });
228 }
229
230 fn replace(&mut self, char_range: Range<usize>, text: &str) {
231 let in_block_syntax_zone = self.is_in_block_syntax_zone(char_range.start);
232 let delete_has_newline = self
233 .slice(char_range.clone())
234 .map(|s| s.contains('\n'))
235 .unwrap_or(false);
236 let deleted_len = char_range.len();
237 let inserted_len = text.chars().count();
238
239 // Use Loro's atomic splice operation
240 self.content
241 .splice(char_range.start, deleted_len, text)
242 .ok();
243
244 self.inner.borrow_mut().last_edit = Some(EditInfo {
245 edit_char_pos: char_range.start,
246 inserted_len,
247 deleted_len,
248 contains_newline: delete_has_newline || text.contains('\n'),
249 in_block_syntax_zone,
250 doc_len_after: self.content.len_unicode(),
251 timestamp: Instant::now(),
252 });
253 }
254
255 fn push(&mut self, text: &str) {
256 let char_offset = self.content.len_unicode();
257 let contains_newline = text.contains('\n');
258 let in_block_syntax_zone = self.is_in_block_syntax_zone(char_offset);
259
260 self.content.push_str(text).ok();
261
262 self.inner.borrow_mut().last_edit = Some(EditInfo {
263 edit_char_pos: char_offset,
264 inserted_len: text.chars().count(),
265 deleted_len: 0,
266 contains_newline,
267 in_block_syntax_zone,
268 doc_len_after: self.content.len_unicode(),
269 timestamp: Instant::now(),
270 });
271 }
272
273 fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> {
274 if char_range.end > self.content.len_unicode() {
275 return None;
276 }
277 self.content
278 .slice(char_range.start, char_range.end)
279 .ok()
280 .map(|s| s.to_smolstr())
281 }
282
283 fn char_at(&self, char_offset: usize) -> Option<char> {
284 self.content.char_at(char_offset).ok()
285 }
286
287 fn to_string(&self) -> String {
288 self.content.to_string()
289 }
290
291 fn char_to_byte(&self, char_offset: usize) -> usize {
292 self.content
293 .convert_pos(char_offset, PosType::Unicode, PosType::Bytes)
294 .unwrap_or(self.content.len_utf8())
295 }
296
297 fn byte_to_char(&self, byte_offset: usize) -> usize {
298 self.content
299 .convert_pos(byte_offset, PosType::Bytes, PosType::Unicode)
300 .unwrap_or(self.content.len_unicode())
301 }
302
303 fn last_edit(&self) -> Option<EditInfo> {
304 self.inner.borrow().last_edit
305 }
306}
307
308impl UndoManager for LoroTextBuffer {
309 fn can_undo(&self) -> bool {
310 self.inner.borrow().undo_mgr.can_undo()
311 }
312
313 fn can_redo(&self) -> bool {
314 self.inner.borrow().undo_mgr.can_redo()
315 }
316
317 fn undo(&mut self) -> bool {
318 self.inner.borrow_mut().undo_mgr.undo().is_ok()
319 }
320
321 fn redo(&mut self) -> bool {
322 self.inner.borrow_mut().undo_mgr.redo().is_ok()
323 }
324
325 fn clear_history(&mut self) {
326 self.inner.borrow_mut().undo_mgr = LoroUndoManager::new(&self.doc);
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_basic_operations() {
336 let mut buffer = LoroTextBuffer::new();
337
338 buffer.insert(0, "Hello");
339 assert_eq!(buffer.to_string(), "Hello");
340
341 buffer.insert(5, " World");
342 assert_eq!(buffer.to_string(), "Hello World");
343
344 buffer.delete(5..6);
345 assert_eq!(buffer.to_string(), "HelloWorld");
346 }
347
348 #[test]
349 fn test_snapshot_roundtrip() {
350 let mut buffer = LoroTextBuffer::new();
351 buffer.insert(0, "Test content");
352
353 let snapshot = buffer.export_snapshot();
354 let restored = LoroTextBuffer::from_snapshot(&snapshot).unwrap();
355
356 assert_eq!(restored.to_string(), "Test content");
357 }
358
359 #[test]
360 fn test_slice() {
361 let mut buffer = LoroTextBuffer::new();
362 buffer.insert(0, "Hello World");
363
364 assert_eq!(buffer.slice(0..5).as_deref(), Some("Hello"));
365 assert_eq!(buffer.slice(6..11).as_deref(), Some("World"));
366 assert_eq!(buffer.slice(0..100), None);
367 }
368
369 #[test]
370 fn test_offset_conversion() {
371 let mut buffer = LoroTextBuffer::new();
372 buffer.insert(0, "hello 🌍");
373
374 assert_eq!(buffer.len_chars(), 7); // h e l l o 🌍
375 assert_eq!(buffer.len_bytes(), 10); // 6 + 4
376
377 assert_eq!(buffer.char_to_byte(6), 6); // before emoji
378 assert_eq!(buffer.char_to_byte(7), 10); // after emoji
379 }
380
381 #[test]
382 fn test_clone_shares_state() {
383 let mut buffer1 = LoroTextBuffer::new();
384 buffer1.insert(0, "Hello");
385
386 let buffer2 = buffer1.clone();
387
388 // Both should see the same last_edit
389 assert_eq!(buffer1.last_edit(), buffer2.last_edit());
390
391 // Edit through buffer1
392 buffer1.insert(5, " World");
393
394 // buffer2 should see the updated last_edit (shared state)
395 assert_eq!(buffer1.last_edit(), buffer2.last_edit());
396 assert_eq!(buffer2.last_edit().unwrap().inserted_len, 6);
397 }
398
399 #[test]
400 fn test_cursor_management() {
401 let mut buffer = LoroTextBuffer::new();
402 buffer.insert(0, "Hello World");
403
404 // Sync cursor to position 5
405 buffer.sync_cursor(5);
406 assert_eq!(buffer.resolve_cursor(), Some(5));
407
408 // Insert text before cursor - cursor should shift
409 buffer.insert(0, "Hi ");
410 // After insert, cursor tracked by Loro should have shifted
411 let pos = buffer.resolve_cursor().unwrap();
412 assert_eq!(pos, 8); // 5 + 3 = 8
413 }
414}