diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 5914413..be81d83 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -2,7 +2,7 @@ //! //! Uses Loro CRDT for text storage with built-in undo/redo support. -use loro::{LoroDoc, LoroResult, LoroText, UndoManager}; +use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager}; /// Single source of truth for editor state. /// @@ -21,9 +21,14 @@ pub struct EditorDocument { /// Undo manager for the document. undo_mgr: UndoManager, - /// Current cursor position (char offset) + /// Current cursor position (char offset) - fast local cache. + /// This is the authoritative position for immediate operations. pub cursor: CursorState, + /// CRDT-aware cursor that tracks position through remote edits and undo/redo. + /// Recreated after our own edits, queried after undo/redo/remote edits. + loro_cursor: Option, + /// Active selection if any pub selection: Option, @@ -127,6 +132,9 @@ impl EditorDocument { undo_mgr.set_merge_interval(300); // 300ms merge window undo_mgr.set_max_undo_steps(100); + // Create initial Loro cursor at position 0 + let loro_cursor = text.get_cursor(0, Side::default()); + Self { doc, text, @@ -135,6 +143,7 @@ impl EditorDocument { offset: 0, affinity: Affinity::Before, }, + loro_cursor, selection: None, composition: None, last_edit: None, @@ -230,14 +239,33 @@ impl EditorDocument { /// Undo the last operation. /// Returns true if an undo was performed. + /// Automatically updates cursor position from the Loro cursor. pub fn undo(&mut self) -> LoroResult { - self.undo_mgr.undo() + // Sync Loro cursor to current position BEFORE undo + // so it tracks through the undo operation + self.sync_loro_cursor(); + + let result = self.undo_mgr.undo()?; + if result { + // After undo, query Loro cursor for new position + self.sync_cursor_from_loro(); + } + Ok(result) } /// Redo the last undone operation. /// Returns true if a redo was performed. + /// Automatically updates cursor position from the Loro cursor. pub fn redo(&mut self) -> LoroResult { - self.undo_mgr.redo() + // Sync Loro cursor to current position BEFORE redo + self.sync_loro_cursor(); + + let result = self.undo_mgr.redo()?; + if result { + // After redo, query Loro cursor for new position + self.sync_cursor_from_loro(); + } + Ok(result) } /// Check if undo is available. @@ -255,6 +283,98 @@ impl EditorDocument { pub fn slice(&self, start: usize, end: usize) -> Option { self.text.slice(start, end).ok() } + + /// Sync the Loro cursor to the current cursor.offset position. + /// Call this after OUR edits where we know the new cursor position. + pub fn sync_loro_cursor(&mut self) { + self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default()); + } + + /// Update cursor.offset from the Loro cursor's tracked position. + /// Call this after undo/redo or remote edits where the position may have shifted. + /// Returns the new offset, or None if the cursor couldn't be resolved. + pub fn sync_cursor_from_loro(&mut self) -> Option { + let loro_cursor = self.loro_cursor.as_ref()?; + let result = self.doc.get_cursor_pos(loro_cursor).ok()?; + let new_offset = result.current.pos; + self.cursor.offset = new_offset.min(self.len_chars()); + Some(self.cursor.offset) + } + + /// Get the Loro cursor for serialization. + pub fn loro_cursor(&self) -> Option<&Cursor> { + self.loro_cursor.as_ref() + } + + /// Set the Loro cursor (used when restoring from storage). + pub fn set_loro_cursor(&mut self, cursor: Option) { + self.loro_cursor = cursor; + // Sync cursor.offset from the restored Loro cursor + if self.loro_cursor.is_some() { + self.sync_cursor_from_loro(); + } + } + + /// Export the document as a binary snapshot. + /// This captures all CRDT state including undo history. + pub fn export_snapshot(&self) -> Vec { + self.doc.export(ExportMode::Snapshot).unwrap_or_default() + } + + /// Create a new EditorDocument from a binary snapshot. + /// Falls back to empty document if import fails. + /// + /// If `loro_cursor` is provided, it will be used to restore the cursor position. + /// Otherwise, falls back to `fallback_offset`. + pub fn from_snapshot( + snapshot: &[u8], + loro_cursor: Option, + fallback_offset: usize, + ) -> Self { + let doc = LoroDoc::new(); + + if !snapshot.is_empty() { + if let Err(e) = doc.import(snapshot) { + tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e); + } + } + + let text = doc.get_text("content"); + + // Set up undo manager + let mut undo_mgr = UndoManager::new(&doc); + undo_mgr.set_merge_interval(300); + undo_mgr.set_max_undo_steps(100); + + // Try to restore cursor from Loro cursor, fall back to offset + let max_offset = text.len_unicode(); + let cursor_offset = if let Some(ref lc) = loro_cursor { + doc.get_cursor_pos(lc) + .map(|r| r.current.pos) + .unwrap_or(fallback_offset) + } else { + fallback_offset + }; + + let cursor = CursorState { + offset: cursor_offset.min(max_offset), + affinity: Affinity::Before, + }; + + // If no Loro cursor provided, create one at the restored position + let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default())); + + Self { + doc, + text, + undo_mgr, + cursor, + loro_cursor, + selection: None, + composition: None, + last_edit: None, + } + } } // EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone. @@ -266,6 +386,8 @@ impl Clone for EditorDocument { let content = self.to_string(); let mut new_doc = Self::new(content); new_doc.cursor = self.cursor; + // Recreate Loro cursor at the same position in the new doc + new_doc.sync_loro_cursor(); new_doc.selection = self.selection; new_doc.composition = self.composition.clone(); new_doc.last_edit = self.last_edit.clone(); diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 432768e..ef759ea 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -52,15 +52,11 @@ use dioxus::prelude::*; /// - No mouse selection #[component] pub fn MarkdownEditor(initial_content: Option) -> Element { - // Try to restore from localStorage - let restored = use_memo(move || { + // Try to restore from localStorage (includes CRDT state for undo history) + let mut document = use_signal(move || { storage::load_from_storage() - .map(|s| s.content) - .or_else(|| initial_content.clone()) - .unwrap_or_default() + .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) }); - - let mut document = use_signal(|| EditorDocument::new(restored())); let editor_id = "markdown-editor"; // Cache for incremental paragraph rendering @@ -164,15 +160,36 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { // Auto-save with debounce #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { - // Read document once and extract what we need - let doc = document(); - let content = doc.to_string(); - let cursor = doc.cursor.offset; - drop(doc); + // Capture snapshot data, syncing Loro cursor first + let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| { + // Sync Loro cursor to current position before saving + doc.sync_loro_cursor(); + ( + doc.to_string(), + doc.cursor.offset, + doc.loro_cursor().cloned(), + doc.export_snapshot(), + ) + }); // Save after 500ms of no typing let timer = gloo_timers::callback::Timeout::new(500, move || { - let _ = storage::save_to_storage(&content, cursor); + use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set + let snapshot_b64 = if snapshot_bytes.is_empty() { + None + } else { + Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &snapshot_bytes, + )) + }; + let snapshot = storage::EditorSnapshot { + content, + snapshot: snapshot_b64, + cursor: loro_cursor, + cursor_offset, + }; + let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot); }); timer.forget(); }); @@ -279,10 +296,10 @@ fn should_intercept_key(evt: &Event) -> bool { // Handle Ctrl/Cmd shortcuts if mods.ctrl() || mods.meta() { if let Key::Character(ch) = &key { - // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I) - return matches!(ch.as_str(), "b" | "i"); + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y) + return matches!(ch.as_str(), "b" | "i" | "z" | "y"); } - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.) + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) return false; } @@ -665,6 +682,31 @@ fn handle_keydown(evt: Event, document: &mut Signal { + if mods.shift() { + // Ctrl+Shift+Z = redo + if let Ok(true) = doc.redo() { + // Cursor position should be handled by the undo manager + // but we may need to clamp it + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + } else { + // Ctrl+Z = undo + if let Ok(true) = doc.undo() { + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + } + doc.selection = None; + return; + } + "y" => { + // Ctrl+Y = redo (alternative) + if let Ok(true) = doc.redo() { + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); + } + doc.selection = None; + return; + } _ => {} } } @@ -814,6 +856,12 @@ fn handle_keydown(evt: Event, document: &mut Signal {} } + + // Sync Loro cursor when edits affect paragraph boundaries + // This ensures cursor position is tracked correctly through structural changes + if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { + doc.sync_loro_cursor(); + } }); } diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index 411f487..d42d1ca 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -60,6 +60,15 @@ fn is_boundary_affecting(edit: &EditInfo) -> bool { false } +/// Apply a signed delta to a usize, saturating at 0 on underflow. +fn apply_delta(val: usize, delta: isize) -> usize { + if delta >= 0 { + val.saturating_add(delta as usize) + } else { + val.saturating_sub((-delta) as usize) + } +} + /// Adjust a cached paragraph's positions after an earlier edit. fn adjust_paragraph_positions( cached: &CachedParagraph, @@ -68,23 +77,23 @@ fn adjust_paragraph_positions( ) -> ParagraphRender { let mut adjusted_map = cached.offset_map.clone(); for mapping in &mut adjusted_map { - mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize; - mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize; - mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize; - mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize; + mapping.char_range.start = apply_delta(mapping.char_range.start, char_delta); + mapping.char_range.end = apply_delta(mapping.char_range.end, char_delta); + mapping.byte_range.start = apply_delta(mapping.byte_range.start, byte_delta); + mapping.byte_range.end = apply_delta(mapping.byte_range.end, byte_delta); } let mut adjusted_syntax = cached.syntax_spans.clone(); for span in &mut adjusted_syntax { - span.char_range.start = (span.char_range.start as isize + char_delta) as usize; - span.char_range.end = (span.char_range.end as isize + char_delta) as usize; + span.char_range.start = apply_delta(span.char_range.start, char_delta); + span.char_range.end = apply_delta(span.char_range.end, char_delta); } ParagraphRender { - byte_range: (cached.byte_range.start as isize + byte_delta) as usize - ..(cached.byte_range.end as isize + byte_delta) as usize, - char_range: (cached.char_range.start as isize + char_delta) as usize - ..(cached.char_range.end as isize + char_delta) as usize, + byte_range: apply_delta(cached.byte_range.start, byte_delta) + ..apply_delta(cached.byte_range.end, byte_delta), + char_range: apply_delta(cached.char_range.start, char_delta) + ..apply_delta(cached.char_range.end, char_delta), html: cached.html.clone(), offset_map: adjusted_map, syntax_spans: adjusted_syntax, @@ -159,25 +168,25 @@ pub fn render_paragraphs_incremental( .paragraphs .iter() .map(|p| { - if p.char_range.end <= edit_pos { - // Before edit - no change + if p.char_range.end < edit_pos { + // Before edit - no change (edit is strictly after this paragraph) (p.byte_range.clone(), p.char_range.clone()) - } else if p.char_range.start >= edit_pos { - // After edit - shift by delta + } else if p.char_range.start > edit_pos { + // After edit - shift by delta (edit is strictly before this paragraph) // Calculate byte delta (approximation: assume 1 byte per char for ASCII) // This is imprecise but boundaries are rediscovered on slow path anyway let byte_delta = char_delta; // TODO: proper byte calculation ( - (p.byte_range.start as isize + byte_delta) as usize - ..(p.byte_range.end as isize + byte_delta) as usize, - (p.char_range.start as isize + char_delta) as usize - ..(p.char_range.end as isize + char_delta) as usize, + apply_delta(p.byte_range.start, byte_delta) + ..apply_delta(p.byte_range.end, byte_delta), + apply_delta(p.char_range.start, char_delta) + ..apply_delta(p.char_range.end, char_delta), ) } else { - // Edit is within this paragraph - expand its end + // Edit is at or within this paragraph - expand its end ( - p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize, - p.char_range.start..(p.char_range.end as isize + char_delta) as usize, + p.byte_range.start..apply_delta(p.byte_range.end, char_delta), + p.char_range.start..apply_delta(p.char_range.end, char_delta), ) } }) diff --git a/crates/weaver-app/src/components/editor/storage.rs b/crates/weaver-app/src/components/editor/storage.rs index 2c64d63..1a1d4c5 100644 --- a/crates/weaver-app/src/components/editor/storage.rs +++ b/crates/weaver-app/src/components/editor/storage.rs @@ -1,15 +1,33 @@ //! LocalStorage persistence for the editor. //! -//! Only available on WASM targets. +//! Stores both human-readable content (for debugging) and the full CRDT +//! snapshot (for undo history preservation across sessions). +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; #[cfg(all(target_family = "wasm", target_os = "unknown"))] use gloo_storage::{LocalStorage, Storage}; +use loro::cursor::Cursor; use serde::{Deserialize, Serialize}; +use super::document::EditorDocument; + /// Editor snapshot for persistence. +/// +/// Stores both human-readable content and CRDT snapshot for best of both worlds: +/// - `content`: Human-readable text for debugging +/// - `snapshot`: Base64-encoded CRDT state for full undo history restoration +/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position +/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored #[derive(Serialize, Deserialize, Clone, Debug)] pub struct EditorSnapshot { + /// Human-readable document content (for debugging/fallback) pub content: String, + /// Base64-encoded CRDT snapshot (preserves undo history) + pub snapshot: Option, + /// Loro Cursor for stable cursor position tracking + pub cursor: Option, + /// Fallback cursor offset (used if Loro cursor can't be restored) pub cursor_offset: usize, } @@ -18,21 +36,51 @@ const STORAGE_KEY: &str = "weaver_editor_draft"; /// Save editor state to LocalStorage (WASM only). #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn save_to_storage( - content: &str, - cursor_offset: usize, -) -> Result<(), gloo_storage::errors::StorageError> { +pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> { + let snapshot_bytes = doc.export_snapshot(); + let snapshot_b64 = if snapshot_bytes.is_empty() { + None + } else { + Some(BASE64.encode(&snapshot_bytes)) + }; + let snapshot = EditorSnapshot { - content: content.to_string(), - cursor_offset, + content: doc.to_string(), + snapshot: snapshot_b64, + cursor: doc.loro_cursor().cloned(), + cursor_offset: doc.cursor.offset, }; LocalStorage::set(STORAGE_KEY, &snapshot) } /// Load editor state from LocalStorage (WASM only). +/// Returns an EditorDocument restored from CRDT snapshot if available, +/// otherwise falls back to just the text content. #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn load_from_storage() -> Option { - LocalStorage::get(STORAGE_KEY).ok() +pub fn load_from_storage() -> Option { + let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?; + + // Try to restore from CRDT snapshot first + if let Some(ref snapshot_b64) = snapshot.snapshot { + if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { + let doc = EditorDocument::from_snapshot( + &snapshot_bytes, + snapshot.cursor.clone(), + snapshot.cursor_offset, + ); + // Verify the content matches (sanity check) + if doc.to_string() == snapshot.content { + return Some(doc); + } + tracing::warn!("Snapshot content mismatch, falling back to text content"); + } + } + + // Fallback: create new doc from text content + let mut doc = EditorDocument::new(snapshot.content); + doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars()); + doc.sync_loro_cursor(); + Some(doc) } /// Clear editor state from LocalStorage (WASM only). @@ -44,12 +92,12 @@ pub fn clear_storage() { // Stub implementations for non-WASM targets #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn save_to_storage(_content: &str, _cursor_offset: usize) -> Result<(), String> { +pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> { Ok(()) } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn load_from_storage() -> Option { +pub fn load_from_storage() -> Option { None }