···44 }
4546 // Find mapping for this cursor position
47- let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset)
48 .ok_or("no mapping found for cursor offset")?;
4950- tracing::info!("[CURSOR] Restoring cursor at offset {}", char_offset);
51- tracing::info!("[CURSOR] found mapping: char_range {:?}, node_id '{}', char_offset_in_node {}",
52- mapping.char_range, mapping.node_id, mapping.char_offset_in_node);
53-54- // If cursor is in invisible content, snap to next visible position
55- // For now, we'll still use the mapping but this is a future enhancement
56- if should_snap {
57- tracing::debug!("cursor in invisible content at offset {}", char_offset);
58- }
5960 // Get window and document
61 let window = web_sys::window().ok_or("no window")?;
···123 let mut accumulated_utf16 = 0;
124 let mut last_node: Option<web_sys::Node> = None;
125126- tracing::info!("[CURSOR] Walking text nodes, target_utf16_offset = {}", target_utf16_offset);
127 while let Some(node) = walker.next_node()? {
128 last_node = Some(node.clone());
129130 if let Some(text) = node.text_content() {
131 let text_len = text.encode_utf16().count();
132- tracing::info!("[CURSOR] text node: '{}' (utf16_len {}), accumulated = {}",
133- text.chars().take(20).collect::<String>(), text_len, accumulated_utf16);
134135 // Found the node containing target offset
136 if accumulated_utf16 + text_len >= target_utf16_offset {
137 let offset_in_node = target_utf16_offset - accumulated_utf16;
138- tracing::info!("[CURSOR] -> FOUND at offset {} in this node", offset_in_node);
139 return Ok((node, offset_in_node));
140 }
141
···44 }
4546 // Find mapping for this cursor position
47+ let (mapping, _should_snap) = find_mapping_for_char(offset_map, char_offset)
48 .ok_or("no mapping found for cursor offset")?;
4950+ tracing::trace!(
51+ target: "weaver::cursor",
52+ char_offset,
53+ node_id = %mapping.node_id,
54+ mapping_range = ?mapping.char_range,
55+ child_index = ?mapping.child_index,
56+ "restoring cursor position"
57+ );
05859 // Get window and document
60 let window = web_sys::window().ok_or("no window")?;
···122 let mut accumulated_utf16 = 0;
123 let mut last_node: Option<web_sys::Node> = None;
1240125 while let Some(node) = walker.next_node()? {
126 last_node = Some(node.clone());
127128 if let Some(text) = node.text_content() {
129 let text_len = text.encode_utf16().count();
00130131 // Found the node containing target offset
132 if accumulated_utf16 + text_len >= target_utf16_offset {
133 let offset_in_node = target_utf16_offset - accumulated_utf16;
0134 return Ok((node, offset_in_node));
135 }
136
···87 /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
88 /// Edits here could affect block-level syntax like headings, lists, code fences.
89 pub in_block_syntax_zone: bool,
000090}
9192/// Max distance from line start where block syntax can appear.
···183 /// Insert text and record edit info for incremental rendering.
184 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
185 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
000186 self.last_edit = Some(EditInfo {
187 edit_char_pos: pos,
188- inserted_len: text.chars().count(),
189 deleted_len: 0,
190 contains_newline: text.contains('\n'),
191 in_block_syntax_zone,
0192 });
193- self.text.insert(pos, text)
194 }
195196 /// Remove text range and record edit info for incremental rendering.
197 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
198 let content = self.text.to_string();
199- let end = start + len;
200 let contains_newline = content
201 .chars()
202 .skip(start)
···204 .any(|c| c == '\n');
205 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
2060207 self.last_edit = Some(EditInfo {
208 edit_char_pos: start,
209 inserted_len: 0,
210 deleted_len: len,
211 contains_newline,
212 in_block_syntax_zone,
0213 });
214- self.text.delete(start, len)
215 }
216217 /// Replace text (delete then insert) and record combined edit info.
···224 .any(|c| c == '\n');
225 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
226000000000227 self.last_edit = Some(EditInfo {
228 edit_char_pos: start,
229- inserted_len: text.chars().count(),
230 deleted_len: len,
231 contains_newline: delete_has_newline || text.contains('\n'),
232 in_block_syntax_zone,
0233 });
234-235- // Use splice for atomic replace
236- self.text.splice(start, len, text)?;
237 Ok(())
238 }
239···321 self.doc.export(ExportMode::Snapshot).unwrap_or_default()
322 }
323000000324 /// 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`.
0000329 pub fn from_snapshot(
330 snapshot: &[u8],
331 loro_cursor: Option<Cursor>,
···341342 let text = doc.get_text("content");
343344- // 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);
···87 /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
88 /// Edits here could affect block-level syntax like headings, lists, code fences.
89 pub in_block_syntax_zone: bool,
90+ /// Document length (in chars) after this edit was applied.
91+ /// Used to detect stale edit info - if current doc length doesn't match,
92+ /// the edit info is from a previous render cycle and shouldn't be used.
93+ pub doc_len_after: usize,
94}
9596/// Max distance from line start where block syntax can appear.
···187 /// Insert text and record edit info for incremental rendering.
188 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
189 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
190+ let len_before = self.text.len_unicode();
191+ let result = self.text.insert(pos, text);
192+ let len_after = self.text.len_unicode();
193 self.last_edit = Some(EditInfo {
194 edit_char_pos: pos,
195+ inserted_len: len_after.saturating_sub(len_before),
196 deleted_len: 0,
197 contains_newline: text.contains('\n'),
198 in_block_syntax_zone,
199+ doc_len_after: len_after,
200 });
201+ result
202 }
203204 /// Remove text range and record edit info for incremental rendering.
205 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
206 let content = self.text.to_string();
0207 let contains_newline = content
208 .chars()
209 .skip(start)
···211 .any(|c| c == '\n');
212 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
213214+ let result = self.text.delete(start, len);
215 self.last_edit = Some(EditInfo {
216 edit_char_pos: start,
217 inserted_len: 0,
218 deleted_len: len,
219 contains_newline,
220 in_block_syntax_zone,
221+ doc_len_after: self.text.len_unicode(),
222 });
223+ result
224 }
225226 /// Replace text (delete then insert) and record combined edit info.
···233 .any(|c| c == '\n');
234 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
235236+ let len_before = self.text.len_unicode();
237+ // Use splice for atomic replace
238+ self.text.splice(start, len, text)?;
239+ let len_after = self.text.len_unicode();
240+241+ // inserted_len = (len_after - len_before) + deleted_len
242+ // because: len_after = len_before - deleted + inserted
243+ let inserted_len = (len_after + len).saturating_sub(len_before);
244+245 self.last_edit = Some(EditInfo {
246 edit_char_pos: start,
247+ inserted_len,
248 deleted_len: len,
249 contains_newline: delete_has_newline || text.contains('\n'),
250 in_block_syntax_zone,
251+ doc_len_after: len_after,
252 });
000253 Ok(())
254 }
255···337 self.doc.export(ExportMode::Snapshot).unwrap_or_default()
338 }
339340+ /// Get the current state frontiers for change detection.
341+ /// Frontiers represent the "version" of the document state.
342+ pub fn state_frontiers(&self) -> loro::Frontiers {
343+ self.doc.state_frontiers()
344+ }
345+346 /// Create a new EditorDocument from a binary snapshot.
347 /// Falls back to empty document if import fails.
348 ///
349 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
350 /// Otherwise, falls back to `fallback_offset`.
351+ ///
352+ /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
353+ /// happen in real-time; it cannot rebuild history from imported CRDT ops.
354+ /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
355 pub fn from_snapshot(
356 snapshot: &[u8],
357 loro_cursor: Option<Cursor>,
···367368 let text = doc.get_text("content");
369370+ // Set up undo manager - tracks operations from this point forward only
371 let mut undo_mgr = UndoManager::new(&doc);
372 undo_mgr.set_merge_interval(300);
373 undo_mgr.set_max_undo_steps(100);
+50-75
crates/weaver-app/src/components/editor/mod.rs
···140 cached_paragraphs.set(new_paras.clone());
141142 // Update syntax visibility after DOM changes
143- // Debug: log what syntax spans we have
144- for span in spans.iter() {
145- tracing::debug!(
146- "[VISIBILITY_INPUT] span {} char_range {:?} formatted_range {:?}",
147- span.syn_id,
148- span.char_range,
149- span.formatted_range
150- );
151- }
152 update_syntax_visibility(
153 cursor_offset,
154 selection.as_ref(),
···157 );
158 });
159160- // Auto-save with debounce
000161 #[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- });
0000000000000000000000000000000174175- // 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 });
196197 rsx! {
···382 anchor,
383 head: focus,
384 });
385- tracing::debug!("[SYNC] Selection {}..{}", anchor, focus);
386 } else {
387 // Collapsed selection (just cursor)
388 doc.selection = None;
389- tracing::debug!("[SYNC] Cursor at {}", focus);
390 }
391 }
392 _ => {
···528529/// Handle paste events and insert text at cursor
530fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
531- tracing::info!("[PASTE] handle_paste called");
532-533 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
534 {
535 use dioxus::web::WebEventExt;
···539 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
540 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
541 if let Ok(text) = data_transfer.get_data("text/plain") {
542- tracing::info!("[PASTE] Got text: {} chars", text.len());
543 document.with_mut(|doc| {
544 // Delete selection if present
545 if let Some(sel) = doc.selection {
···563564/// Handle cut events - extract text, write to clipboard, then delete
565fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
566- tracing::info!("[CUT] handle_cut called");
567-568 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
569 {
570 use dioxus::web::WebEventExt;
···578 if start != end {
579 // Extract text
580 let selected_text = doc.slice(start, end).unwrap_or_default();
581- tracing::info!(
582- "[CUT] Extracted {} chars: {:?}",
583- selected_text.len(),
584- &selected_text[..selected_text.len().min(50)]
585- );
586587 // Write to clipboard BEFORE deleting
588 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···609610/// Handle copy events - extract text, clean it up, write to clipboard
611fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) {
612- tracing::info!("[COPY] handle_copy called");
613-614 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
615 {
616 use dioxus::web::WebEventExt;
···629 let clean_text = selected_text
630 .replace('\u{200C}', "")
631 .replace('\u{200B}', "");
632-633- tracing::info!(
634- "[COPY] Extracted {} chars (cleaned to {})",
635- selected_text.len(),
636- clean_text.len()
637- );
638639 // Write to clipboard
640 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···809 doc.cursor.offset += 3;
810 } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) {
811 // We're in a list item
812- tracing::debug!("[ENTER] List context detected: {:?}", ctx);
813- tracing::debug!(
814- "[ENTER] Cursor at {}, doc len {}",
815- doc.cursor.offset,
816- doc.len_chars()
817- );
818 if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) {
819- tracing::debug!("[ENTER] Item is empty, exiting list");
820 // Empty item - exit list by removing marker and inserting paragraph break
821 let line_start = find_line_start(doc.loro_text(), doc.cursor.offset);
822 let line_end = find_line_end(doc.loro_text(), doc.cursor.offset);
···948 indent.len() + number.to_string().len() + 2 // "1. "
949 }
950 };
951-952- tracing::debug!(
953- "[LIST] is_empty check: line={:?}, line.len()={}, marker_len={}, result={}",
954- line,
955- line.len(),
956- marker_len,
957- line.len() <= marker_len
958- );
959960 // Item is empty if line length equals marker length (nothing after marker)
961 line.len() <= marker_len
···140 cached_paragraphs.set(new_paras.clone());
141142 // Update syntax visibility after DOM changes
000000000143 update_syntax_visibility(
144 cursor_offset,
145 selection.as_ref(),
···148 );
149 });
150151+ // Track last saved frontiers to detect changes (peek-only, no subscriptions)
152+ let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None);
153+154+ // Auto-save with periodic check (no reactive dependency to avoid loops)
155 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
156 use_effect(move || {
157+ // Check every 500ms if there are unsaved changes
158+ let interval = gloo_timers::callback::Interval::new(500, move || {
159+ // Peek both signals without creating reactive dependencies
160+ let current_frontiers = document.peek().state_frontiers();
161+162+ // Only save if frontiers changed (document was edited)
163+ let needs_save = {
164+ let last_frontiers = last_saved_frontiers.peek();
165+ match &*last_frontiers {
166+ None => true, // First save
167+ Some(last) => ¤t_frontiers != last,
168+ }
169+ }; // drop last_frontiers borrow here
170+171+ if needs_save {
172+ // Sync cursor and extract data for save
173+ let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| {
174+ doc.sync_loro_cursor();
175+ (
176+ doc.to_string(),
177+ doc.cursor.offset,
178+ doc.loro_cursor().cloned(),
179+ doc.export_snapshot(),
180+ )
181+ });
182+183+ use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set
184+ let snapshot_b64 = if snapshot_bytes.is_empty() {
185+ None
186+ } else {
187+ Some(base64::Engine::encode(
188+ &base64::engine::general_purpose::STANDARD,
189+ &snapshot_bytes,
190+ ))
191+ };
192+ let snapshot = storage::EditorSnapshot {
193+ content,
194+ snapshot: snapshot_b64,
195+ cursor: loro_cursor,
196+ cursor_offset,
197+ };
198+ let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot);
199200+ // Update last saved frontiers
201+ last_saved_frontiers.set(Some(current_frontiers));
202+ }
000000000000000203 });
204+ interval.forget();
205 });
206207 rsx! {
···392 anchor,
393 head: focus,
394 });
0395 } else {
396 // Collapsed selection (just cursor)
397 doc.selection = None;
0398 }
399 }
400 _ => {
···536537/// Handle paste events and insert text at cursor
538fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
00539 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
540 {
541 use dioxus::web::WebEventExt;
···545 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
546 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
547 if let Ok(text) = data_transfer.get_data("text/plain") {
0548 document.with_mut(|doc| {
549 // Delete selection if present
550 if let Some(sel) = doc.selection {
···568569/// Handle cut events - extract text, write to clipboard, then delete
570fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
00571 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
572 {
573 use dioxus::web::WebEventExt;
···581 if start != end {
582 // Extract text
583 let selected_text = doc.slice(start, end).unwrap_or_default();
00000584585 // Write to clipboard BEFORE deleting
586 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···607608/// Handle copy events - extract text, clean it up, write to clipboard
609fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) {
00610 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
611 {
612 use dioxus::web::WebEventExt;
···625 let clean_text = selected_text
626 .replace('\u{200C}', "")
627 .replace('\u{200B}', "");
000000628629 // Write to clipboard
630 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···799 doc.cursor.offset += 3;
800 } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) {
801 // We're in a list item
000000802 if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) {
0803 // Empty item - exit list by removing marker and inserting paragraph break
804 let line_start = find_line_start(doc.loro_text(), doc.cursor.offset);
805 let line_end = find_line_end(doc.loro_text(), doc.cursor.offset);
···931 indent.len() + number.to_string().len() + 2 // "1. "
932 }
933 };
00000000934935 // Item is empty if line length equals marker length (nothing after marker)
936 line.len() <= marker_len
+31-11
crates/weaver-app/src/components/editor/render.rs
···151 }
152153 // Determine if we can use fast path (skip boundary discovery)
00154 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
1550000000000156 // Get paragraph boundaries
157 let paragraph_ranges = if use_fast_path {
158- // Fast path: adjust cached boundaries based on edit
159 let cache = cache.unwrap();
160 let edit = edit.unwrap();
161162 // Find which paragraph the edit falls into
163 let edit_pos = edit.edit_char_pos;
164- let char_delta = edit.inserted_len as isize - edit.deleted_len as isize;
0000165166 // Adjust each cached paragraph's range
167 cache
···210 }
211 };
212213- tracing::debug!("[RENDER] Discovered {} paragraph ranges", paragraph_ranges.len());
214 for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
215- tracing::debug!("[RENDER] Range {}: bytes {:?}, chars {:?}", i, byte_range, char_range);
00000000000216 }
217218 // Render paragraphs, reusing cache where possible
···224 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
225 let para_source = text_slice_to_string(text, char_range.clone());
226 let source_hash = hash_source(¶_source);
227-228- tracing::debug!(
229- "[RENDER] Para {}: char_range {:?}, source preview: {:?}",
230- idx,
231- char_range,
232- ¶_source[..para_source.len().min(50)]
233- );
234235 // Check if we have a cached render with matching hash
236 let cached_match =
···151 }
152153 // Determine if we can use fast path (skip boundary discovery)
154+ // Need cache and non-boundary-affecting edit info (for edit position)
155+ let current_len = text.len_unicode();
156 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
157158+ tracing::debug!(
159+ target: "weaver::render",
160+ use_fast_path,
161+ has_cache = cache.is_some(),
162+ has_edit = edit.is_some(),
163+ boundary_affecting = edit.map(is_boundary_affecting),
164+ current_len,
165+ "render path decision"
166+ );
167+168 // Get paragraph boundaries
169 let paragraph_ranges = if use_fast_path {
170+ // Fast path: adjust cached boundaries based on actual length change
171 let cache = cache.unwrap();
172 let edit = edit.unwrap();
173174 // Find which paragraph the edit falls into
175 let edit_pos = edit.edit_char_pos;
176+177+ // Compute delta from actual length difference, not edit info
178+ // This handles stale edits gracefully (delta = 0 if lengths match)
179+ let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
180+ let char_delta = current_len as isize - cached_len as isize;
181182 // Adjust each cached paragraph's range
183 cache
···226 }
227 };
228229+ // Log discovered paragraphs
230 for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
231+ let preview: String = text_slice_to_string(text, char_range.clone())
232+ .chars()
233+ .take(30)
234+ .collect();
235+ tracing::trace!(
236+ target: "weaver::render",
237+ para_idx = i,
238+ char_range = ?char_range,
239+ byte_range = ?byte_range,
240+ preview = %preview,
241+ "paragraph boundary"
242+ );
243 }
244245 // Render paragraphs, reusing cache where possible
···251 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
252 let para_source = text_slice_to_string(text, char_range.clone());
253 let source_hash = hash_source(¶_source);
0000000254255 // Check if we have a cached render with matching hash
256 let cached_match =
···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
00022#[derive(Serialize, Deserialize, Clone, Debug)]
23pub 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>,
···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 document history
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+///
23+/// Note: Undo/redo is session-only (UndoManager state is ephemeral).
24+/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
25#[derive(Serialize, Deserialize, Clone, Debug)]
26pub struct EditorSnapshot {
27 /// Human-readable document content (for debugging/fallback)
28 pub content: String,
29+ /// Base64-encoded CRDT snapshot
30 pub snapshot: Option<String>,
31 /// Loro Cursor for stable cursor position tracking
32 pub cursor: Option<Cursor>,