loro refactor done, more bugs fixed

Orual 0d64558b cda2ec7e

+279 -52
+126 -4
crates/weaver-app/src/components/editor/document.rs
··· 2 //! 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 5 - use loro::{LoroDoc, LoroResult, LoroText, UndoManager}; 6 7 /// Single source of truth for editor state. 8 /// ··· 21 /// Undo manager for the document. 22 undo_mgr: UndoManager, 23 24 - /// Current cursor position (char offset) 25 pub cursor: CursorState, 26 27 /// Active selection if any 28 pub selection: Option<Selection>, ··· 127 undo_mgr.set_merge_interval(300); // 300ms merge window 128 undo_mgr.set_max_undo_steps(100); 129 130 Self { 131 doc, 132 text, ··· 135 offset: 0, 136 affinity: Affinity::Before, 137 }, 138 selection: None, 139 composition: None, 140 last_edit: None, ··· 230 231 /// Undo the last operation. 232 /// Returns true if an undo was performed. 233 pub fn undo(&mut self) -> LoroResult<bool> { 234 - self.undo_mgr.undo() 235 } 236 237 /// Redo the last undone operation. 238 /// Returns true if a redo was performed. 239 pub fn redo(&mut self) -> LoroResult<bool> { 240 - self.undo_mgr.redo() 241 } 242 243 /// Check if undo is available. ··· 255 pub fn slice(&self, start: usize, end: usize) -> Option<String> { 256 self.text.slice(start, end).ok() 257 } 258 } 259 260 // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. ··· 266 let content = self.to_string(); 267 let mut new_doc = Self::new(content); 268 new_doc.cursor = self.cursor; 269 new_doc.selection = self.selection; 270 new_doc.composition = self.composition.clone(); 271 new_doc.last_edit = self.last_edit.clone();
··· 2 //! 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 5 + use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager}; 6 7 /// Single source of truth for editor state. 8 /// ··· 21 /// Undo manager for the document. 22 undo_mgr: UndoManager, 23 24 + /// Current cursor position (char offset) - fast local cache. 25 + /// This is the authoritative position for immediate operations. 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>, 31 32 /// Active selection if any 33 pub selection: Option<Selection>, ··· 132 undo_mgr.set_merge_interval(300); // 300ms merge window 133 undo_mgr.set_max_undo_steps(100); 134 135 + // Create initial Loro cursor at position 0 136 + let loro_cursor = text.get_cursor(0, Side::default()); 137 + 138 Self { 139 doc, 140 text, ··· 143 offset: 0, 144 affinity: Affinity::Before, 145 }, 146 + loro_cursor, 147 selection: None, 148 composition: None, 149 last_edit: None, ··· 239 240 /// Undo the last operation. 241 /// Returns true if an undo was performed. 242 + /// Automatically updates cursor position from the Loro cursor. 243 pub fn undo(&mut self) -> LoroResult<bool> { 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) 254 } 255 256 /// Redo the last undone operation. 257 /// Returns true if a redo was performed. 258 + /// Automatically updates cursor position from the Loro cursor. 259 pub fn redo(&mut self) -> LoroResult<bool> { 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) 269 } 270 271 /// Check if undo is available. ··· 283 pub fn slice(&self, start: usize, end: usize) -> Option<String> { 284 self.text.slice(start, end).ok() 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 + } 378 } 379 380 // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. ··· 386 let content = self.to_string(); 387 let mut new_doc = Self::new(content); 388 new_doc.cursor = self.cursor; 389 + // Recreate Loro cursor at the same position in the new doc 390 + new_doc.sync_loro_cursor(); 391 new_doc.selection = self.selection; 392 new_doc.composition = self.composition.clone(); 393 new_doc.last_edit = self.last_edit.clone();
+64 -16
crates/weaver-app/src/components/editor/mod.rs
··· 52 /// - No mouse selection 53 #[component] 54 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 55 - // Try to restore from localStorage 56 - let restored = use_memo(move || { 57 storage::load_from_storage() 58 - .map(|s| s.content) 59 - .or_else(|| initial_content.clone()) 60 - .unwrap_or_default() 61 }); 62 - 63 - let mut document = use_signal(|| EditorDocument::new(restored())); 64 let editor_id = "markdown-editor"; 65 66 // Cache for incremental paragraph rendering ··· 164 // Auto-save with debounce 165 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 166 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); 172 173 // Save after 500ms of no typing 174 let timer = gloo_timers::callback::Timeout::new(500, move || { 175 - let _ = storage::save_to_storage(&content, cursor); 176 }); 177 timer.forget(); 178 }); ··· 279 // Handle Ctrl/Cmd shortcuts 280 if mods.ctrl() || mods.meta() { 281 if let Key::Character(ch) = &key { 282 - // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I) 283 - return matches!(ch.as_str(), "b" | "i"); 284 } 285 - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.) 286 return false; 287 } 288 ··· 665 formatting::apply_formatting(doc, FormatAction::Italic); 666 return; 667 } 668 _ => {} 669 } 670 } ··· 813 } 814 815 _ => {} 816 } 817 }); 818 }
··· 52 /// - No mouse selection 53 #[component] 54 pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 55 + // Try to restore from localStorage (includes CRDT state for undo history) 56 + let mut document = use_signal(move || { 57 storage::load_from_storage() 58 + .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 59 }); 60 let editor_id = "markdown-editor"; 61 62 // Cache for incremental paragraph rendering ··· 160 // Auto-save with debounce 161 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 162 use_effect(move || { 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 + }); 174 175 // Save after 500ms of no typing 176 let timer = gloo_timers::callback::Timeout::new(500, move || { 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); 193 }); 194 timer.forget(); 195 }); ··· 296 // Handle Ctrl/Cmd shortcuts 297 if mods.ctrl() || mods.meta() { 298 if let Key::Character(ch) = &key { 299 + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y) 300 + return matches!(ch.as_str(), "b" | "i" | "z" | "y"); 301 } 302 + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 303 return false; 304 } 305 ··· 682 formatting::apply_formatting(doc, FormatAction::Italic); 683 return; 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 + } 710 _ => {} 711 } 712 } ··· 855 } 856 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(); 864 } 865 }); 866 }
+30 -21
crates/weaver-app/src/components/editor/render.rs
··· 60 false 61 } 62 63 /// Adjust a cached paragraph's positions after an earlier edit. 64 fn adjust_paragraph_positions( 65 cached: &CachedParagraph, ··· 68 ) -> ParagraphRender { 69 let mut adjusted_map = cached.offset_map.clone(); 70 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; 75 } 76 77 let mut adjusted_syntax = cached.syntax_spans.clone(); 78 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; 81 } 82 83 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, 88 html: cached.html.clone(), 89 offset_map: adjusted_map, 90 syntax_spans: adjusted_syntax, ··· 159 .paragraphs 160 .iter() 161 .map(|p| { 162 - if p.char_range.end <= edit_pos { 163 - // Before edit - no change 164 (p.byte_range.clone(), p.char_range.clone()) 165 - } else if p.char_range.start >= edit_pos { 166 - // After edit - shift by delta 167 // Calculate byte delta (approximation: assume 1 byte per char for ASCII) 168 // This is imprecise but boundaries are rediscovered on slow path anyway 169 let byte_delta = char_delta; // TODO: proper byte calculation 170 ( 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, 175 ) 176 } else { 177 - // Edit is within this paragraph - expand its end 178 ( 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, 181 ) 182 } 183 })
··· 60 false 61 } 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 + 72 /// Adjust a cached paragraph's positions after an earlier edit. 73 fn adjust_paragraph_positions( 74 cached: &CachedParagraph, ··· 77 ) -> ParagraphRender { 78 let mut adjusted_map = cached.offset_map.clone(); 79 for mapping in &mut adjusted_map { 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); 84 } 85 86 let mut adjusted_syntax = cached.syntax_spans.clone(); 87 for span in &mut adjusted_syntax { 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); 90 } 91 92 ParagraphRender { 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), 97 html: cached.html.clone(), 98 offset_map: adjusted_map, 99 syntax_spans: adjusted_syntax, ··· 168 .paragraphs 169 .iter() 170 .map(|p| { 171 + if p.char_range.end < edit_pos { 172 + // Before edit - no change (edit is strictly after this paragraph) 173 (p.byte_range.clone(), p.char_range.clone()) 174 + } else if p.char_range.start > edit_pos { 175 + // After edit - shift by delta (edit is strictly before this paragraph) 176 // Calculate byte delta (approximation: assume 1 byte per char for ASCII) 177 // This is imprecise but boundaries are rediscovered on slow path anyway 178 let byte_delta = char_delta; // TODO: proper byte calculation 179 ( 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), 184 ) 185 } else { 186 + // Edit is at or within this paragraph - expand its end 187 ( 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), 190 ) 191 } 192 })
+59 -11
crates/weaver-app/src/components/editor/storage.rs
··· 1 //! LocalStorage persistence for the editor. 2 //! 3 - //! Only available on WASM targets. 4 5 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 6 use gloo_storage::{LocalStorage, Storage}; 7 use serde::{Deserialize, Serialize}; 8 9 /// Editor snapshot for persistence. 10 #[derive(Serialize, Deserialize, Clone, Debug)] 11 pub struct EditorSnapshot { 12 pub content: String, 13 pub cursor_offset: usize, 14 } 15 ··· 18 19 /// Save editor state to LocalStorage (WASM only). 20 #[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> { 25 let snapshot = EditorSnapshot { 26 - content: content.to_string(), 27 - cursor_offset, 28 }; 29 LocalStorage::set(STORAGE_KEY, &snapshot) 30 } 31 32 /// Load editor state from LocalStorage (WASM only). 33 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 34 - pub fn load_from_storage() -> Option<EditorSnapshot> { 35 - LocalStorage::get(STORAGE_KEY).ok() 36 } 37 38 /// Clear editor state from LocalStorage (WASM only). ··· 44 45 // Stub implementations for non-WASM targets 46 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 47 - pub fn save_to_storage(_content: &str, _cursor_offset: usize) -> Result<(), String> { 48 Ok(()) 49 } 50 51 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 52 - pub fn load_from_storage() -> Option<EditorSnapshot> { 53 None 54 } 55
··· 1 //! LocalStorage persistence for the editor. 2 //! 3 + //! Stores both human-readable content (for debugging) and the full CRDT 4 + //! snapshot (for undo history preservation across sessions). 5 6 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 8 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 9 use gloo_storage::{LocalStorage, Storage}; 10 + use loro::cursor::Cursor; 11 use serde::{Deserialize, Serialize}; 12 13 + use super::document::EditorDocument; 14 + 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 22 #[derive(Serialize, Deserialize, Clone, Debug)] 23 pub struct EditorSnapshot { 24 + /// Human-readable document content (for debugging/fallback) 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) 31 pub cursor_offset: usize, 32 } 33 ··· 36 37 /// Save editor state to LocalStorage (WASM only). 38 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 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 + 47 let snapshot = EditorSnapshot { 48 + content: doc.to_string(), 49 + snapshot: snapshot_b64, 50 + cursor: doc.loro_cursor().cloned(), 51 + cursor_offset: doc.cursor.offset, 52 }; 53 LocalStorage::set(STORAGE_KEY, &snapshot) 54 } 55 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. 59 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 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) 84 } 85 86 /// Clear editor state from LocalStorage (WASM only). ··· 92 93 // Stub implementations for non-WASM targets 94 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 95 + pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> { 96 Ok(()) 97 } 98 99 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 100 + pub fn load_from_storage() -> Option<EditorDocument> { 101 None 102 } 103