···2//!
3//! Uses Loro CRDT for text storage with built-in undo/redo support.
45-use loro::{LoroDoc, LoroResult, LoroText, UndoManager};
67/// Single source of truth for editor state.
8///
···21 /// Undo manager for the document.
22 undo_mgr: UndoManager,
2324- /// Current cursor position (char offset)
025 pub cursor: CursorState,
00002627 /// 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);
129000130 Self {
131 doc,
132 text,
···135 offset: 0,
136 affinity: Affinity::Before,
137 },
0138 selection: None,
139 composition: None,
140 last_edit: None,
···230231 /// Undo the last operation.
232 /// Returns true if an undo was performed.
0233 pub fn undo(&mut self) -> LoroResult<bool> {
234- self.undo_mgr.undo()
000000000235 }
236237 /// Redo the last undone operation.
238 /// Returns true if a redo was performed.
0239 pub fn redo(&mut self) -> LoroResult<bool> {
240- self.undo_mgr.redo()
00000000241 }
242243 /// Check if undo is available.
···255 pub fn slice(&self, start: usize, end: usize) -> Option<String> {
256 self.text.slice(start, end).ok()
257 }
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000258}
259260// 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;
00269 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.
45+use loro::{cursor::{Cursor, Side}, ExportMode, LoroDoc, LoroResult, LoroText, UndoManager};
67/// Single source of truth for editor state.
8///
···21 /// Undo manager for the document.
22 undo_mgr: UndoManager,
2324+ /// 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>,
3132 /// 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);
134135+ // 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,
···239240 /// 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 }
255256 /// 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 }
270271 /// 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}
379380// 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]
54pub 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";
6566 // 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);
000000172173 // Save after 500ms of no typing
174 let timer = gloo_timers::callback::Timeout::new(500, move || {
175- let _ = storage::save_to_storage(&content, cursor);
000000000000000176 });
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 }
0000000000000000000000000668 _ => {}
669 }
670 }
···813 }
814815 _ => {}
000000816 }
817 });
818}
···52/// - No mouse selection
53#[component]
54pub 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()))
0059 });
0060 let editor_id = "markdown-editor";
6162 // 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+ });
174175 // 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 }
856857 _ => {}
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}
6200000000063/// Adjust a cached paragraph's positions after an earlier edit.
64fn 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 }
7677 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 }
8283 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}
6263+/// 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.
73fn 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 }
8586 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 }
9192 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 })