···22//!
33//! Uses Loro CRDT for text storage with built-in undo/redo support.
4455-use loro::{LoroDoc, LoroResult, LoroText, UndoManager};
55+use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager};
6677/// Single source of truth for editor state.
88///
···2121 /// Undo manager for the document.
2222 undo_mgr: UndoManager,
23232424- /// Current cursor position (char offset)
2424+ /// Current cursor position (char offset) - fast local cache.
2525+ /// This is the authoritative position for immediate operations.
2526 pub cursor: CursorState,
2727+2828+ /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
2929+ /// Recreated after our own edits, queried after undo/redo/remote edits.
3030+ loro_cursor: Option<Cursor>,
26312732 /// Active selection if any
2833 pub selection: Option<Selection>,
···127132 undo_mgr.set_merge_interval(300); // 300ms merge window
128133 undo_mgr.set_max_undo_steps(100);
129134135135+ // Create initial Loro cursor at position 0
136136+ let loro_cursor = text.get_cursor(0, Side::default());
137137+130138 Self {
131139 doc,
132140 text,
···135143 offset: 0,
136144 affinity: Affinity::Before,
137145 },
146146+ loro_cursor,
138147 selection: None,
139148 composition: None,
140149 last_edit: None,
···230239231240 /// Undo the last operation.
232241 /// Returns true if an undo was performed.
242242+ /// Automatically updates cursor position from the Loro cursor.
233243 pub fn undo(&mut self) -> LoroResult<bool> {
234234- self.undo_mgr.undo()
244244+ // Sync Loro cursor to current position BEFORE undo
245245+ // so it tracks through the undo operation
246246+ self.sync_loro_cursor();
247247+248248+ let result = self.undo_mgr.undo()?;
249249+ if result {
250250+ // After undo, query Loro cursor for new position
251251+ self.sync_cursor_from_loro();
252252+ }
253253+ Ok(result)
235254 }
236255237256 /// Redo the last undone operation.
238257 /// Returns true if a redo was performed.
258258+ /// Automatically updates cursor position from the Loro cursor.
239259 pub fn redo(&mut self) -> LoroResult<bool> {
240240- self.undo_mgr.redo()
260260+ // Sync Loro cursor to current position BEFORE redo
261261+ self.sync_loro_cursor();
262262+263263+ let result = self.undo_mgr.redo()?;
264264+ if result {
265265+ // After redo, query Loro cursor for new position
266266+ self.sync_cursor_from_loro();
267267+ }
268268+ Ok(result)
241269 }
242270243271 /// Check if undo is available.
···255283 pub fn slice(&self, start: usize, end: usize) -> Option<String> {
256284 self.text.slice(start, end).ok()
257285 }
286286+287287+ /// Sync the Loro cursor to the current cursor.offset position.
288288+ /// Call this after OUR edits where we know the new cursor position.
289289+ pub fn sync_loro_cursor(&mut self) {
290290+ self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default());
291291+ }
292292+293293+ /// Update cursor.offset from the Loro cursor's tracked position.
294294+ /// Call this after undo/redo or remote edits where the position may have shifted.
295295+ /// Returns the new offset, or None if the cursor couldn't be resolved.
296296+ pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
297297+ let loro_cursor = self.loro_cursor.as_ref()?;
298298+ let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
299299+ let new_offset = result.current.pos;
300300+ self.cursor.offset = new_offset.min(self.len_chars());
301301+ Some(self.cursor.offset)
302302+ }
303303+304304+ /// Get the Loro cursor for serialization.
305305+ pub fn loro_cursor(&self) -> Option<&Cursor> {
306306+ self.loro_cursor.as_ref()
307307+ }
308308+309309+ /// Set the Loro cursor (used when restoring from storage).
310310+ pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) {
311311+ self.loro_cursor = cursor;
312312+ // Sync cursor.offset from the restored Loro cursor
313313+ if self.loro_cursor.is_some() {
314314+ self.sync_cursor_from_loro();
315315+ }
316316+ }
317317+318318+ /// Export the document as a binary snapshot.
319319+ /// This captures all CRDT state including undo history.
320320+ pub fn export_snapshot(&self) -> Vec<u8> {
321321+ self.doc.export(ExportMode::Snapshot).unwrap_or_default()
322322+ }
323323+324324+ /// Create a new EditorDocument from a binary snapshot.
325325+ /// Falls back to empty document if import fails.
326326+ ///
327327+ /// If `loro_cursor` is provided, it will be used to restore the cursor position.
328328+ /// Otherwise, falls back to `fallback_offset`.
329329+ pub fn from_snapshot(
330330+ snapshot: &[u8],
331331+ loro_cursor: Option<Cursor>,
332332+ fallback_offset: usize,
333333+ ) -> Self {
334334+ let doc = LoroDoc::new();
335335+336336+ if !snapshot.is_empty() {
337337+ if let Err(e) = doc.import(snapshot) {
338338+ tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
339339+ }
340340+ }
341341+342342+ let text = doc.get_text("content");
343343+344344+ // Set up undo manager
345345+ let mut undo_mgr = UndoManager::new(&doc);
346346+ undo_mgr.set_merge_interval(300);
347347+ undo_mgr.set_max_undo_steps(100);
348348+349349+ // Try to restore cursor from Loro cursor, fall back to offset
350350+ let max_offset = text.len_unicode();
351351+ let cursor_offset = if let Some(ref lc) = loro_cursor {
352352+ doc.get_cursor_pos(lc)
353353+ .map(|r| r.current.pos)
354354+ .unwrap_or(fallback_offset)
355355+ } else {
356356+ fallback_offset
357357+ };
358358+359359+ let cursor = CursorState {
360360+ offset: cursor_offset.min(max_offset),
361361+ affinity: Affinity::Before,
362362+ };
363363+364364+ // If no Loro cursor provided, create one at the restored position
365365+ let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default()));
366366+367367+ Self {
368368+ doc,
369369+ text,
370370+ undo_mgr,
371371+ cursor,
372372+ loro_cursor,
373373+ selection: None,
374374+ composition: None,
375375+ last_edit: None,
376376+ }
377377+ }
258378}
259379260380// EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone.
···266386 let content = self.to_string();
267387 let mut new_doc = Self::new(content);
268388 new_doc.cursor = self.cursor;
389389+ // Recreate Loro cursor at the same position in the new doc
390390+ new_doc.sync_loro_cursor();
269391 new_doc.selection = self.selection;
270392 new_doc.composition = self.composition.clone();
271393 new_doc.last_edit = self.last_edit.clone();
+64-16
crates/weaver-app/src/components/editor/mod.rs
···5252/// - No mouse selection
5353#[component]
5454pub fn MarkdownEditor(initial_content: Option<String>) -> Element {
5555- // Try to restore from localStorage
5656- let restored = use_memo(move || {
5555+ // Try to restore from localStorage (includes CRDT state for undo history)
5656+ let mut document = use_signal(move || {
5757 storage::load_from_storage()
5858- .map(|s| s.content)
5959- .or_else(|| initial_content.clone())
6060- .unwrap_or_default()
5858+ .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default()))
6159 });
6262-6363- let mut document = use_signal(|| EditorDocument::new(restored()));
6460 let editor_id = "markdown-editor";
65616662 // Cache for incremental paragraph rendering
···164160 // Auto-save with debounce
165161 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
166162 use_effect(move || {
167167- // Read document once and extract what we need
168168- let doc = document();
169169- let content = doc.to_string();
170170- let cursor = doc.cursor.offset;
171171- drop(doc);
163163+ // Capture snapshot data, syncing Loro cursor first
164164+ let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| {
165165+ // Sync Loro cursor to current position before saving
166166+ doc.sync_loro_cursor();
167167+ (
168168+ doc.to_string(),
169169+ doc.cursor.offset,
170170+ doc.loro_cursor().cloned(),
171171+ doc.export_snapshot(),
172172+ )
173173+ });
172174173175 // Save after 500ms of no typing
174176 let timer = gloo_timers::callback::Timeout::new(500, move || {
175175- let _ = storage::save_to_storage(&content, cursor);
177177+ use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set
178178+ let snapshot_b64 = if snapshot_bytes.is_empty() {
179179+ None
180180+ } else {
181181+ Some(base64::Engine::encode(
182182+ &base64::engine::general_purpose::STANDARD,
183183+ &snapshot_bytes,
184184+ ))
185185+ };
186186+ let snapshot = storage::EditorSnapshot {
187187+ content,
188188+ snapshot: snapshot_b64,
189189+ cursor: loro_cursor,
190190+ cursor_offset,
191191+ };
192192+ let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot);
176193 });
177194 timer.forget();
178195 });
···279296 // Handle Ctrl/Cmd shortcuts
280297 if mods.ctrl() || mods.meta() {
281298 if let Key::Character(ch) = &key {
282282- // Intercept our formatting shortcuts (Ctrl+B, Ctrl+I)
283283- return matches!(ch.as_str(), "b" | "i");
299299+ // Intercept our shortcuts: formatting (b/i), undo/redo (z/y)
300300+ return matches!(ch.as_str(), "b" | "i" | "z" | "y");
284301 }
285285- // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.)
302302+ // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
286303 return false;
287304 }
288305···665682 formatting::apply_formatting(doc, FormatAction::Italic);
666683 return;
667684 }
685685+ "z" => {
686686+ if mods.shift() {
687687+ // Ctrl+Shift+Z = redo
688688+ if let Ok(true) = doc.redo() {
689689+ // Cursor position should be handled by the undo manager
690690+ // but we may need to clamp it
691691+ doc.cursor.offset = doc.cursor.offset.min(doc.len_chars());
692692+ }
693693+ } else {
694694+ // Ctrl+Z = undo
695695+ if let Ok(true) = doc.undo() {
696696+ doc.cursor.offset = doc.cursor.offset.min(doc.len_chars());
697697+ }
698698+ }
699699+ doc.selection = None;
700700+ return;
701701+ }
702702+ "y" => {
703703+ // Ctrl+Y = redo (alternative)
704704+ if let Ok(true) = doc.redo() {
705705+ doc.cursor.offset = doc.cursor.offset.min(doc.len_chars());
706706+ }
707707+ doc.selection = None;
708708+ return;
709709+ }
668710 _ => {}
669711 }
670712 }
···813855 }
814856815857 _ => {}
858858+ }
859859+860860+ // Sync Loro cursor when edits affect paragraph boundaries
861861+ // This ensures cursor position is tracked correctly through structural changes
862862+ if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) {
863863+ doc.sync_loro_cursor();
816864 }
817865 });
818866}
+30-21
crates/weaver-app/src/components/editor/render.rs
···6060 false
6161}
62626363+/// Apply a signed delta to a usize, saturating at 0 on underflow.
6464+fn apply_delta(val: usize, delta: isize) -> usize {
6565+ if delta >= 0 {
6666+ val.saturating_add(delta as usize)
6767+ } else {
6868+ val.saturating_sub((-delta) as usize)
6969+ }
7070+}
7171+6372/// Adjust a cached paragraph's positions after an earlier edit.
6473fn adjust_paragraph_positions(
6574 cached: &CachedParagraph,
···6877) -> ParagraphRender {
6978 let mut adjusted_map = cached.offset_map.clone();
7079 for mapping in &mut adjusted_map {
7171- mapping.char_range.start = (mapping.char_range.start as isize + char_delta) as usize;
7272- mapping.char_range.end = (mapping.char_range.end as isize + char_delta) as usize;
7373- mapping.byte_range.start = (mapping.byte_range.start as isize + byte_delta) as usize;
7474- mapping.byte_range.end = (mapping.byte_range.end as isize + byte_delta) as usize;
8080+ mapping.char_range.start = apply_delta(mapping.char_range.start, char_delta);
8181+ mapping.char_range.end = apply_delta(mapping.char_range.end, char_delta);
8282+ mapping.byte_range.start = apply_delta(mapping.byte_range.start, byte_delta);
8383+ mapping.byte_range.end = apply_delta(mapping.byte_range.end, byte_delta);
7584 }
76857786 let mut adjusted_syntax = cached.syntax_spans.clone();
7887 for span in &mut adjusted_syntax {
7979- span.char_range.start = (span.char_range.start as isize + char_delta) as usize;
8080- span.char_range.end = (span.char_range.end as isize + char_delta) as usize;
8888+ span.char_range.start = apply_delta(span.char_range.start, char_delta);
8989+ span.char_range.end = apply_delta(span.char_range.end, char_delta);
8190 }
82918392 ParagraphRender {
8484- byte_range: (cached.byte_range.start as isize + byte_delta) as usize
8585- ..(cached.byte_range.end as isize + byte_delta) as usize,
8686- char_range: (cached.char_range.start as isize + char_delta) as usize
8787- ..(cached.char_range.end as isize + char_delta) as usize,
9393+ byte_range: apply_delta(cached.byte_range.start, byte_delta)
9494+ ..apply_delta(cached.byte_range.end, byte_delta),
9595+ char_range: apply_delta(cached.char_range.start, char_delta)
9696+ ..apply_delta(cached.char_range.end, char_delta),
8897 html: cached.html.clone(),
8998 offset_map: adjusted_map,
9099 syntax_spans: adjusted_syntax,
···159168 .paragraphs
160169 .iter()
161170 .map(|p| {
162162- if p.char_range.end <= edit_pos {
163163- // Before edit - no change
171171+ if p.char_range.end < edit_pos {
172172+ // Before edit - no change (edit is strictly after this paragraph)
164173 (p.byte_range.clone(), p.char_range.clone())
165165- } else if p.char_range.start >= edit_pos {
166166- // After edit - shift by delta
174174+ } else if p.char_range.start > edit_pos {
175175+ // After edit - shift by delta (edit is strictly before this paragraph)
167176 // Calculate byte delta (approximation: assume 1 byte per char for ASCII)
168177 // This is imprecise but boundaries are rediscovered on slow path anyway
169178 let byte_delta = char_delta; // TODO: proper byte calculation
170179 (
171171- (p.byte_range.start as isize + byte_delta) as usize
172172- ..(p.byte_range.end as isize + byte_delta) as usize,
173173- (p.char_range.start as isize + char_delta) as usize
174174- ..(p.char_range.end as isize + char_delta) as usize,
180180+ apply_delta(p.byte_range.start, byte_delta)
181181+ ..apply_delta(p.byte_range.end, byte_delta),
182182+ apply_delta(p.char_range.start, char_delta)
183183+ ..apply_delta(p.char_range.end, char_delta),
175184 )
176185 } else {
177177- // Edit is within this paragraph - expand its end
186186+ // Edit is at or within this paragraph - expand its end
178187 (
179179- p.byte_range.start..(p.byte_range.end as isize + char_delta) as usize,
180180- p.char_range.start..(p.char_range.end as isize + char_delta) as usize,
188188+ p.byte_range.start..apply_delta(p.byte_range.end, char_delta),
189189+ p.char_range.start..apply_delta(p.char_range.end, char_delta),
181190 )
182191 }
183192 })