diff --git a/crates/weaver-app/src/components/editor/cursor.rs b/crates/weaver-app/src/components/editor/cursor.rs index 6e16ed8..c9af5f4 100644 --- a/crates/weaver-app/src/components/editor/cursor.rs +++ b/crates/weaver-app/src/components/editor/cursor.rs @@ -44,18 +44,17 @@ pub fn restore_cursor_position( } // Find mapping for this cursor position - let (mapping, should_snap) = find_mapping_for_char(offset_map, char_offset) + let (mapping, _should_snap) = find_mapping_for_char(offset_map, char_offset) .ok_or("no mapping found for cursor offset")?; - tracing::info!("[CURSOR] Restoring cursor at offset {}", char_offset); - tracing::info!("[CURSOR] found mapping: char_range {:?}, node_id '{}', char_offset_in_node {}", - mapping.char_range, mapping.node_id, mapping.char_offset_in_node); - - // If cursor is in invisible content, snap to next visible position - // For now, we'll still use the mapping but this is a future enhancement - if should_snap { - tracing::debug!("cursor in invisible content at offset {}", char_offset); - } + tracing::trace!( + target: "weaver::cursor", + char_offset, + node_id = %mapping.node_id, + mapping_range = ?mapping.char_range, + child_index = ?mapping.child_index, + "restoring cursor position" + ); // Get window and document let window = web_sys::window().ok_or("no window")?; @@ -123,19 +122,15 @@ fn find_text_node_at_offset( let mut accumulated_utf16 = 0; let mut last_node: Option = None; - tracing::info!("[CURSOR] Walking text nodes, target_utf16_offset = {}", target_utf16_offset); while let Some(node) = walker.next_node()? { last_node = Some(node.clone()); if let Some(text) = node.text_content() { let text_len = text.encode_utf16().count(); - tracing::info!("[CURSOR] text node: '{}' (utf16_len {}), accumulated = {}", - text.chars().take(20).collect::(), text_len, accumulated_utf16); // Found the node containing target offset if accumulated_utf16 + text_len >= target_utf16_offset { let offset_in_node = target_utf16_offset - accumulated_utf16; - tracing::info!("[CURSOR] -> FOUND at offset {} in this node", offset_in_node); return Ok((node, offset_in_node)); } diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index be81d83..e2e7fdb 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -87,6 +87,10 @@ pub struct EditInfo { /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). /// Edits here could affect block-level syntax like headings, lists, code fences. pub in_block_syntax_zone: bool, + /// Document length (in chars) after this edit was applied. + /// Used to detect stale edit info - if current doc length doesn't match, + /// the edit info is from a previous render cycle and shouldn't be used. + pub doc_len_after: usize, } /// Max distance from line start where block syntax can appear. @@ -183,20 +187,23 @@ impl EditorDocument { /// Insert text and record edit info for incremental rendering. pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); + let len_before = self.text.len_unicode(); + let result = self.text.insert(pos, text); + let len_after = self.text.len_unicode(); self.last_edit = Some(EditInfo { edit_char_pos: pos, - inserted_len: text.chars().count(), + inserted_len: len_after.saturating_sub(len_before), deleted_len: 0, contains_newline: text.contains('\n'), in_block_syntax_zone, + doc_len_after: len_after, }); - self.text.insert(pos, text) + result } /// Remove text range and record edit info for incremental rendering. pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { let content = self.text.to_string(); - let end = start + len; let contains_newline = content .chars() .skip(start) @@ -204,14 +211,16 @@ impl EditorDocument { .any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); + let result = self.text.delete(start, len); self.last_edit = Some(EditInfo { edit_char_pos: start, inserted_len: 0, deleted_len: len, contains_newline, in_block_syntax_zone, + doc_len_after: self.text.len_unicode(), }); - self.text.delete(start, len) + result } /// Replace text (delete then insert) and record combined edit info. @@ -224,16 +233,23 @@ impl EditorDocument { .any(|c| c == '\n'); let in_block_syntax_zone = self.is_in_block_syntax_zone(start); + let len_before = self.text.len_unicode(); + // Use splice for atomic replace + self.text.splice(start, len, text)?; + let len_after = self.text.len_unicode(); + + // inserted_len = (len_after - len_before) + deleted_len + // because: len_after = len_before - deleted + inserted + let inserted_len = (len_after + len).saturating_sub(len_before); + self.last_edit = Some(EditInfo { edit_char_pos: start, - inserted_len: text.chars().count(), + inserted_len, deleted_len: len, contains_newline: delete_has_newline || text.contains('\n'), in_block_syntax_zone, + doc_len_after: len_after, }); - - // Use splice for atomic replace - self.text.splice(start, len, text)?; Ok(()) } @@ -321,11 +337,21 @@ impl EditorDocument { self.doc.export(ExportMode::Snapshot).unwrap_or_default() } + /// Get the current state frontiers for change detection. + /// Frontiers represent the "version" of the document state. + pub fn state_frontiers(&self) -> loro::Frontiers { + self.doc.state_frontiers() + } + /// 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`. + /// + /// Note: Undo/redo is session-only. The UndoManager tracks operations as they + /// happen in real-time; it cannot rebuild history from imported CRDT ops. + /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. pub fn from_snapshot( snapshot: &[u8], loro_cursor: Option, @@ -341,7 +367,7 @@ impl EditorDocument { let text = doc.get_text("content"); - // Set up undo manager + // Set up undo manager - tracks operations from this point forward only let mut undo_mgr = UndoManager::new(&doc); undo_mgr.set_merge_interval(300); undo_mgr.set_max_undo_steps(100); diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index ef759ea..048d3bf 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -140,15 +140,6 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { cached_paragraphs.set(new_paras.clone()); // Update syntax visibility after DOM changes - // Debug: log what syntax spans we have - for span in spans.iter() { - tracing::debug!( - "[VISIBILITY_INPUT] span {} char_range {:?} formatted_range {:?}", - span.syn_id, - span.char_range, - span.formatted_range - ); - } update_syntax_visibility( cursor_offset, selection.as_ref(), @@ -157,41 +148,60 @@ pub fn MarkdownEditor(initial_content: Option) -> Element { ); }); - // Auto-save with debounce + // Track last saved frontiers to detect changes (peek-only, no subscriptions) + let mut last_saved_frontiers: Signal> = use_signal(|| None); + + // Auto-save with periodic check (no reactive dependency to avoid loops) #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] use_effect(move || { - // 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(), - ) - }); + // Check every 500ms if there are unsaved changes + let interval = gloo_timers::callback::Interval::new(500, move || { + // Peek both signals without creating reactive dependencies + let current_frontiers = document.peek().state_frontiers(); + + // Only save if frontiers changed (document was edited) + let needs_save = { + let last_frontiers = last_saved_frontiers.peek(); + match &*last_frontiers { + None => true, // First save + Some(last) => ¤t_frontiers != last, + } + }; // drop last_frontiers borrow here + + if needs_save { + // Sync cursor and extract data for save + let (content, cursor_offset, loro_cursor, snapshot_bytes) = document.with_mut(|doc| { + 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 || { - 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); + 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); + + // Update last saved frontiers + last_saved_frontiers.set(Some(current_frontiers)); + } }); - timer.forget(); + interval.forget(); }); rsx! { @@ -382,11 +392,9 @@ fn sync_cursor_from_dom( anchor, head: focus, }); - tracing::debug!("[SYNC] Selection {}..{}", anchor, focus); } else { // Collapsed selection (just cursor) doc.selection = None; - tracing::debug!("[SYNC] Cursor at {}", focus); } } _ => { @@ -528,8 +536,6 @@ fn update_syntax_visibility( /// Handle paste events and insert text at cursor fn handle_paste(evt: Event, document: &mut Signal) { - tracing::info!("[PASTE] handle_paste called"); - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use dioxus::web::WebEventExt; @@ -539,7 +545,6 @@ fn handle_paste(evt: Event, document: &mut Signal if let Some(clipboard_evt) = base_evt.dyn_ref::() { if let Some(data_transfer) = clipboard_evt.clipboard_data() { if let Ok(text) = data_transfer.get_data("text/plain") { - tracing::info!("[PASTE] Got text: {} chars", text.len()); document.with_mut(|doc| { // Delete selection if present if let Some(sel) = doc.selection { @@ -563,8 +568,6 @@ fn handle_paste(evt: Event, document: &mut Signal /// Handle cut events - extract text, write to clipboard, then delete fn handle_cut(evt: Event, document: &mut Signal) { - tracing::info!("[CUT] handle_cut called"); - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use dioxus::web::WebEventExt; @@ -578,11 +581,6 @@ fn handle_cut(evt: Event, document: &mut Signal) if start != end { // Extract text let selected_text = doc.slice(start, end).unwrap_or_default(); - tracing::info!( - "[CUT] Extracted {} chars: {:?}", - selected_text.len(), - &selected_text[..selected_text.len().min(50)] - ); // Write to clipboard BEFORE deleting if let Some(data_transfer) = clipboard_evt.clipboard_data() { @@ -609,8 +607,6 @@ fn handle_cut(evt: Event, document: &mut Signal) /// Handle copy events - extract text, clean it up, write to clipboard fn handle_copy(evt: Event, document: &Signal) { - tracing::info!("[COPY] handle_copy called"); - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] { use dioxus::web::WebEventExt; @@ -630,12 +626,6 @@ fn handle_copy(evt: Event, document: &Signal) { .replace('\u{200C}', "") .replace('\u{200B}', ""); - tracing::info!( - "[COPY] Extracted {} chars (cleaned to {})", - selected_text.len(), - clean_text.len() - ); - // Write to clipboard if let Some(data_transfer) = clipboard_evt.clipboard_data() { if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { @@ -809,14 +799,7 @@ fn handle_keydown(evt: Event, document: &mut Signal, /// Loro Cursor for stable cursor position tracking pub cursor: Option, diff --git a/crates/weaver-app/src/components/editor/visibility.rs b/crates/weaver-app/src/components/editor/visibility.rs index 4dbcea2..3400696 100644 --- a/crates/weaver-app/src/components/editor/visibility.rs +++ b/crates/weaver-app/src/components/editor/visibility.rs @@ -72,23 +72,23 @@ impl VisibilityState { .map(|r| selection_overlaps(selection, r)) .unwrap_or(false); - tracing::debug!( - "[VISIBILITY] span {} char_range {:?} formatted_range {:?} cursor {} -> in_extended={} in_formatted={} visible={}", - span.syn_id, - span.char_range, - span.formatted_range, - cursor_offset, - in_extended, - in_formatted_region, - result - ); - result } SyntaxType::Block => { - // Show if cursor anywhere in same paragraph - cursor_in_same_paragraph(cursor_offset, &span.char_range, paragraphs) - || selection_overlaps(selection, &span.char_range) + // Show if cursor anywhere in same paragraph (with slop for edge cases) + // The slop handles typing at the end of a heading like "# |" + let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); + let in_paragraph = para_bounds + .as_ref() + .map(|p| { + // Extend paragraph bounds by 1 char on each side for slop + let ext_start = p.start.saturating_sub(1); + let ext_end = p.end.saturating_add(1); + cursor_offset >= ext_start && cursor_offset <= ext_end + }) + .unwrap_or(false); + + in_paragraph || selection_overlaps(selection, &span.char_range) } }; @@ -97,6 +97,14 @@ impl VisibilityState { } } + tracing::debug!( + target: "weaver::visibility", + cursor_offset, + total_spans = syntax_spans.len(), + visible_count = visible.len(), + "calculated visibility" + ); + Self { visible_span_ids: visible, } diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index 32033f2..5056101 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -351,12 +351,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let format_end = self.last_char_offset; let formatted_range = format_start..format_end; - tracing::debug!( - "[FINALIZE_PAIRED] Setting formatted_range {:?} for opening '{}' and closing (last span)", - formatted_range, - opening_syn_id - ); - // Update the opening span's formatted_range if let Some(opening_span) = self .syntax_spans @@ -364,7 +358,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon .find(|s| s.syn_id == opening_syn_id) { opening_span.formatted_range = Some(formatted_range.clone()); - tracing::debug!("[FINALIZE_PAIRED] Updated opening span {}", opening_syn_id); } else { tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id); } @@ -375,7 +368,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Only update if it's an inline span (closing syntax should be inline) if closing_span.syntax_type == SyntaxType::Inline { closing_span.formatted_range = Some(formatted_range); - tracing::debug!("[FINALIZE_PAIRED] Updated closing span {}", closing_span.syn_id); } } } @@ -390,6 +382,14 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon let syntax_char_len = syntax.chars().count(); let char_end = char_start + syntax_char_len; + tracing::trace!( + target: "weaver::writer", + byte_range = ?range, + char_range = ?(char_start..char_end), + syntax = %syntax.escape_debug(), + "emit_syntax" + ); + // In boundary_only mode, just update offsets without HTML if self.boundary_only { self.last_char_offset = char_end; @@ -438,13 +438,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon // Record offset mapping for this syntax self.record_mapping(range.clone(), char_start..char_end); - tracing::debug!( - "[EMIT_SYNTAX] Updating offsets: last_char {} -> {}, last_byte {} -> {}", - self.last_char_offset, - char_end, - self.last_byte_offset, - range.end - ); self.last_char_offset = char_end; self.last_byte_offset = range.end; // Mark bytes as processed @@ -616,28 +609,12 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon /// to the writer passed in the constructor. pub fn run(mut self) -> Result { while let Some((event, range)) = self.events.next() { - // Log events for debugging - // tracing::debug!("[WRITER] Event: {:?}, range: {:?}, last_byte: {}, last_char: {}", - // match &event { - // Event::Start(tag) => format!("Start({:?})", tag), - // Event::End(tag) => format!("End({:?})", tag), - // Event::Text(t) => format!("Text('{}')", t), - // Event::Code(t) => format!("Code('{}')", t), - // Event::Html(t) => format!("Html('{}')", t), - // Event::InlineHtml(t) => format!("InlineHtml('{}')", t), - // Event::FootnoteReference(t) => format!("FootnoteReference('{}')", t), - // Event::SoftBreak => "SoftBreak".to_string(), - // Event::HardBreak => "HardBreak".to_string(), - // Event::Rule => "Rule".to_string(), - // Event::TaskListMarker(b) => format!("TaskListMarker({})", b), - // Event::WeaverBlock(t) => format!("WeaverBlock('{}')", t), - // Event::InlineMath(t) => format!("InlineMath('{}')", t), - // Event::DisplayMath(t) => format!("DisplayMath('{}')", t), - // }, - // &range, - // self.last_byte_offset, - // self.last_char_offset - // ); + tracing::trace!( + target: "weaver::writer", + event = ?event, + byte_range = ?range, + "processing event" + ); // For End events, emit any trailing content within the event's range // BEFORE calling end_tag (which calls end_node and clears current_node_id) @@ -654,7 +631,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon if matches!(&event, Event::End(_)) && !is_inline_format_end { // Emit gap from last_byte_offset to range.end - // (emit_syntax handles char offset tracking) self.emit_gap_before(range.end)?; } else if !matches!(&event, Event::End(_)) { // For other events, emit any gap before range.start @@ -1240,14 +1216,6 @@ impl<'a, I: Iterator, Range)>, W: StrWrite, E: EmbedCon } // Update tracking - we've consumed this opening syntax - tracing::debug!( - "[START_TAG] Opening syntax '{}': last_char {} -> {}, last_byte {} -> {}", - syntax, - self.last_char_offset, - char_end, - self.last_byte_offset, - range.start + syntax_byte_len - ); self.last_char_offset = char_end; self.last_byte_offset = range.start + syntax_byte_len; } diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index 5c385fb..5bcd721 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -16,7 +16,6 @@ const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); /// routes will be rendered under the outlet inside this component #[component] pub fn Navbar() -> Element { - tracing::debug!("Navbar component rendering"); let route = use_route::(); tracing::debug!("Route: {:?}", route); @@ -34,8 +33,6 @@ pub fn Navbar() -> Element { let fetcher = use_context::(); let mut show_login_modal = use_signal(|| false); - tracing::debug!("Navbar got route_handle: {:?}", route_handle.read()); - rsx! { document::Link { rel: "stylesheet", href: NAVBAR_CSS } document::Link { rel: "stylesheet", href: asset!("/assets/styling/button.css") }