loro refactor done, more bugs fixed

Orual 0d64558b cda2ec7e

+279 -52
+126 -4
crates/weaver-app/src/components/editor/document.rs
··· 2 2 //! 3 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 4 5 - use loro::{LoroDoc, LoroResult, LoroText, UndoManager}; 5 + use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager}; 6 6 7 7 /// Single source of truth for editor state. 8 8 /// ··· 21 21 /// Undo manager for the document. 22 22 undo_mgr: UndoManager, 23 23 24 - /// Current cursor position (char offset) 24 + /// Current cursor position (char offset) - fast local cache. 25 + /// This is the authoritative position for immediate operations. 25 26 pub cursor: CursorState, 27 + 28 + /// CRDT-aware cursor that tracks position through remote edits and undo/redo. 29 + /// Recreated after our own edits, queried after undo/redo/remote edits. 30 + loro_cursor: Option<Cursor>, 26 31 27 32 /// Active selection if any 28 33 pub selection: Option<Selection>, ··· 127 132 undo_mgr.set_merge_interval(300); // 300ms merge window 128 133 undo_mgr.set_max_undo_steps(100); 129 134 135 + // Create initial Loro cursor at position 0 136 + let loro_cursor = text.get_cursor(0, Side::default()); 137 + 130 138 Self { 131 139 doc, 132 140 text, ··· 135 143 offset: 0, 136 144 affinity: Affinity::Before, 137 145 }, 146 + loro_cursor, 138 147 selection: None, 139 148 composition: None, 140 149 last_edit: None, ··· 230 239 231 240 /// Undo the last operation. 232 241 /// Returns true if an undo was performed. 242 + /// Automatically updates cursor position from the Loro cursor. 233 243 pub fn undo(&mut self) -> LoroResult<bool> { 234 - self.undo_mgr.undo() 244 + // Sync Loro cursor to current position BEFORE undo 245 + // so it tracks through the undo operation 246 + self.sync_loro_cursor(); 247 + 248 + let result = self.undo_mgr.undo()?; 249 + if result { 250 + // After undo, query Loro cursor for new position 251 + self.sync_cursor_from_loro(); 252 + } 253 + Ok(result) 235 254 } 236 255 237 256 /// Redo the last undone operation. 238 257 /// Returns true if a redo was performed. 258 + /// Automatically updates cursor position from the Loro cursor. 239 259 pub fn redo(&mut self) -> LoroResult<bool> { 240 - self.undo_mgr.redo() 260 + // Sync Loro cursor to current position BEFORE redo 261 + self.sync_loro_cursor(); 262 + 263 + let result = self.undo_mgr.redo()?; 264 + if result { 265 + // After redo, query Loro cursor for new position 266 + self.sync_cursor_from_loro(); 267 + } 268 + Ok(result) 241 269 } 242 270 243 271 /// Check if undo is available. ··· 255 283 pub fn slice(&self, start: usize, end: usize) -> Option<String> { 256 284 self.text.slice(start, end).ok() 257 285 } 286 + 287 + /// Sync the Loro cursor to the current cursor.offset position. 288 + /// Call this after OUR edits where we know the new cursor position. 289 + pub fn sync_loro_cursor(&mut self) { 290 + self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default()); 291 + } 292 + 293 + /// Update cursor.offset from the Loro cursor's tracked position. 294 + /// Call this after undo/redo or remote edits where the position may have shifted. 295 + /// Returns the new offset, or None if the cursor couldn't be resolved. 296 + pub fn sync_cursor_from_loro(&mut self) -> Option<usize> { 297 + let loro_cursor = self.loro_cursor.as_ref()?; 298 + let result = self.doc.get_cursor_pos(loro_cursor).ok()?; 299 + let new_offset = result.current.pos; 300 + self.cursor.offset = new_offset.min(self.len_chars()); 301 + Some(self.cursor.offset) 302 + } 303 + 304 + /// Get the Loro cursor for serialization. 305 + pub fn loro_cursor(&self) -> Option<&Cursor> { 306 + self.loro_cursor.as_ref() 307 + } 308 + 309 + /// Set the Loro cursor (used when restoring from storage). 310 + pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) { 311 + self.loro_cursor = cursor; 312 + // Sync cursor.offset from the restored Loro cursor 313 + if self.loro_cursor.is_some() { 314 + self.sync_cursor_from_loro(); 315 + } 316 + } 317 + 318 + /// Export the document as a binary snapshot. 319 + /// This captures all CRDT state including undo history. 320 + pub fn export_snapshot(&self) -> Vec<u8> { 321 + self.doc.export(ExportMode::Snapshot).unwrap_or_default() 322 + } 323 + 324 + /// Create a new EditorDocument from a binary snapshot. 325 + /// Falls back to empty document if import fails. 326 + /// 327 + /// If `loro_cursor` is provided, it will be used to restore the cursor position. 328 + /// Otherwise, falls back to `fallback_offset`. 329 + pub fn from_snapshot( 330 + snapshot: &[u8], 331 + loro_cursor: Option<Cursor>, 332 + fallback_offset: usize, 333 + ) -> Self { 334 + let doc = LoroDoc::new(); 335 + 336 + if !snapshot.is_empty() { 337 + if let Err(e) = doc.import(snapshot) { 338 + tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); 339 + } 340 + } 341 + 342 + let text = doc.get_text("content"); 343 + 344 + // Set up undo manager 345 + let mut undo_mgr = UndoManager::new(&doc); 346 + undo_mgr.set_merge_interval(300); 347 + undo_mgr.set_max_undo_steps(100); 348 + 349 + // Try to restore cursor from Loro cursor, fall back to offset 350 + let max_offset = text.len_unicode(); 351 + let cursor_offset = if let Some(ref lc) = loro_cursor { 352 + doc.get_cursor_pos(lc) 353 + .map(|r| r.current.pos) 354 + .unwrap_or(fallback_offset) 355 + } else { 356 + fallback_offset 357 + }; 358 + 359 + let cursor = CursorState { 360 + offset: cursor_offset.min(max_offset), 361 + affinity: Affinity::Before, 362 + }; 363 + 364 + // If no Loro cursor provided, create one at the restored position 365 + let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default())); 366 + 367 + Self { 368 + doc, 369 + text, 370 + undo_mgr, 371 + cursor, 372 + loro_cursor, 373 + selection: None, 374 + composition: None, 375 + last_edit: None, 376 + } 377 + } 258 378 } 259 379 260 380 // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. ··· 266 386 let content = self.to_string(); 267 387 let mut new_doc = Self::new(content); 268 388 new_doc.cursor = self.cursor; 389 + // Recreate Loro cursor at the same position in the new doc 390 + new_doc.sync_loro_cursor(); 269 391 new_doc.selection = self.selection; 270 392 new_doc.composition = self.composition.clone(); 271 393 new_doc.last_edit = self.last_edit.clone();
+64 -16
crates/weaver-app/src/components/editor/mod.rs
··· 52 52 /// - No mouse selection 53 53 #[component] 54 54 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 55 - // Try to restore from localStorage 56 - let restored = use_memo(move || { 55 + // Try to restore from localStorage (includes CRDT state for undo history) 56 + let mut document = use_signal(move || { 57 57 storage::load_from_storage() 58 - .map(|s| s.content) 59 - .or_else(|| initial_content.clone()) 60 - .unwrap_or_default() 58 + .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 61 59 }); 62 - 63 - let mut document = use_signal(|| EditorDocument::new(restored())); 64 60 let editor_id = "markdown-editor"; 65 61 66 62 // Cache for incremental paragraph rendering ··· 164 160 // Auto-save with debounce 165 161 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 166 162 use_effect(move || { 167 - // Read document once and extract what we need 168 - let doc = document(); 169 - let content = doc.to_string(); 170 - let cursor = doc.cursor.offset; 171 - drop(doc); 163 + // Capture snapshot data, syncing Loro cursor first 164 + let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| { 165 + // Sync Loro cursor to current position before saving 166 + doc.sync_loro_cursor(); 167 + ( 168 + doc.to_string(), 169 + doc.cursor.offset, 170 + doc.loro_cursor().cloned(), 171 + doc.export_snapshot(), 172 + ) 173 + }); 172 174 173 175 // Save after 500ms of no typing 174 176 let timer = gloo_timers::callback::Timeout::new(500, move || { 175 - let _ = storage::save_to_storage(&content, cursor); 177 + use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set 178 + let snapshot_b64 = if snapshot_bytes.is_empty() { 179 + None 180 + } else { 181 + Some(base64::Engine::encode( 182 + &base64::engine::general_purpose::STANDARD, 183 + &snapshot_bytes, 184 + )) 185 + }; 186 + let snapshot = storage::EditorSnapshot { 187 + content, 188 + snapshot: snapshot_b64, 189 + cursor: loro_cursor, 190 + cursor_offset, 191 + }; 192 + let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot); 176 193 }); 177 194 timer.forget(); 178 195 }); ··· 279 296 // Handle Ctrl/Cmd shortcuts 280 297 if mods.ctrl() || mods.meta() { 281 298 if let Key::Character(ch) = &key { 282 - // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I) 283 - return matches!(ch.as_str(), "b" | "i"); 299 + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y) 300 + return matches!(ch.as_str(), "b" | "i" | "z" | "y"); 284 301 } 285 - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.) 302 + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 286 303 return false; 287 304 } 288 305 ··· 665 682 formatting::apply_formatting(doc, FormatAction::Italic); 666 683 return; 667 684 } 685 + "z" => { 686 + if mods.shift() { 687 + // Ctrl+Shift+Z = redo 688 + if let Ok(true) = doc.redo() { 689 + // Cursor position should be handled by the undo manager 690 + // but we may need to clamp it 691 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 692 + } 693 + } else { 694 + // Ctrl+Z = undo 695 + if let Ok(true) = doc.undo() { 696 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 697 + } 698 + } 699 + doc.selection = None; 700 + return; 701 + } 702 + "y" => { 703 + // Ctrl+Y = redo (alternative) 704 + if let Ok(true) = doc.redo() { 705 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 706 + } 707 + doc.selection = None; 708 + return; 709 + } 668 710 _ => {} 669 711 } 670 712 } ··· 813 855 } 814 856 815 857 _ => {} 858 + } 859 + 860 + // Sync Loro cursor when edits affect paragraph boundaries 861 + // This ensures cursor position is tracked correctly through structural changes 862 + if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { 863 + doc.sync_loro_cursor(); 816 864 } 817 865 }); 818 866 }
+30 -21
crates/weaver-app/src/components/editor/render.rs
··· 60 60 false 61 61 } 62 62 63 + /// Apply a signed delta to a usize, saturating at 0 on underflow. 64 + fn apply_delta(val: usize, delta: isize) -> usize { 65 + if delta >= 0 { 66 + val.saturating_add(delta as usize) 67 + } else { 68 + val.saturating_sub((-delta) as usize) 69 + } 70 + } 71 + 63 72 /// Adjust a cached paragraph's positions after an earlier edit. 64 73 fn adjust_paragraph_positions( 65 74 cached: &CachedParagraph, ··· 68 77 ) -> ParagraphRender { 69 78 let mut adjusted_map = cached.offset_map.clone(); 70 79 for mapping in &mut adjusted_map { 71 - mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize; 72 - mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; 73 - mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize; 74 - mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; 80 + mapping.char_range.start = apply_delta(mapping.char_range.start, char_delta); 81 + mapping.char_range.end = apply_delta(mapping.char_range.end, char_delta); 82 + mapping.byte_range.start = apply_delta(mapping.byte_range.start, byte_delta); 83 + mapping.byte_range.end = apply_delta(mapping.byte_range.end, byte_delta); 75 84 } 76 85 77 86 let mut adjusted_syntax = cached.syntax_spans.clone(); 78 87 for span in &mut adjusted_syntax { 79 - span.char_range.start = (span.char_range.start as isize + char_delta) as usize; 80 - span.char_range.end = (span.char_range.end as isize + char_delta) as usize; 88 + span.char_range.start = apply_delta(span.char_range.start, char_delta); 89 + span.char_range.end = apply_delta(span.char_range.end, char_delta); 81 90 } 82 91 83 92 ParagraphRender { 84 - byte_range: (cached.byte_range.start as isize + byte_delta) as usize 85 - ..(cached.byte_range.end as isize + byte_delta) as usize, 86 - char_range: (cached.char_range.start as isize + char_delta) as usize 87 - ..(cached.char_range.end as isize + char_delta) as usize, 93 + byte_range: apply_delta(cached.byte_range.start, byte_delta) 94 + ..apply_delta(cached.byte_range.end, byte_delta), 95 + char_range: apply_delta(cached.char_range.start, char_delta) 96 + ..apply_delta(cached.char_range.end, char_delta), 88 97 html: cached.html.clone(), 89 98 offset_map: adjusted_map, 90 99 syntax_spans: adjusted_syntax, ··· 159 168 .paragraphs 160 169 .iter() 161 170 .map(|p| { 162 - if p.char_range.end <= edit_pos { 163 - // Before edit - no change 171 + if p.char_range.end < edit_pos { 172 + // Before edit - no change (edit is strictly after this paragraph) 164 173 (p.byte_range.clone(), p.char_range.clone()) 165 - } else if p.char_range.start >= edit_pos { 166 - // After edit - shift by delta 174 + } else if p.char_range.start > edit_pos { 175 + // After edit - shift by delta (edit is strictly before this paragraph) 167 176 // Calculate byte delta (approximation: assume 1 byte per char for ASCII) 168 177 // This is imprecise but boundaries are rediscovered on slow path anyway 169 178 let byte_delta = char_delta; // TODO: proper byte calculation 170 179 ( 171 - (p.byte_range.start as isize + byte_delta) as usize 172 - ..(p.byte_range.end as isize + byte_delta) as usize, 173 - (p.char_range.start as isize + char_delta) as usize 174 - ..(p.char_range.end as isize + char_delta) as usize, 180 + apply_delta(p.byte_range.start, byte_delta) 181 + ..apply_delta(p.byte_range.end, byte_delta), 182 + apply_delta(p.char_range.start, char_delta) 183 + ..apply_delta(p.char_range.end, char_delta), 175 184 ) 176 185 } else { 177 - // Edit is within this paragraph - expand its end 186 + // Edit is at or within this paragraph - expand its end 178 187 ( 179 - p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize, 180 - p.char_range.start..(p.char_range.end as isize + char_delta) as usize, 188 + p.byte_range.start..apply_delta(p.byte_range.end, char_delta), 189 + p.char_range.start..apply_delta(p.char_range.end, char_delta), 181 190 ) 182 191 } 183 192 })
+59 -11
crates/weaver-app/src/components/editor/storage.rs
··· 1 1 //! LocalStorage persistence for the editor. 2 2 //! 3 - //! Only available on WASM targets. 3 + //! Stores both human-readable content (for debugging) and the full CRDT 4 + //! snapshot (for undo history preservation across sessions). 4 5 6 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 5 8 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 6 9 use gloo_storage::{LocalStorage, Storage}; 10 + use loro::cursor::Cursor; 7 11 use serde::{Deserialize, Serialize}; 8 12 13 + use super::document::EditorDocument; 14 + 9 15 /// Editor snapshot for persistence. 16 + /// 17 + /// Stores both human-readable content and CRDT snapshot for best of both worlds: 18 + /// - `content`: Human-readable text for debugging 19 + /// - `snapshot`: Base64-encoded CRDT state for full undo history restoration 20 + /// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position 21 + /// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored 10 22 #[derive(Serialize, Deserialize, Clone, Debug)] 11 23 pub struct EditorSnapshot { 24 + /// Human-readable document content (for debugging/fallback) 12 25 pub content: String, 26 + /// Base64-encoded CRDT snapshot (preserves undo history) 27 + pub snapshot: Option<String>, 28 + /// Loro Cursor for stable cursor position tracking 29 + pub cursor: Option<Cursor>, 30 + /// Fallback cursor offset (used if Loro cursor can't be restored) 13 31 pub cursor_offset: usize, 14 32 } 15 33 ··· 18 36 19 37 /// Save editor state to LocalStorage (WASM only). 20 38 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 - pub fn save_to_storage( 22 - content: &str, 23 - cursor_offset: usize, 24 - ) -> Result<(), gloo_storage::errors::StorageError> { 39 + pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> { 40 + let snapshot_bytes = doc.export_snapshot(); 41 + let snapshot_b64 = if snapshot_bytes.is_empty() { 42 + None 43 + } else { 44 + Some(BASE64.encode(&snapshot_bytes)) 45 + }; 46 + 25 47 let snapshot = EditorSnapshot { 26 - content: content.to_string(), 27 - cursor_offset, 48 + content: doc.to_string(), 49 + snapshot: snapshot_b64, 50 + cursor: doc.loro_cursor().cloned(), 51 + cursor_offset: doc.cursor.offset, 28 52 }; 29 53 LocalStorage::set(STORAGE_KEY, &snapshot) 30 54 } 31 55 32 56 /// Load editor state from LocalStorage (WASM only). 57 + /// Returns an EditorDocument restored from CRDT snapshot if available, 58 + /// otherwise falls back to just the text content. 33 59 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 34 - pub fn load_from_storage() -> Option<EditorSnapshot> { 35 - LocalStorage::get(STORAGE_KEY).ok() 60 + pub fn load_from_storage() -> Option<EditorDocument> { 61 + let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?; 62 + 63 + // Try to restore from CRDT snapshot first 64 + if let Some(ref snapshot_b64) = snapshot.snapshot { 65 + if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 66 + let doc = EditorDocument::from_snapshot( 67 + &snapshot_bytes, 68 + snapshot.cursor.clone(), 69 + snapshot.cursor_offset, 70 + ); 71 + // Verify the content matches (sanity check) 72 + if doc.to_string() == snapshot.content { 73 + return Some(doc); 74 + } 75 + tracing::warn!("Snapshot content mismatch, falling back to text content"); 76 + } 77 + } 78 + 79 + // Fallback: create new doc from text content 80 + let mut doc = EditorDocument::new(snapshot.content); 81 + doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); 82 + doc.sync_loro_cursor(); 83 + Some(doc) 36 84 } 37 85 38 86 /// Clear editor state from LocalStorage (WASM only). ··· 44 92 45 93 // Stub implementations for non-WASM targets 46 94 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 47 - pub fn save_to_storage(_content: &str, _cursor_offset: usize) -> Result<(), String> { 95 + pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> { 48 96 Ok(()) 49 97 } 50 98 51 99 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 52 - pub fn load_from_storage() -> Option<EditorSnapshot> { 100 + pub fn load_from_storage() -> Option<EditorDocument> { 53 101 None 54 102 } 55 103