···8787 /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
8888 /// Edits here could affect block-level syntax like headings, lists, code fences.
8989 pub in_block_syntax_zone: bool,
9090+ /// Document length (in chars) after this edit was applied.
9191+ /// Used to detect stale edit info - if current doc length doesn't match,
9292+ /// the edit info is from a previous render cycle and shouldn't be used.
9393+ pub doc_len_after: usize,
9094}
91959296/// Max distance from line start where block syntax can appear.
···183187 /// Insert text and record edit info for incremental rendering.
184188 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
185189 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
190190+ let len_before = self.text.len_unicode();
191191+ let result = self.text.insert(pos, text);
192192+ let len_after = self.text.len_unicode();
186193 self.last_edit = Some(EditInfo {
187194 edit_char_pos: pos,
188188- inserted_len: text.chars().count(),
195195+ inserted_len: len_after.saturating_sub(len_before),
189196 deleted_len: 0,
190197 contains_newline: text.contains('\n'),
191198 in_block_syntax_zone,
199199+ doc_len_after: len_after,
192200 });
193193- self.text.insert(pos, text)
201201+ result
194202 }
195203196204 /// Remove text range and record edit info for incremental rendering.
197205 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
198206 let content = self.text.to_string();
199199- let end = start + len;
200207 let contains_newline = content
201208 .chars()
202209 .skip(start)
···204211 .any(|c| c == '\n');
205212 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
206213214214+ let result = self.text.delete(start, len);
207215 self.last_edit = Some(EditInfo {
208216 edit_char_pos: start,
209217 inserted_len: 0,
210218 deleted_len: len,
211219 contains_newline,
212220 in_block_syntax_zone,
221221+ doc_len_after: self.text.len_unicode(),
213222 });
214214- self.text.delete(start, len)
223223+ result
215224 }
216225217226 /// Replace text (delete then insert) and record combined edit info.
···224233 .any(|c| c == '\n');
225234 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
226235236236+ let len_before = self.text.len_unicode();
237237+ // Use splice for atomic replace
238238+ self.text.splice(start, len, text)?;
239239+ let len_after = self.text.len_unicode();
240240+241241+ // inserted_len = (len_after - len_before) + deleted_len
242242+ // because: len_after = len_before - deleted + inserted
243243+ let inserted_len = (len_after + len).saturating_sub(len_before);
244244+227245 self.last_edit = Some(EditInfo {
228246 edit_char_pos: start,
229229- inserted_len: text.chars().count(),
247247+ inserted_len,
230248 deleted_len: len,
231249 contains_newline: delete_has_newline || text.contains('\n'),
232250 in_block_syntax_zone,
251251+ doc_len_after: len_after,
233252 });
234234-235235- // Use splice for atomic replace
236236- self.text.splice(start, len, text)?;
237253 Ok(())
238254 }
239255···321337 self.doc.export(ExportMode::Snapshot).unwrap_or_default()
322338 }
323339340340+ /// Get the current state frontiers for change detection.
341341+ /// Frontiers represent the "version" of the document state.
342342+ pub fn state_frontiers(&self) -> loro::Frontiers {
343343+ self.doc.state_frontiers()
344344+ }
345345+324346 /// Create a new EditorDocument from a binary snapshot.
325347 /// Falls back to empty document if import fails.
326348 ///
327349 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
328350 /// Otherwise, falls back to `fallback_offset`.
351351+ ///
352352+ /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
353353+ /// happen in real-time; it cannot rebuild history from imported CRDT ops.
354354+ /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
329355 pub fn from_snapshot(
330356 snapshot: &[u8],
331357 loro_cursor: Option<Cursor>,
···341367342368 let text = doc.get_text("content");
343369344344- // Set up undo manager
370370+ // Set up undo manager - tracks operations from this point forward only
345371 let mut undo_mgr = UndoManager::new(&doc);
346372 undo_mgr.set_merge_interval(300);
347373 undo_mgr.set_max_undo_steps(100);
+50-75
crates/weaver-app/src/components/editor/mod.rs
···140140 cached_paragraphs.set(new_paras.clone());
141141142142 // Update syntax visibility after DOM changes
143143- // Debug: log what syntax spans we have
144144- for span in spans.iter() {
145145- tracing::debug!(
146146- "[VISIBILITY_INPUT] span {} char_range {:?} formatted_range {:?}",
147147- span.syn_id,
148148- span.char_range,
149149- span.formatted_range
150150- );
151151- }
152143 update_syntax_visibility(
153144 cursor_offset,
154145 selection.as_ref(),
···157148 );
158149 });
159150160160- // Auto-save with debounce
151151+ // Track last saved frontiers to detect changes (peek-only, no subscriptions)
152152+ let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None);
153153+154154+ // Auto-save with periodic check (no reactive dependency to avoid loops)
161155 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
162156 use_effect(move || {
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- });
157157+ // Check every 500ms if there are unsaved changes
158158+ let interval = gloo_timers::callback::Interval::new(500, move || {
159159+ // Peek both signals without creating reactive dependencies
160160+ let current_frontiers = document.peek().state_frontiers();
161161+162162+ // Only save if frontiers changed (document was edited)
163163+ let needs_save = {
164164+ let last_frontiers = last_saved_frontiers.peek();
165165+ match &*last_frontiers {
166166+ None => true, // First save
167167+ Some(last) => ¤t_frontiers != last,
168168+ }
169169+ }; // drop last_frontiers borrow here
170170+171171+ if needs_save {
172172+ // Sync cursor and extract data for save
173173+ let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| {
174174+ doc.sync_loro_cursor();
175175+ (
176176+ doc.to_string(),
177177+ doc.cursor.offset,
178178+ doc.loro_cursor().cloned(),
179179+ doc.export_snapshot(),
180180+ )
181181+ });
182182+183183+ use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set
184184+ let snapshot_b64 = if snapshot_bytes.is_empty() {
185185+ None
186186+ } else {
187187+ Some(base64::Engine::encode(
188188+ &base64::engine::general_purpose::STANDARD,
189189+ &snapshot_bytes,
190190+ ))
191191+ };
192192+ let snapshot = storage::EditorSnapshot {
193193+ content,
194194+ snapshot: snapshot_b64,
195195+ cursor: loro_cursor,
196196+ cursor_offset,
197197+ };
198198+ let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot);
174199175175- // Save after 500ms of no typing
176176- let timer = gloo_timers::callback::Timeout::new(500, move || {
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);
200200+ // Update last saved frontiers
201201+ last_saved_frontiers.set(Some(current_frontiers));
202202+ }
193203 });
194194- timer.forget();
204204+ interval.forget();
195205 });
196206197207 rsx! {
···382392 anchor,
383393 head: focus,
384394 });
385385- tracing::debug!("[SYNC] Selection {}..{}", anchor, focus);
386395 } else {
387396 // Collapsed selection (just cursor)
388397 doc.selection = None;
389389- tracing::debug!("[SYNC] Cursor at {}", focus);
390398 }
391399 }
392400 _ => {
···528536529537/// Handle paste events and insert text at cursor
530538fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
531531- tracing::info!("[PASTE] handle_paste called");
532532-533539 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
534540 {
535541 use dioxus::web::WebEventExt;
···539545 if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
540546 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
541547 if let Ok(text) = data_transfer.get_data("text/plain") {
542542- tracing::info!("[PASTE] Got text: {} chars", text.len());
543548 document.with_mut(|doc| {
544549 // Delete selection if present
545550 if let Some(sel) = doc.selection {
···563568564569/// Handle cut events - extract text, write to clipboard, then delete
565570fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
566566- tracing::info!("[CUT] handle_cut called");
567567-568571 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
569572 {
570573 use dioxus::web::WebEventExt;
···578581 if start != end {
579582 // Extract text
580583 let selected_text = doc.slice(start, end).unwrap_or_default();
581581- tracing::info!(
582582- "[CUT] Extracted {} chars: {:?}",
583583- selected_text.len(),
584584- &selected_text[..selected_text.len().min(50)]
585585- );
586584587585 // Write to clipboard BEFORE deleting
588586 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···609607610608/// Handle copy events - extract text, clean it up, write to clipboard
611609fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) {
612612- tracing::info!("[COPY] handle_copy called");
613613-614610 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
615611 {
616612 use dioxus::web::WebEventExt;
···629625 let clean_text = selected_text
630626 .replace('\u{200C}', "")
631627 .replace('\u{200B}', "");
632632-633633- tracing::info!(
634634- "[COPY] Extracted {} chars (cleaned to {})",
635635- selected_text.len(),
636636- clean_text.len()
637637- );
638628639629 // Write to clipboard
640630 if let Some(data_transfer) = clipboard_evt.clipboard_data() {
···809799 doc.cursor.offset += 3;
810800 } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) {
811801 // We're in a list item
812812- tracing::debug!("[ENTER] List context detected: {:?}", ctx);
813813- tracing::debug!(
814814- "[ENTER] Cursor at {}, doc len {}",
815815- doc.cursor.offset,
816816- doc.len_chars()
817817- );
818802 if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) {
819819- tracing::debug!("[ENTER] Item is empty, exiting list");
820803 // Empty item - exit list by removing marker and inserting paragraph break
821804 let line_start = find_line_start(doc.loro_text(), doc.cursor.offset);
822805 let line_end = find_line_end(doc.loro_text(), doc.cursor.offset);
···948931 indent.len() + number.to_string().len() + 2 // "1. "
949932 }
950933 };
951951-952952- tracing::debug!(
953953- "[LIST] is_empty check: line={:?}, line.len()={}, marker_len={}, result={}",
954954- line,
955955- line.len(),
956956- marker_len,
957957- line.len() <= marker_len
958958- );
959934960935 // Item is empty if line length equals marker length (nothing after marker)
961936 line.len() <= marker_len
+31-11
crates/weaver-app/src/components/editor/render.rs
···151151 }
152152153153 // Determine if we can use fast path (skip boundary discovery)
154154+ // Need cache and non-boundary-affecting edit info (for edit position)
155155+ let current_len = text.len_unicode();
154156 let use_fast_path = cache.is_some() && edit.is_some() && !is_boundary_affecting(edit.unwrap());
155157158158+ tracing::debug!(
159159+ target: "weaver::render",
160160+ use_fast_path,
161161+ has_cache = cache.is_some(),
162162+ has_edit = edit.is_some(),
163163+ boundary_affecting = edit.map(is_boundary_affecting),
164164+ current_len,
165165+ "render path decision"
166166+ );
167167+156168 // Get paragraph boundaries
157169 let paragraph_ranges = if use_fast_path {
158158- // Fast path: adjust cached boundaries based on edit
170170+ // Fast path: adjust cached boundaries based on actual length change
159171 let cache = cache.unwrap();
160172 let edit = edit.unwrap();
161173162174 // Find which paragraph the edit falls into
163175 let edit_pos = edit.edit_char_pos;
164164- let char_delta = edit.inserted_len as isize - edit.deleted_len as isize;
176176+177177+ // Compute delta from actual length difference, not edit info
178178+ // This handles stale edits gracefully (delta = 0 if lengths match)
179179+ let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0);
180180+ let char_delta = current_len as isize - cached_len as isize;
165181166182 // Adjust each cached paragraph's range
167183 cache
···210226 }
211227 };
212228213213- tracing::debug!("[RENDER] Discovered {} paragraph ranges", paragraph_ranges.len());
229229+ // Log discovered paragraphs
214230 for (i, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
215215- tracing::debug!("[RENDER] Range {}: bytes {:?}, chars {:?}", i, byte_range, char_range);
231231+ let preview: String = text_slice_to_string(text, char_range.clone())
232232+ .chars()
233233+ .take(30)
234234+ .collect();
235235+ tracing::trace!(
236236+ target: "weaver::render",
237237+ para_idx = i,
238238+ char_range = ?char_range,
239239+ byte_range = ?byte_range,
240240+ preview = %preview,
241241+ "paragraph boundary"
242242+ );
216243 }
217244218245 // Render paragraphs, reusing cache where possible
···224251 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
225252 let para_source = text_slice_to_string(text, char_range.clone());
226253 let source_hash = hash_source(¶_source);
227227-228228- tracing::debug!(
229229- "[RENDER] Para {}: char_range {:?}, source preview: {:?}",
230230- idx,
231231- char_range,
232232- ¶_source[..para_source.len().min(50)]
233233- );
234254235255 // Check if we have a cached render with matching hash
236256 let cached_match =
···1616///
1717/// Stores both human-readable content and CRDT snapshot for best of both worlds:
1818/// - `content`: Human-readable text for debugging
1919-/// - `snapshot`: Base64-encoded CRDT state for full undo history restoration
1919+/// - `snapshot`: Base64-encoded CRDT state for document history
2020/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position
2121/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored
2222+///
2323+/// Note: Undo/redo is session-only (UndoManager state is ephemeral).
2424+/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
2225#[derive(Serialize, Deserialize, Clone, Debug)]
2326pub struct EditorSnapshot {
2427 /// Human-readable document content (for debugging/fallback)
2528 pub content: String,
2626- /// Base64-encoded CRDT snapshot (preserves undo history)
2929+ /// Base64-encoded CRDT snapshot
2730 pub snapshot: Option<String>,
2831 /// Loro Cursor for stable cursor position tracking
2932 pub cursor: Option<Cursor>,