···1//! Editor actions and keybinding system.
2//!
3//! This module re-exports core types and provides Dioxus-specific conversions
4-//! and the concrete execute_action implementation for EditorDocument.
56use dioxus::prelude::*;
78-use super::document::EditorDocument;
9use super::platform::Platform;
1011// Re-export core types.
···133///
134/// This is the central dispatch point for all editor operations.
135/// Returns true if the action was handled and the document was modified.
136-pub fn execute_action(doc: &mut EditorDocument, action: &EditorAction) -> bool {
137 use super::formatting::{self, FormatAction};
138 use super::input::{
139 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty,
···573}
574575/// Find word boundary backward from cursor.
576-fn find_word_boundary_backward(doc: &EditorDocument, cursor: usize) -> usize {
577 use super::input::get_char_at;
578579 if cursor == 0 {
···603}
604605/// Find word boundary forward from cursor.
606-fn find_word_boundary_forward(doc: &EditorDocument, cursor: usize) -> usize {
607 use super::input::get_char_at;
608609 let len = doc.len_chars();
···639/// are handled by beforeinput. Navigation (arrows, etc.) is passed
640/// through to the browser.
641pub fn handle_keydown_with_bindings(
642- doc: &mut EditorDocument,
643 config: &KeybindingConfig,
644 combo: KeyCombo,
645 range: Range,
···1//! Editor actions and keybinding system.
2//!
3//! This module re-exports core types and provides Dioxus-specific conversions
4+//! and the concrete execute_action implementation for SignalEditorDocument.
56use dioxus::prelude::*;
78+use super::document::SignalEditorDocument;
9use super::platform::Platform;
1011// Re-export core types.
···133///
134/// This is the central dispatch point for all editor operations.
135/// Returns true if the action was handled and the document was modified.
136+pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool {
137 use super::formatting::{self, FormatAction};
138 use super::input::{
139 detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty,
···573}
574575/// Find word boundary backward from cursor.
576+fn find_word_boundary_backward(doc: &SignalEditorDocument, cursor: usize) -> usize {
577 use super::input::get_char_at;
578579 if cursor == 0 {
···603}
604605/// Find word boundary forward from cursor.
606+fn find_word_boundary_forward(doc: &SignalEditorDocument, cursor: usize) -> usize {
607 use super::input::get_char_at;
608609 let len = doc.len_chars();
···639/// are handled by beforeinput. Navigation (arrows, etc.) is passed
640/// through to the browser.
641pub fn handle_keydown_with_bindings(
642+ doc: &mut SignalEditorDocument,
643 config: &KeybindingConfig,
644 combo: KeyCombo,
645 range: Range,
···17use dioxus::prelude::*;
1819use super::actions::{EditorAction, execute_action};
20-use super::document::EditorDocument;
21use super::platform::Platform;
2223// Re-export types from extracted crates.
···33/// Returns whether the event was handled and default should be prevented.
34#[allow(dead_code)]
35pub fn handle_beforeinput(
36- doc: &mut EditorDocument,
37 ctx: BeforeInputContext<'_>,
38) -> BeforeInputResult {
39 // During composition, let the browser handle most things.
···316}
317318/// Get the current range based on cursor and selection state.
319-fn get_current_range(doc: &EditorDocument) -> Range {
320 if let Some(sel) = *doc.selection.read() {
321 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
322 Range::new(start, end)
···17use dioxus::prelude::*;
1819use super::actions::{EditorAction, execute_action};
20+use super::document::SignalEditorDocument;
21use super::platform::Platform;
2223// Re-export types from extracted crates.
···33/// Returns whether the event was handled and default should be prevented.
34#[allow(dead_code)]
35pub fn handle_beforeinput(
36+ doc: &mut SignalEditorDocument,
37 ctx: BeforeInputContext<'_>,
38) -> BeforeInputResult {
39 // During composition, let the browser handle most things.
···316}
317318/// Get the current range based on cursor and selection state.
319+fn get_current_range(doc: &SignalEditorDocument) -> Range {
320 if let Some(sel) = *doc.selection.read() {
321 let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
322 Range::new(start, end)
+47-24
crates/weaver-app/src/components/editor/collab.rs
···1112// Only compile for WASM - no-op stub provided at end
1314-use super::document::EditorDocument;
1516use dioxus::prelude::*;
1718#[cfg(target_arch = "wasm32")]
19-use jacquard::smol_str::{format_smolstr, SmolStr};
20#[cfg(target_arch = "wasm32")]
21use jacquard::types::string::AtUri;
22···38#[derive(Props, Clone, PartialEq)]
39pub struct CollabCoordinatorProps {
40 /// The editor document to sync
41- pub document: EditorDocument,
42 /// Resource URI for the document being edited
43 pub resource_uri: String,
44 /// Presence state signal (updated by coordinator)
···239 let strong_ref = match fetcher.confirm_record_ref(&uri).await {
240 Ok(r) => r,
241 Err(e) => {
242- let err = format_smolstr!("Failed to get resource ref: {e}");
0243 debug_state
244 .with_mut(|ds| ds.last_error = Some(err.clone()));
245 state.set(CoordinatorState::Error(err));
···336 })
337 .await
338 {
339- tracing::error!("CollabCoordinator: AddPeers send failed: {e}");
00340 }
341 } else {
342 tracing::error!("CollabCoordinator: sink is None!");
···395 let fetcher = fetcher.clone();
396397 // Get our profile info and send BroadcastJoin
398- let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await {
000399 Some(did) => {
400- let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await {
401- Ok(profile) => {
402- match &profile.inner {
403- ProfileDataViewInner::ProfileView(p) => {
404- p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into())
405- }
406- ProfileDataViewInner::ProfileViewDetailed(p) => {
407- p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into())
408- }
0000409 ProfileDataViewInner::TangledProfileView(p) => {
410 p.handle.as_ref().into()
411 }
412 _ => did.as_ref().into(),
413- }
414- }
415- Err(_) => did.as_ref().into(),
416- };
417 (did.as_ref().into(), display_name)
418 }
419 None => {
420- tracing::warn!("CollabCoordinator: no current DID for Join message");
00421 ("unknown".into(), "Anonymous".into())
422 }
423 };
···430 })
431 .await
432 {
433- tracing::error!("CollabCoordinator: BroadcastJoin send failed: {e}");
00434 }
435 }
436 }
···460 let position = cursor.offset;
461 let sel = selection.map(|s| (s.anchor, s.head));
462463- tracing::debug!(position, ?sel, "CollabCoordinator: cursor changed, broadcasting");
0000464465 spawn(async move {
466 if let Some(ref mut s) = *worker_sink.write() {
467- tracing::debug!(position, "CollabCoordinator: sending BroadcastCursor to worker");
000468 if let Err(e) = s
469 .send(WorkerInput::BroadcastCursor {
470 position,
···475 tracing::warn!("Failed to send BroadcastCursor to worker: {e}");
476 }
477 } else {
478- tracing::debug!(position, "CollabCoordinator: worker sink not ready, skipping cursor broadcast");
000479 }
480 });
481 });
···1112// Only compile for WASM - no-op stub provided at end
1314+use super::document::SignalEditorDocument;
1516use dioxus::prelude::*;
1718#[cfg(target_arch = "wasm32")]
19+use jacquard::smol_str::{SmolStr, format_smolstr};
20#[cfg(target_arch = "wasm32")]
21use jacquard::types::string::AtUri;
22···38#[derive(Props, Clone, PartialEq)]
39pub struct CollabCoordinatorProps {
40 /// The editor document to sync
41+ pub document: SignalEditorDocument,
42 /// Resource URI for the document being edited
43 pub resource_uri: String,
44 /// Presence state signal (updated by coordinator)
···239 let strong_ref = match fetcher.confirm_record_ref(&uri).await {
240 Ok(r) => r,
241 Err(e) => {
242+ let err =
243+ format_smolstr!("Failed to get resource ref: {e}");
244 debug_state
245 .with_mut(|ds| ds.last_error = Some(err.clone()));
246 state.set(CoordinatorState::Error(err));
···337 })
338 .await
339 {
340+ tracing::error!(
341+ "CollabCoordinator: AddPeers send failed: {e}"
342+ );
343 }
344 } else {
345 tracing::error!("CollabCoordinator: sink is None!");
···398 let fetcher = fetcher.clone();
399400 // Get our profile info and send BroadcastJoin
401+ let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher
402+ .current_did()
403+ .await
404+ {
405 Some(did) => {
406+ let display_name: SmolStr =
407+ match fetcher.fetch_profile(&did.clone().into()).await {
408+ Ok(profile) => match &profile.inner {
409+ ProfileDataViewInner::ProfileView(p) => p
410+ .display_name
411+ .as_ref()
412+ .map(|s| s.as_ref().into())
413+ .unwrap_or_else(|| did.as_ref().into()),
414+ ProfileDataViewInner::ProfileViewDetailed(p) => p
415+ .display_name
416+ .as_ref()
417+ .map(|s| s.as_ref().into())
418+ .unwrap_or_else(|| did.as_ref().into()),
419 ProfileDataViewInner::TangledProfileView(p) => {
420 p.handle.as_ref().into()
421 }
422 _ => did.as_ref().into(),
423+ },
424+ Err(_) => did.as_ref().into(),
425+ };
0426 (did.as_ref().into(), display_name)
427 }
428 None => {
429+ tracing::warn!(
430+ "CollabCoordinator: no current DID for Join message"
431+ );
432 ("unknown".into(), "Anonymous".into())
433 }
434 };
···441 })
442 .await
443 {
444+ tracing::error!(
445+ "CollabCoordinator: BroadcastJoin send failed: {e}"
446+ );
447 }
448 }
449 }
···473 let position = cursor.offset;
474 let sel = selection.map(|s| (s.anchor, s.head));
475476+ tracing::debug!(
477+ position,
478+ ?sel,
479+ "CollabCoordinator: cursor changed, broadcasting"
480+ );
481482 spawn(async move {
483 if let Some(ref mut s) = *worker_sink.write() {
484+ tracing::debug!(
485+ position,
486+ "CollabCoordinator: sending BroadcastCursor to worker"
487+ );
488 if let Err(e) = s
489 .send(WorkerInput::BroadcastCursor {
490 position,
···495 tracing::warn!("Failed to send BroadcastCursor to worker: {e}");
496 }
497 } else {
498+ tracing::debug!(
499+ position,
500+ "CollabCoordinator: worker sink not ready, skipping cursor broadcast"
501+ );
502 }
503 });
504 });
···7//!
8//! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity:
9//! - Cursor/selection changes don't trigger content re-renders
10-//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation
11//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
1213-use std::borrow::Cow;
14-use std::cell::RefCell;
15-use std::rc::Rc;
16-17use dioxus::prelude::*;
18use loro::{
19- ExportMode, Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson,
20- UndoManager, VersionVector,
21- cursor::{Cursor, Side},
22};
2324use jacquard::IntoStatic;
···28use weaver_api::com_atproto::repo::strong_ref::StrongRef;
29use weaver_api::sh_weaver::embed::images::Image;
30use weaver_api::sh_weaver::embed::records::RecordEmbed;
31-pub use weaver_editor_core::{
32- Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
33-};
34use weaver_api::sh_weaver::notebook::entry::Entry;
000003536/// Helper for working with editor images.
37/// Constructed from LoroMap data, NOT serialized directly.
···4748/// Single source of truth for editor state.
49///
50-/// Contains the document text (backed by Loro CRDT), cursor position,
51/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
52/// schema with CRDT containers for each field.
53///
···56/// The document itself is NOT wrapped in a Signal. Instead, individual fields
57/// that need reactivity are wrapped in Signals:
58/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
59-/// - `last_edit` - triggers paragraph re-renders when content changes
60///
61-/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`.
62///
63/// # Cloning
64///
65-/// EditorDocument is cheap to clone - Loro types are Arc-backed handles,
66/// and Signals are Copy. Closures can capture clones without overhead.
67#[derive(Clone)]
68-pub struct EditorDocument {
69- /// The Loro document containing all editor state.
70- doc: LoroDoc,
07172 // --- Entry schema containers (Loro handles interior mutability) ---
73- /// Markdown content (maps to entry.content)
74- content: LoroText,
75-76 /// Entry title (maps to entry.title)
77 title: LoroText,
78···118 /// Maps root URI -> last diff URI we've imported from that root.
119 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this.
120 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>,
121-122- // --- Editor state (non-reactive) ---
123- /// Undo manager for the document.
124- undo_mgr: Rc<RefCell<UndoManager>>,
125-126- /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
127- /// Recreated after our own edits, queried after undo/redo/remote edits.
128- loro_cursor: Option<Cursor>,
129130 // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
131 /// Current cursor position. Signal so cursor changes don't dirty content memos.
···141 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
142 pub composition_ended_at: Signal<Option<web_time::Instant>>,
143144- /// Most recent edit info for incremental rendering optimization.
145- /// Signal so paragraphs memo can subscribe to content changes only.
146- pub last_edit: Signal<Option<EditInfo>>,
147148 /// Pending snap direction for cursor restoration after edits.
149 /// Set by input handlers, consumed by cursor restoration.
···154 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
155}
156157-// CursorState, Affinity, Selection, CompositionState, EditInfo, and BLOCK_SYNTAX_ZONE
158-// are imported from weaver_editor_core.
159-160/// Pre-loaded document state that can be created outside of reactive context.
161///
162/// This struct holds the raw LoroDoc (which is safe outside reactive context)
163-/// along with sync state metadata. Use `EditorDocument::from_loaded_state()`
164-/// inside a `use_hook` to convert this into a reactive EditorDocument.
165///
166/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed).
167/// PartialEq always returns false since we can't meaningfully compare docs.
···197 }
198}
199200-impl EditorDocument {
201- /// Check if a character position is within the block-syntax zone of its line.
202- fn is_in_block_syntax_zone(&self, pos: usize) -> bool {
203- if pos <= BLOCK_SYNTAX_ZONE {
204- return true;
205- }
206-207- // Search backwards from pos-1, only need to check BLOCK_SYNTAX_ZONE + 1 chars.
208- let search_start = pos.saturating_sub(BLOCK_SYNTAX_ZONE + 1);
209- for i in (search_start..pos).rev() {
210- if self.content.char_at(i).ok() == Some('\n') {
211- // Found newline at i, distance from line start is pos - i - 1.
212- return (pos - i - 1) <= BLOCK_SYNTAX_ZONE;
213- }
214- }
215- // No newline found in search range, and pos > BLOCK_SYNTAX_ZONE, so not in zone.
216- false
217- }
218-219 /// Create a new editor document with the given content.
220 /// Sets `created_at` to current time.
221 ///
222 /// # Note
223 /// This creates Dioxus Signals for reactive fields. Call from within
224- /// a component using `use_hook(|| EditorDocument::new(...))`.
225 pub fn new(initial_content: String) -> Self {
226- let doc = LoroDoc::new();
00227228- // Get all containers
229- let content = doc.get_text("content");
230 let title = doc.get_text("title");
231 let path = doc.get_text("path");
232 let created_at = doc.get_text("created_at");
···235236 // Insert initial content if any
237 if !initial_content.is_empty() {
238- content
239- .insert(0, &initial_content)
240- .expect("failed to insert initial content");
241 }
242243 // Set created_at to current time (ISO 8601)
···246 .insert(0, &now)
247 .expect("failed to set created_at");
248249- // Set up undo manager with merge interval for batching keystrokes
250- let mut undo_mgr = UndoManager::new(&doc);
251- undo_mgr.set_merge_interval(300); // 300ms merge window
252- undo_mgr.set_max_undo_steps(100);
253-254- // Create initial Loro cursor at position 0
255- let loro_cursor = content.get_cursor(0, Side::default());
256-257 Self {
258- doc,
259- content,
260 title,
261 path,
262 created_at,
···268 last_diff: Signal::new(None),
269 last_synced_version: Signal::new(None),
270 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
271- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
272- loro_cursor,
273- // Reactive editor state - wrapped in Signals
274 cursor: Signal::new(CursorState {
275 offset: 0,
276 affinity: Affinity::Before,
···278 selection: Signal::new(None),
279 composition: Signal::new(None),
280 composition_ended_at: Signal::new(None),
281- last_edit: Signal::new(None),
282 pending_snap: Signal::new(None),
283 collected_refs: Signal::new(Vec::new()),
284 }
285 }
286287- /// Create an EditorDocument from a fetched Entry.
288 ///
289 /// MUST be called from within a reactive context (e.g., `use_hook`) to
290 /// properly initialize Dioxus Signals.
···341342 /// Get the underlying LoroText for read operations on content.
343 pub fn loro_text(&self) -> &LoroText {
344- &self.content
345 }
346347 /// Get the underlying LoroDoc for subscriptions and advanced operations.
348 pub fn loro_doc(&self) -> &LoroDoc {
349- &self.doc
0000000000350 }
351352 // --- Content accessors ---
353354 /// Get the markdown content as a string.
355 pub fn content(&self) -> String {
356- self.content.to_string()
357 }
358359 /// Convert the document content to a string (alias for content()).
360 pub fn to_string(&self) -> String {
361- self.content.to_string()
362 }
363364 /// Get the length of the content in characters.
365 pub fn len_chars(&self) -> usize {
366- self.content.len_unicode()
367 }
368369 /// Get the length of the content in UTF-8 bytes.
370 pub fn len_bytes(&self) -> usize {
371- self.content.len_utf8()
372 }
373374 /// Get the length of the content in UTF-16 code units.
375 pub fn len_utf16(&self) -> usize {
376- self.content.len_utf16()
377 }
378379 /// Check if the content is empty.
380 pub fn is_empty(&self) -> bool {
381- self.content.len_unicode() == 0
382 }
383384 // --- Entry metadata accessors ---
···652 .map(|r| r.into_static())
653 }
654655- /// Insert text into content and record edit info for incremental rendering.
0656 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
657- let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
658- let len_before = self.content.len_unicode();
659- let result = self.content.insert(pos, text);
660- let len_after = self.content.len_unicode();
661- self.last_edit.set(Some(EditInfo {
662- edit_char_pos: pos,
663- inserted_len: len_after.saturating_sub(len_before),
664- deleted_len: 0,
665- contains_newline: text.contains('\n'),
666- in_block_syntax_zone,
667- doc_len_after: len_after,
668- timestamp: web_time::Instant::now(),
669- }));
670- result
671 }
672673 /// Push text to end of content. Faster than insert for appending.
674 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
675- let pos = self.content.len_unicode();
676- let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
677- let result = self.content.push_str(text);
678- let len_after = self.content.len_unicode();
679- self.last_edit.set(Some(EditInfo {
680- edit_char_pos: pos,
681- inserted_len: text.chars().count(),
682- deleted_len: 0,
683- contains_newline: text.contains('\n'),
684- in_block_syntax_zone,
685- doc_len_after: len_after,
686- timestamp: web_time::Instant::now(),
687- }));
688- result
689 }
690691- /// Remove text range from content and record edit info for incremental rendering.
0692 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
693- let contains_newline = self
694- .content
695- .slice(start, start + len)
696- .map(|s| s.contains('\n'))
697- .unwrap_or(false);
698- let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
699-700- let result = self.content.delete(start, len);
701- self.last_edit.set(Some(EditInfo {
702- edit_char_pos: start,
703- inserted_len: 0,
704- deleted_len: len,
705- contains_newline,
706- in_block_syntax_zone,
707- doc_len_after: self.content.len_unicode(),
708- timestamp: web_time::Instant::now(),
709- }));
710- result
711 }
712713- /// Replace text in content (delete then insert) and record combined edit info.
0714 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
715- let delete_has_newline = self
716- .content
717- .slice(start, start + len)
718- .map(|s| s.contains('\n'))
719- .unwrap_or(false);
720- let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
721-722- let len_before = self.content.len_unicode();
723- // Use splice for atomic replace
724- self.content.splice(start, len, text)?;
725- let len_after = self.content.len_unicode();
726-727- // inserted_len = (len_after - len_before) + deleted_len
728- // because: len_after = len_before - deleted + inserted
729- let inserted_len = (len_after + len).saturating_sub(len_before);
730-731- self.last_edit.set(Some(EditInfo {
732- edit_char_pos: start,
733- inserted_len,
734- deleted_len: len,
735- contains_newline: delete_has_newline || text.contains('\n'),
736- in_block_syntax_zone,
737- doc_len_after: len_after,
738- timestamp: web_time::Instant::now(),
739- }));
740 Ok(())
741 }
742···746 // so it tracks through the undo operation
747 self.sync_loro_cursor();
748749- let result = self.undo_mgr.borrow_mut().undo()?;
750 if result {
751 // After undo, query Loro cursor for new position
752 self.sync_cursor_from_loro();
753 // Signal content change for re-render
754- self.last_edit.set(None);
755 }
756 Ok(result)
757 }
···761 // Sync Loro cursor to current position BEFORE redo
762 self.sync_loro_cursor();
763764- let result = self.undo_mgr.borrow_mut().redo()?;
765 if result {
766 // After redo, query Loro cursor for new position
767 self.sync_cursor_from_loro();
768 // Signal content change for re-render
769- self.last_edit.set(None);
770 }
771 Ok(result)
772 }
773774 /// Check if undo is available.
775 pub fn can_undo(&self) -> bool {
776- self.undo_mgr.borrow().can_undo()
777 }
778779 /// Check if redo is available.
780 pub fn can_redo(&self) -> bool {
781- self.undo_mgr.borrow().can_redo()
782 }
783784 /// Get a slice of the content text.
785 /// Returns None if the range is invalid.
786- pub fn slice(&self, start: usize, end: usize) -> Option<String> {
787- self.content.slice(start, end).ok()
788 }
789790 /// Sync the Loro cursor to the current cursor.offset position.
···792 pub fn sync_loro_cursor(&mut self) {
793 let offset = self.cursor.read().offset;
794 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro");
795- self.loro_cursor = self.content.get_cursor(offset, Side::default());
796 }
797798 /// Update cursor.offset from the Loro cursor's tracked position.
799 /// Call this after undo/redo or remote edits where the position may have shifted.
800 /// Returns the new offset, or None if the cursor couldn't be resolved.
801 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
802- let loro_cursor = self.loro_cursor.as_ref()?;
803- let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
804 let old_offset = self.cursor.read().offset;
805- let new_offset = result.current.pos.min(self.len_chars());
806- let jump = if new_offset > old_offset { new_offset - old_offset } else { old_offset - new_offset };
0000807 if jump > 100 {
808 tracing::warn!(
809 old_offset,
···812 "sync_cursor_from_loro: LARGE CURSOR JUMP detected"
813 );
814 }
815- tracing::debug!(old_offset, new_offset, "sync_cursor_from_loro: updating cursor from Loro");
0000816 self.cursor.with_mut(|c| c.offset = new_offset);
817 Some(new_offset)
818 }
819820 /// Get the Loro cursor for serialization.
821- pub fn loro_cursor(&self) -> Option<&Cursor> {
822- self.loro_cursor.as_ref()
823 }
824825 /// Set the Loro cursor (used when restoring from storage).
826 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) {
827 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called");
828- self.loro_cursor = cursor;
829 // Sync cursor.offset from the restored Loro cursor
830- if self.loro_cursor.is_some() {
831 self.sync_cursor_from_loro();
832 }
833 }
···835 /// Export the document as a binary snapshot.
836 /// This captures all CRDT state including undo history.
837 pub fn export_snapshot(&self) -> Vec<u8> {
838- self.doc.export(ExportMode::Snapshot).unwrap_or_default()
839 }
840841 /// Get the current state frontiers for change detection.
842 /// Frontiers represent the "version" of the document state.
843 pub fn state_frontiers(&self) -> Frontiers {
844- self.doc.state_frontiers()
845 }
846847 /// Get the current version vector.
848 pub fn version_vector(&self) -> VersionVector {
849- self.doc.oplog_vv()
850 }
851852 /// Get the last edit info for incremental rendering.
853- /// Reading this creates a reactive dependency on content changes.
854 pub fn last_edit(&self) -> Option<EditInfo> {
855- self.last_edit.read().clone()
000000856 }
857858 // --- Collected refs accessors ---
···916 /// Check if there are unsynced changes since the last PDS sync.
917 pub fn has_unsynced_changes(&self) -> bool {
918 match &*self.last_synced_version.read() {
919- Some(synced_vv) => self.doc.oplog_vv() != *synced_vv,
920 None => true, // Never synced, so there are changes
921 }
922 }
···926 /// After successful upload, call `mark_synced()` to update the sync marker.
927 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
928 let from_vv = self.last_synced_version.read().clone().unwrap_or_default();
929- let current_vv = self.doc.oplog_vv();
930-931- // No changes since last sync
932- if from_vv == current_vv {
933- return None;
934- }
935-936- let updates = self
937- .doc
938- .export(ExportMode::Updates {
939- from: Cow::Owned(from_vv),
940- })
941- .ok()?;
942-943- // Don't return empty updates
944- if updates.is_empty() {
945- return None;
946- }
947-948- Some(updates)
949 }
950951 /// Mark the current state as synced.
952 /// Call this after successfully uploading a diff to the PDS.
953 pub fn mark_synced(&mut self) {
954- self.last_synced_version.set(Some(self.doc.oplog_vv()));
955 }
956957 /// Import updates from a PDS diff blob.
958 /// Used when loading edit history from the PDS.
959 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> {
960- let len_before = self.content.len_unicode();
961- let vv_before = self.doc.oplog_vv();
962963- self.doc.import(updates)?;
00964965- let len_after = self.content.len_unicode();
966- let vv_after = self.doc.oplog_vv();
967 let vv_changed = vv_before != vv_after;
968 let len_changed = len_before != len_after;
969···977978 // Only trigger re-render if something actually changed
979 if vv_changed {
980- self.last_edit.set(None);
981 }
982 Ok(())
983 }
···985 /// Export updates since the given version vector.
986 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync.
987 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> {
988- let current_vv = self.doc.oplog_vv();
989-990- // No changes since the given version
991- if *from_vv == current_vv {
992- return None;
993- }
994-995- let updates = self
996- .doc
997- .export(ExportMode::Updates {
998- from: Cow::Borrowed(from_vv),
999- })
1000- .ok()?;
1001-1002- if updates.is_empty() {
1003- return None;
1004- }
1005-1006- Some(updates)
1007 }
10081009 /// Set the sync state when loading from PDS.
···1016 ) {
1017 self.edit_root.set(Some(edit_root));
1018 self.last_diff.set(last_diff);
1019- self.last_synced_version.set(Some(self.doc.oplog_vv()));
1020 }
10211022- /// Create a new EditorDocument from a binary snapshot.
1023 /// Falls back to empty document if import fails.
1024 ///
1025 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
···1037 loro_cursor: Option<Cursor>,
1038 fallback_offset: usize,
1039 ) -> Self {
1040- let doc = LoroDoc::new();
1041-1042- if !snapshot.is_empty() {
1043- if let Err(e) = doc.import(snapshot) {
1044- tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
000001045 }
1046- }
10471048- // Get all containers (they will contain data from the snapshot if import succeeded)
1049- let content = doc.get_text("content");
01050 let title = doc.get_text("title");
1051 let path = doc.get_text("path");
1052 let created_at = doc.get_text("created_at");
1053 let tags = doc.get_list("tags");
1054 let embeds = doc.get_map("embeds");
10551056- // Set up undo manager - tracks operations from this point forward only
1057- let mut undo_mgr = UndoManager::new(&doc);
1058- undo_mgr.set_merge_interval(300);
1059- undo_mgr.set_max_undo_steps(100);
1060-1061 // Try to restore cursor from Loro cursor, fall back to offset
1062- let max_offset = content.len_unicode();
1063 let cursor_offset = if let Some(ref lc) = loro_cursor {
1064 doc.get_cursor_pos(lc)
1065 .map(|r| r.current.pos)
···1073 affinity: Affinity::Before,
1074 };
10751076- // If no Loro cursor provided, create one at the restored position
1077- let loro_cursor =
1078- loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default()));
000010791080 Self {
1081- doc,
1082- content,
1083 title,
1084 path,
1085 created_at,
···1091 last_diff: Signal::new(None),
1092 last_synced_version: Signal::new(None),
1093 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
1094- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
1095- loro_cursor,
1096- // Reactive editor state - wrapped in Signals
1097 cursor: Signal::new(cursor_state),
1098 selection: Signal::new(None),
1099 composition: Signal::new(None),
1100 composition_ended_at: Signal::new(None),
1101- last_edit: Signal::new(None),
1102 pending_snap: Signal::new(None),
1103 collected_refs: Signal::new(Vec::new()),
1104 }
1105 }
11061107- /// Create an EditorDocument from pre-loaded state.
1108 ///
1109 /// Use this when loading from PDS/localStorage merge outside reactive context.
1110 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it
···1113 /// # Note
1114 /// This creates Dioxus Signals. Call from within a component using `use_hook`.
1115 pub fn from_loaded_state(state: LoadedDocState) -> Self {
1116- let doc = state.doc;
0011171118- // Get all containers from the loaded doc
1119- let content = doc.get_text("content");
1120 let title = doc.get_text("title");
1121 let path = doc.get_text("path");
1122 let created_at = doc.get_text("created_at");
1123 let tags = doc.get_list("tags");
1124 let embeds = doc.get_map("embeds");
11251126- // Set up undo manager
1127- let mut undo_mgr = UndoManager::new(&doc);
1128- undo_mgr.set_merge_interval(300);
1129- undo_mgr.set_max_undo_steps(100);
1130-1131 // Position cursor at end of content
1132- let cursor_offset = content.len_unicode();
1133 let cursor_state = CursorState {
1134 offset: cursor_offset,
1135 affinity: Affinity::Before,
1136 };
1137- let loro_cursor = content.get_cursor(cursor_offset, Side::default());
00011381139 Self {
1140- doc,
1141- content,
1142 title,
1143 path,
1144 created_at,
···1151 // Use the synced version from state (tracks the PDS version vector)
1152 last_synced_version: Signal::new(state.synced_version),
1153 last_seen_diffs: Signal::new(state.last_seen_diffs),
1154- undo_mgr: Rc::new(RefCell::new(undo_mgr)),
1155- loro_cursor,
1156 cursor: Signal::new(cursor_state),
1157 selection: Signal::new(None),
1158 composition: Signal::new(None),
1159 composition_ended_at: Signal::new(None),
1160- last_edit: Signal::new(None),
1161 pending_snap: Signal::new(None),
1162 collected_refs: Signal::new(Vec::new()),
1163 }
1164 }
1165}
11661167-impl PartialEq for EditorDocument {
1168 fn eq(&self, _other: &Self) -> bool {
1169- // EditorDocument uses interior mutability, so we can't meaningfully compare.
1170 // Return false to ensure components re-render when passed as props.
1171 false
1172 }
1173}
11741175-impl weaver_editor_crdt::CrdtDocument for EditorDocument {
1176 fn export_snapshot(&self) -> Vec<u8> {
1177 self.export_snapshot()
1178 }
···1191 }
11921193 fn edit_root(&self) -> Option<StrongRef<'static>> {
1194- self.edit_root()
1195 }
11961197 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
1198- self.set_edit_root(root);
1199 }
12001201 fn last_diff(&self) -> Option<StrongRef<'static>> {
1202- self.last_diff()
1203 }
12041205 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
1206- self.set_last_diff(diff);
1207 }
12081209 fn mark_synced(&mut self) {
1210- self.mark_synced();
1211 }
12121213 fn has_unsynced_changes(&self) -> bool {
1214- self.has_unsynced_changes()
00000000000000000000000000000000000000000000001215 }
1216}
···7//!
8//! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity:
9//! - Cursor/selection changes don't trigger content re-renders
10+//! - Content changes bump `content_changed` Signal to trigger paragraph re-renders
11//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
12000013use dioxus::prelude::*;
14use loro::{
15+ Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector,
16+ cursor::Cursor,
017};
1819use jacquard::IntoStatic;
···23use weaver_api::com_atproto::repo::strong_ref::StrongRef;
24use weaver_api::sh_weaver::embed::images::Image;
25use weaver_api::sh_weaver::embed::records::RecordEmbed;
00026use weaver_api::sh_weaver::notebook::entry::Entry;
27+use weaver_editor_core::EditorDocument;
28+use weaver_editor_core::TextBuffer;
29+use weaver_editor_core::UndoManager;
30+pub use weaver_editor_core::{Affinity, CompositionState, CursorState, EditInfo, Selection};
31+use weaver_editor_crdt::LoroTextBuffer;
3233/// Helper for working with editor images.
34/// Constructed from LoroMap data, NOT serialized directly.
···4445/// Single source of truth for editor state.
46///
47+/// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position,
48/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
49/// schema with CRDT containers for each field.
50///
···53/// The document itself is NOT wrapped in a Signal. Instead, individual fields
54/// that need reactivity are wrapped in Signals:
55/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
56+/// - `content_changed` - bumped to trigger paragraph re-renders when content changes
57///
58+/// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`.
59///
60/// # Cloning
61///
62+/// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed,
63/// and Signals are Copy. Closures can capture clones without overhead.
64#[derive(Clone)]
65+pub struct SignalEditorDocument {
66+ /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking.
67+ /// Access the underlying LoroDoc via `buffer.doc()`.
68+ buffer: LoroTextBuffer,
6970 // --- Entry schema containers (Loro handles interior mutability) ---
71+ // These are obtained from buffer.doc() but cached for convenience.
0072 /// Entry title (maps to entry.title)
73 title: LoroText,
74···114 /// Maps root URI -> last diff URI we've imported from that root.
115 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this.
116 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>,
00000000117118 // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
119 /// Current cursor position. Signal so cursor changes don't dirty content memos.
···129 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
130 pub composition_ended_at: Signal<Option<web_time::Instant>>,
131132+ /// Bumped when content changes to trigger paragraph re-renders.
133+ /// Actual EditInfo is obtained from `buffer.last_edit()`.
134+ pub content_changed: Signal<()>,
135136 /// Pending snap direction for cursor restoration after edits.
137 /// Set by input handlers, consumed by cursor restoration.
···142 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
143}
144000145/// Pre-loaded document state that can be created outside of reactive context.
146///
147/// This struct holds the raw LoroDoc (which is safe outside reactive context)
148+/// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()`
149+/// inside a `use_hook` to convert this into a reactive SignalEditorDocument.
150///
151/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed).
152/// PartialEq always returns false since we can't meaningfully compare docs.
···182 }
183}
184185+impl SignalEditorDocument {
000000000000000000186 /// Create a new editor document with the given content.
187 /// Sets `created_at` to current time.
188 ///
189 /// # Note
190 /// This creates Dioxus Signals for reactive fields. Call from within
191+ /// a component using `use_hook(|| SignalEditorDocument::new(...))`.
192 pub fn new(initial_content: String) -> Self {
193+ // Create the LoroTextBuffer which owns the LoroDoc
194+ let mut buffer = LoroTextBuffer::new();
195+ let doc = buffer.doc().clone();
196197+ // Get other containers from the doc
0198 let title = doc.get_text("title");
199 let path = doc.get_text("path");
200 let created_at = doc.get_text("created_at");
···203204 // Insert initial content if any
205 if !initial_content.is_empty() {
206+ buffer.insert(0, &initial_content);
00207 }
208209 // Set created_at to current time (ISO 8601)
···212 .insert(0, &now)
213 .expect("failed to set created_at");
21400000000215 Self {
216+ buffer,
0217 title,
218 path,
219 created_at,
···225 last_diff: Signal::new(None),
226 last_synced_version: Signal::new(None),
227 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
000228 cursor: Signal::new(CursorState {
229 offset: 0,
230 affinity: Affinity::Before,
···232 selection: Signal::new(None),
233 composition: Signal::new(None),
234 composition_ended_at: Signal::new(None),
235+ content_changed: Signal::new(()),
236 pending_snap: Signal::new(None),
237 collected_refs: Signal::new(Vec::new()),
238 }
239 }
240241+ /// Create a SignalEditorDocument from a fetched Entry.
242 ///
243 /// MUST be called from within a reactive context (e.g., `use_hook`) to
244 /// properly initialize Dioxus Signals.
···295296 /// Get the underlying LoroText for read operations on content.
297 pub fn loro_text(&self) -> &LoroText {
298+ self.buffer.content()
299 }
300301 /// Get the underlying LoroDoc for subscriptions and advanced operations.
302 pub fn loro_doc(&self) -> &LoroDoc {
303+ self.buffer.doc()
304+ }
305+306+ /// Get direct access to the LoroTextBuffer.
307+ pub fn buffer(&self) -> &LoroTextBuffer {
308+ &self.buffer
309+ }
310+311+ /// Get mutable access to the LoroTextBuffer.
312+ pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer {
313+ &mut self.buffer
314 }
315316 // --- Content accessors ---
317318 /// Get the markdown content as a string.
319 pub fn content(&self) -> String {
320+ weaver_editor_core::TextBuffer::to_string(&self.buffer)
321 }
322323 /// Convert the document content to a string (alias for content()).
324 pub fn to_string(&self) -> String {
325+ weaver_editor_core::TextBuffer::to_string(&self.buffer)
326 }
327328 /// Get the length of the content in characters.
329 pub fn len_chars(&self) -> usize {
330+ weaver_editor_core::TextBuffer::len_chars(&self.buffer)
331 }
332333 /// Get the length of the content in UTF-8 bytes.
334 pub fn len_bytes(&self) -> usize {
335+ weaver_editor_core::TextBuffer::len_bytes(&self.buffer)
336 }
337338 /// Get the length of the content in UTF-16 code units.
339 pub fn len_utf16(&self) -> usize {
340+ self.buffer.content().len_utf16()
341 }
342343 /// Check if the content is empty.
344 pub fn is_empty(&self) -> bool {
345+ weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0
346 }
347348 // --- Entry metadata accessors ---
···616 .map(|r| r.into_static())
617 }
618619+ /// Insert text into content and bump content_changed for re-rendering.
620+ /// Edit info is tracked automatically by the buffer.
621 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
622+ self.buffer.insert(pos, text);
623+ self.content_changed.set(());
624+ Ok(())
00000000000625 }
626627 /// Push text to end of content. Faster than insert for appending.
628 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
629+ let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
630+ self.buffer.insert(pos, text);
631+ self.content_changed.set(());
632+ Ok(())
0000000000633 }
634635+ /// Remove text range from content and bump content_changed for re-rendering.
636+ /// Edit info is tracked automatically by the buffer.
637 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
638+ self.buffer.delete(start..start + len);
639+ self.content_changed.set(());
640+ Ok(())
000000000000000641 }
642643+ /// Replace text in content (atomic splice) and bump content_changed.
644+ /// Edit info is tracked automatically by the buffer.
645 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
646+ self.buffer.replace(start..start + len, text);
647+ self.content_changed.set(());
00000000000000000000000648 Ok(())
649 }
650···654 // so it tracks through the undo operation
655 self.sync_loro_cursor();
656657+ let result = self.buffer.undo();
658 if result {
659 // After undo, query Loro cursor for new position
660 self.sync_cursor_from_loro();
661 // Signal content change for re-render
662+ self.content_changed.set(());
663 }
664 Ok(result)
665 }
···669 // Sync Loro cursor to current position BEFORE redo
670 self.sync_loro_cursor();
671672+ let result = self.buffer.redo();
673 if result {
674 // After redo, query Loro cursor for new position
675 self.sync_cursor_from_loro();
676 // Signal content change for re-render
677+ self.content_changed.set(());
678 }
679 Ok(result)
680 }
681682 /// Check if undo is available.
683 pub fn can_undo(&self) -> bool {
684+ UndoManager::can_undo(&self.buffer)
685 }
686687 /// Check if redo is available.
688 pub fn can_redo(&self) -> bool {
689+ UndoManager::can_redo(&self.buffer)
690 }
691692 /// Get a slice of the content text.
693 /// Returns None if the range is invalid.
694+ pub fn slice(&self, start: usize, end: usize) -> Option<SmolStr> {
695+ self.buffer.slice(start..end)
696 }
697698 /// Sync the Loro cursor to the current cursor.offset position.
···700 pub fn sync_loro_cursor(&mut self) {
701 let offset = self.cursor.read().offset;
702 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro");
703+ self.buffer.sync_cursor(offset);
704 }
705706 /// Update cursor.offset from the Loro cursor's tracked position.
707 /// Call this after undo/redo or remote edits where the position may have shifted.
708 /// Returns the new offset, or None if the cursor couldn't be resolved.
709 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
00710 let old_offset = self.cursor.read().offset;
711+ let new_offset = self.buffer.resolve_cursor()?;
712+ let jump = if new_offset > old_offset {
713+ new_offset - old_offset
714+ } else {
715+ old_offset - new_offset
716+ };
717 if jump > 100 {
718 tracing::warn!(
719 old_offset,
···722 "sync_cursor_from_loro: LARGE CURSOR JUMP detected"
723 );
724 }
725+ tracing::debug!(
726+ old_offset,
727+ new_offset,
728+ "sync_cursor_from_loro: updating cursor from Loro"
729+ );
730 self.cursor.with_mut(|c| c.offset = new_offset);
731 Some(new_offset)
732 }
733734 /// Get the Loro cursor for serialization.
735+ pub fn loro_cursor(&self) -> Option<Cursor> {
736+ self.buffer.loro_cursor()
737 }
738739 /// Set the Loro cursor (used when restoring from storage).
740 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) {
741 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called");
742+ self.buffer.set_loro_cursor(cursor);
743 // Sync cursor.offset from the restored Loro cursor
744+ if self.buffer.loro_cursor().is_some() {
745 self.sync_cursor_from_loro();
746 }
747 }
···749 /// Export the document as a binary snapshot.
750 /// This captures all CRDT state including undo history.
751 pub fn export_snapshot(&self) -> Vec<u8> {
752+ self.buffer.export_snapshot()
753 }
754755 /// Get the current state frontiers for change detection.
756 /// Frontiers represent the "version" of the document state.
757 pub fn state_frontiers(&self) -> Frontiers {
758+ self.buffer.doc().state_frontiers()
759 }
760761 /// Get the current version vector.
762 pub fn version_vector(&self) -> VersionVector {
763+ self.buffer.version()
764 }
765766 /// Get the last edit info for incremental rendering.
767+ /// This comes from the buffer's internal tracking.
768 pub fn last_edit(&self) -> Option<EditInfo> {
769+ self.buffer.last_edit()
770+ }
771+772+ /// Bump the content_changed signal to trigger re-renders.
773+ /// Call this after remote imports or other external content changes.
774+ pub fn notify_content_changed(&mut self) {
775+ self.content_changed.set(());
776 }
777778 // --- Collected refs accessors ---
···836 /// Check if there are unsynced changes since the last PDS sync.
837 pub fn has_unsynced_changes(&self) -> bool {
838 match &*self.last_synced_version.read() {
839+ Some(synced_vv) => self.buffer.version() != *synced_vv,
840 None => true, // Never synced, so there are changes
841 }
842 }
···846 /// After successful upload, call `mark_synced()` to update the sync marker.
847 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
848 let from_vv = self.last_synced_version.read().clone().unwrap_or_default();
849+ self.buffer.export_updates_since(&from_vv)
0000000000000000000850 }
851852 /// Mark the current state as synced.
853 /// Call this after successfully uploading a diff to the PDS.
854 pub fn mark_synced(&mut self) {
855+ self.last_synced_version.set(Some(self.buffer.version()));
856 }
857858 /// Import updates from a PDS diff blob.
859 /// Used when loading edit history from the PDS.
860 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> {
861+ let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
862+ let vv_before = self.buffer.version();
863864+ self.buffer
865+ .import(updates)
866+ .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?;
867868+ let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
869+ let vv_after = self.buffer.version();
870 let vv_changed = vv_before != vv_after;
871 let len_changed = len_before != len_after;
872···880881 // Only trigger re-render if something actually changed
882 if vv_changed {
883+ self.content_changed.set(());
884 }
885 Ok(())
886 }
···888 /// Export updates since the given version vector.
889 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync.
890 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> {
891+ self.buffer.export_updates_since(from_vv)
000000000000000000892 }
893894 /// Set the sync state when loading from PDS.
···901 ) {
902 self.edit_root.set(Some(edit_root));
903 self.last_diff.set(last_diff);
904+ self.last_synced_version.set(Some(self.buffer.version()));
905 }
906907+ /// Create a new SignalEditorDocument from a binary snapshot.
908 /// Falls back to empty document if import fails.
909 ///
910 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
···922 loro_cursor: Option<Cursor>,
923 fallback_offset: usize,
924 ) -> Self {
925+ // Create buffer from snapshot
926+ let buffer = if snapshot.is_empty() {
927+ LoroTextBuffer::new()
928+ } else {
929+ match LoroTextBuffer::from_snapshot(snapshot) {
930+ Ok(buf) => buf,
931+ Err(e) => {
932+ tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
933+ LoroTextBuffer::new()
934+ }
935 }
936+ };
937938+ let doc = buffer.doc().clone();
939+940+ // Get other containers from the doc
941 let title = doc.get_text("title");
942 let path = doc.get_text("path");
943 let created_at = doc.get_text("created_at");
944 let tags = doc.get_list("tags");
945 let embeds = doc.get_map("embeds");
94600000947 // Try to restore cursor from Loro cursor, fall back to offset
948+ let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
949 let cursor_offset = if let Some(ref lc) = loro_cursor {
950 doc.get_cursor_pos(lc)
951 .map(|r| r.current.pos)
···959 affinity: Affinity::Before,
960 };
961962+ // Set up the Loro cursor
963+ let buffer = buffer;
964+ if let Some(lc) = loro_cursor {
965+ buffer.set_loro_cursor(Some(lc));
966+ } else {
967+ buffer.sync_cursor(cursor_state.offset);
968+ }
969970 Self {
971+ buffer,
0972 title,
973 path,
974 created_at,
···980 last_diff: Signal::new(None),
981 last_synced_version: Signal::new(None),
982 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
000983 cursor: Signal::new(cursor_state),
984 selection: Signal::new(None),
985 composition: Signal::new(None),
986 composition_ended_at: Signal::new(None),
987+ content_changed: Signal::new(()),
988 pending_snap: Signal::new(None),
989 collected_refs: Signal::new(Vec::new()),
990 }
991 }
992993+ /// Create a SignalEditorDocument from pre-loaded state.
994 ///
995 /// Use this when loading from PDS/localStorage merge outside reactive context.
996 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it
···999 /// # Note
1000 /// This creates Dioxus Signals. Call from within a component using `use_hook`.
1001 pub fn from_loaded_state(state: LoadedDocState) -> Self {
1002+ // Create buffer from the loaded doc
1003+ let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content");
1004+ let doc = buffer.doc().clone();
10051006+ // Get other containers from the doc
01007 let title = doc.get_text("title");
1008 let path = doc.get_text("path");
1009 let created_at = doc.get_text("created_at");
1010 let tags = doc.get_list("tags");
1011 let embeds = doc.get_map("embeds");
1012000001013 // Position cursor at end of content
1014+ let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
1015 let cursor_state = CursorState {
1016 offset: cursor_offset,
1017 affinity: Affinity::Before,
1018 };
1019+1020+ // Set up the Loro cursor
1021+ let buffer = buffer;
1022+ buffer.sync_cursor(cursor_offset);
10231024 Self {
1025+ buffer,
01026 title,
1027 path,
1028 created_at,
···1035 // Use the synced version from state (tracks the PDS version vector)
1036 last_synced_version: Signal::new(state.synced_version),
1037 last_seen_diffs: Signal::new(state.last_seen_diffs),
001038 cursor: Signal::new(cursor_state),
1039 selection: Signal::new(None),
1040 composition: Signal::new(None),
1041 composition_ended_at: Signal::new(None),
1042+ content_changed: Signal::new(()),
1043 pending_snap: Signal::new(None),
1044 collected_refs: Signal::new(Vec::new()),
1045 }
1046 }
1047}
10481049+impl PartialEq for SignalEditorDocument {
1050 fn eq(&self, _other: &Self) -> bool {
1051+ // SignalEditorDocument uses interior mutability, so we can't meaningfully compare.
1052 // Return false to ensure components re-render when passed as props.
1053 false
1054 }
1055}
10561057+impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument {
1058 fn export_snapshot(&self) -> Vec<u8> {
1059 self.export_snapshot()
1060 }
···1073 }
10741075 fn edit_root(&self) -> Option<StrongRef<'static>> {
1076+ SignalEditorDocument::edit_root(self)
1077 }
10781079 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
1080+ SignalEditorDocument::set_edit_root(self, root);
1081 }
10821083 fn last_diff(&self) -> Option<StrongRef<'static>> {
1084+ SignalEditorDocument::last_diff(self)
1085 }
10861087 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
1088+ SignalEditorDocument::set_last_diff(self, diff);
1089 }
10901091 fn mark_synced(&mut self) {
1092+ SignalEditorDocument::mark_synced(self);
1093 }
10941095 fn has_unsynced_changes(&self) -> bool {
1096+ SignalEditorDocument::has_unsynced_changes(self)
1097+ }
1098+}
1099+1100+impl EditorDocument for SignalEditorDocument {
1101+ type Buffer = LoroTextBuffer;
1102+1103+ fn buffer(&self) -> &Self::Buffer {
1104+ &self.buffer
1105+ }
1106+1107+ fn buffer_mut(&mut self) -> &mut Self::Buffer {
1108+ &mut self.buffer
1109+ }
1110+1111+ fn cursor(&self) -> CursorState {
1112+ *self.cursor.read()
1113+ }
1114+1115+ fn set_cursor(&mut self, cursor: CursorState) {
1116+ self.cursor.set(cursor);
1117+ }
1118+1119+ fn selection(&self) -> Option<Selection> {
1120+ self.selection.read().clone()
1121+ }
1122+1123+ fn set_selection(&mut self, selection: Option<Selection>) {
1124+ self.selection.set(selection);
1125+ }
1126+1127+ fn last_edit(&self) -> Option<EditInfo> {
1128+ self.buffer.last_edit()
1129+ }
1130+1131+ fn set_last_edit(&mut self, _edit: Option<EditInfo>) {
1132+ // Buffer tracks edit info internally. We use this hook to
1133+ // bump content_changed for reactive re-rendering.
1134+ self.content_changed.set(());
1135+ }
1136+1137+ fn composition(&self) -> Option<CompositionState> {
1138+ self.composition.read().clone()
1139+ }
1140+1141+ fn set_composition(&mut self, composition: Option<CompositionState>) {
1142+ self.composition.set(composition);
1143 }
1144}
···1//! Formatting actions and utilities for applying markdown formatting.
23-use super::document::EditorDocument;
04#[allow(unused_imports)]
5use super::input::{ListContext, detect_list_context, find_line_end};
6use dioxus::prelude::*;
···47/// Apply formatting to document.
48///
49/// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap.
50-pub fn apply_formatting(doc: &mut EditorDocument, action: FormatAction) {
51 let cursor_offset = doc.cursor.read().offset;
52 let (start, end) = if let Some(sel) = *doc.selection.read() {
53 // Use selection
···1//! Formatting actions and utilities for applying markdown formatting.
23+use crate::components::editor::SignalEditorDocument;
4+5#[allow(unused_imports)]
6use super::input::{ListContext, detect_list_context, find_line_end};
7use dioxus::prelude::*;
···48/// Apply formatting to document.
49///
50/// If there's a selection, wrap it. Otherwise, expand to word boundaries and wrap.
51+pub fn apply_formatting(doc: &mut SignalEditorDocument, action: FormatAction) {
52 let cursor_offset = doc.cursor.read().offset;
53 let (start, end) = if let Some(sel) = *doc.selection.read() {
54 // Use selection
+6-11
crates/weaver-app/src/components/editor/input.rs
···45use dioxus::prelude::*;
67-use super::document::EditorDocument;
8use super::formatting::{self, FormatAction};
9use weaver_editor_core::SnapDirection;
10···5253/// Handle keyboard events and update document state.
54#[allow(unused)]
55-pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) {
56 use dioxus::prelude::keyboard_types::Key;
5758 let key = evt.key();
···326327 // Sync Loro cursor when edits affect paragraph boundaries
328 // This ensures cursor position is tracked correctly through structural changes
329- if doc
330- .last_edit
331- .read()
332- .as_ref()
333- .is_some_and(|e| e.contains_newline)
334- {
335 doc.sync_loro_cursor();
336 }
337}
338339/// Handle paste events and insert text at cursor.
340-pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) {
341 evt.prevent_default();
342 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
343 let _ = doc;
···379}
380381/// Handle cut events - extract text, write to clipboard, then delete.
382-pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) {
383 evt.prevent_default();
384 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
385 let _ = doc;
···440}
441442/// Handle copy events - extract text, clean it up, write to clipboard.
443-pub fn handle_copy(evt: Event<ClipboardData>, doc: &EditorDocument) {
444 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
445 {
446 use dioxus::web::WebEventExt;
···45use dioxus::prelude::*;
67+use super::document::SignalEditorDocument;
8use super::formatting::{self, FormatAction};
9use weaver_editor_core::SnapDirection;
10···5253/// Handle keyboard events and update document state.
54#[allow(unused)]
55+pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut SignalEditorDocument) {
56 use dioxus::prelude::keyboard_types::Key;
5758 let key = evt.key();
···326327 // Sync Loro cursor when edits affect paragraph boundaries
328 // This ensures cursor position is tracked correctly through structural changes
329+ if doc.last_edit().is_some_and(|e| e.contains_newline) {
00000330 doc.sync_loro_cursor();
331 }
332}
333334/// Handle paste events and insert text at cursor.
335+pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
336 evt.prevent_default();
337 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
338 let _ = doc;
···374}
375376/// Handle cut events - extract text, write to clipboard, then delete.
377+pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) {
378 evt.prevent_default();
379 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
380 let _ = doc;
···435}
436437/// Handle copy events - extract text, clean it up, write to clipboard.
438+pub fn handle_copy(evt: Event<ClipboardData>, doc: &SignalEditorDocument) {
439 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
440 {
441 use dioxus::web::WebEventExt;
···29use serde::{Deserialize, Serialize};
30use weaver_api::com_atproto::repo::strong_ref::StrongRef;
3132-use super::document::EditorDocument;
3334/// Prefix for all draft storage keys.
35pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:";
···89/// Save editor state to LocalStorage (WASM only).
90#[cfg(all(target_family = "wasm", target_os = "unknown"))]
91pub fn save_to_storage(
92- doc: &EditorDocument,
93 key: &str,
94) -> Result<(), gloo_storage::errors::StorageError> {
95 let export_start = crate::perf::now();
···108 content: doc.content(),
109 title: doc.title().into(),
110 snapshot: snapshot_b64,
111- cursor: doc.loro_cursor().cloned(),
112 cursor_offset: doc.cursor.read().offset,
113 editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()),
114 editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()),
···132133/// Load editor state from LocalStorage (WASM only).
134///
135-/// Returns an EditorDocument restored from CRDT snapshot if available,
136/// otherwise falls back to just the text content.
137#[cfg(all(target_family = "wasm", target_os = "unknown"))]
138-pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
139 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
140141 // Parse entry_ref from the snapshot (requires both URI and CID)
···152 // Try to restore from CRDT snapshot first
153 if let Some(ref snapshot_b64) = snapshot.snapshot {
154 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) {
155- let mut doc = EditorDocument::from_snapshot(
156 &snapshot_bytes,
157 snapshot.cursor.clone(),
158 snapshot.cursor_offset,
···170 }
171172 // Fallback: create new doc from text content
173- let mut doc = EditorDocument::new(snapshot.content);
174 doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars());
175 doc.sync_loro_cursor();
176 doc.set_entry_ref(entry_ref);
···192193/// Load snapshot data from LocalStorage (WASM only).
194///
195-/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe
196/// to call outside of reactive context. Use with `load_and_merge_document`.
197#[cfg(all(target_family = "wasm", target_os = "unknown"))]
198pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> {
···344345// Stub implementations for non-WASM targets
346#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
347-pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> {
348 Ok(())
349}
350351#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
352-pub fn load_from_storage(_key: &str) -> Option<EditorDocument> {
353 None
354}
355
···29use serde::{Deserialize, Serialize};
30use weaver_api::com_atproto::repo::strong_ref::StrongRef;
3132+use super::document::SignalEditorDocument;
3334/// Prefix for all draft storage keys.
35pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:";
···89/// Save editor state to LocalStorage (WASM only).
90#[cfg(all(target_family = "wasm", target_os = "unknown"))]
91pub fn save_to_storage(
92+ doc: &SignalEditorDocument,
93 key: &str,
94) -> Result<(), gloo_storage::errors::StorageError> {
95 let export_start = crate::perf::now();
···108 content: doc.content(),
109 title: doc.title().into(),
110 snapshot: snapshot_b64,
111+ cursor: doc.loro_cursor(),
112 cursor_offset: doc.cursor.read().offset,
113 editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()),
114 editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()),
···132133/// Load editor state from LocalStorage (WASM only).
134///
135+/// Returns an SignalEditorDocument restored from CRDT snapshot if available,
136/// otherwise falls back to just the text content.
137#[cfg(all(target_family = "wasm", target_os = "unknown"))]
138+pub fn load_from_storage(key: &str) -> Option<SignalEditorDocument> {
139 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
140141 // Parse entry_ref from the snapshot (requires both URI and CID)
···152 // Try to restore from CRDT snapshot first
153 if let Some(ref snapshot_b64) = snapshot.snapshot {
154 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) {
155+ let mut doc = SignalEditorDocument::from_snapshot(
156 &snapshot_bytes,
157 snapshot.cursor.clone(),
158 snapshot.cursor_offset,
···170 }
171172 // Fallback: create new doc from text content
173+ let mut doc = SignalEditorDocument::new(snapshot.content);
174 doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars());
175 doc.sync_loro_cursor();
176 doc.set_entry_ref(entry_ref);
···192193/// Load snapshot data from LocalStorage (WASM only).
194///
195+/// Unlike `load_from_storage`, this doesn't create an SignalEditorDocument and is safe
196/// to call outside of reactive context. Use with `load_and_merge_document`.
197#[cfg(all(target_family = "wasm", target_os = "unknown"))]
198pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> {
···344345// Stub implementations for non-WASM targets
346#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
347+pub fn save_to_storage(_doc: &SignalEditorDocument, _key: &str) -> Result<(), String> {
348 Ok(())
349}
350351#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
352+pub fn load_from_storage(_key: &str) -> Option<SignalEditorDocument> {
353 None
354}
355
+8-8
crates/weaver-app/src/components/editor/sync.rs
···910use std::collections::HashMap;
1112-use super::document::{EditorDocument, LoadedDocState};
13use crate::fetch::Fetcher;
14use jacquard::IntoStatic;
15use jacquard::prelude::*;
···214/// Wraps the crdt crate's create_edit_root with Fetcher support.
215pub async fn create_edit_root(
216 fetcher: &Fetcher,
217- doc: &EditorDocument,
218 draft_key: &str,
219 entry_uri: Option<&AtUri<'_>>,
220 entry_cid: Option<&Cid<'_>>,
···235/// Wraps the crdt crate's create_diff with Fetcher support.
236pub async fn create_diff(
237 fetcher: &Fetcher,
238- doc: &EditorDocument,
239 root_uri: &AtUri<'_>,
240 root_cid: &Cid<'_>,
241 prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>,
···264/// Updates the document's sync state on success.
265pub async fn sync_to_pds(
266 fetcher: &Fetcher,
267- doc: &mut EditorDocument,
268 draft_key: &str,
269) -> Result<SyncResult, WeaverError> {
270 let fn_start = crate::perf::now();
···415///
416/// Loads from localStorage and PDS (if available), then merges both using Loro's
417/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
418-/// EditorDocument inside a reactive context using `use_hook`.
419///
420/// For unpublished drafts, attempts to discover edit state via Constellation
421/// using the synthetic draft URI.
···603#[derive(Props, Clone, PartialEq)]
604pub struct SyncStatusProps {
605 /// The editor document to sync
606- pub document: EditorDocument,
607 /// Draft key for this document
608 pub draft_key: String,
609 /// Auto-sync interval in milliseconds (0 to disable, default disabled)
···707 // Note: We use peek() to avoid creating a reactive dependency on sync_state
708 let doc_for_effect = doc.clone();
709 use_effect(move || {
710- // Check for unsynced changes (reads last_edit signal for reactivity)
711- let _edit = doc_for_effect.last_edit();
712713 // Use peek to avoid reactive loop
714 let current_state = *sync_state.peek();
···910use std::collections::HashMap;
1112+use super::document::{LoadedDocState, SignalEditorDocument};
13use crate::fetch::Fetcher;
14use jacquard::IntoStatic;
15use jacquard::prelude::*;
···214/// Wraps the crdt crate's create_edit_root with Fetcher support.
215pub async fn create_edit_root(
216 fetcher: &Fetcher,
217+ doc: &SignalEditorDocument,
218 draft_key: &str,
219 entry_uri: Option<&AtUri<'_>>,
220 entry_cid: Option<&Cid<'_>>,
···235/// Wraps the crdt crate's create_diff with Fetcher support.
236pub async fn create_diff(
237 fetcher: &Fetcher,
238+ doc: &SignalEditorDocument,
239 root_uri: &AtUri<'_>,
240 root_cid: &Cid<'_>,
241 prev_diff: Option<(&AtUri<'_>, &Cid<'_>)>,
···264/// Updates the document's sync state on success.
265pub async fn sync_to_pds(
266 fetcher: &Fetcher,
267+ doc: &mut SignalEditorDocument,
268 draft_key: &str,
269) -> Result<SyncResult, WeaverError> {
270 let fn_start = crate::perf::now();
···415///
416/// Loads from localStorage and PDS (if available), then merges both using Loro's
417/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
418+/// SignalEditorDocument inside a reactive context using `use_hook`.
419///
420/// For unpublished drafts, attempts to discover edit state via Constellation
421/// using the synthetic draft URI.
···603#[derive(Props, Clone, PartialEq)]
604pub struct SyncStatusProps {
605 /// The editor document to sync
606+ pub document: SignalEditorDocument,
607 /// Draft key for this document
608 pub draft_key: String,
609 /// Auto-sync interval in milliseconds (0 to disable, default disabled)
···707 // Note: We use peek() to avoid creating a reactive dependency on sync_state
708 let doc_for_effect = doc.clone();
709 use_effect(move || {
710+ // Read content_changed to create reactive dependency on document changes
711+ let _ = doc_for_effect.content_changed.read();
712713 // Use peek to avoid reactive loop
714 let current_state = *sync_state.peek();
+20-26
crates/weaver-app/src/components/editor/tests.rs
···1//! Snapshot tests for the markdown editor rendering pipeline.
23-use weaver_editor_core::{OffsetMapping, find_mapping_for_char};
4use super::paragraph::ParagraphRender;
5use super::render::render_paragraphs_incremental;
6-use loro::LoroDoc;
7use serde::Serialize;
8use weaver_common::ResolvedContent;
00910/// Serializable version of ParagraphRender for snapshot testing.
11#[derive(Debug, Serialize)]
···5556/// Helper: render markdown and convert to serializable test output.
57fn render_test(input: &str) -> Vec<TestParagraph> {
58- let doc = LoroDoc::new();
59- let text = doc.get_text("content");
60- text.insert(0, input).unwrap();
61 let (paragraphs, _cache, _refs) =
62- render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default());
63 paragraphs.iter().map(TestParagraph::from).collect()
64}
65···640 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
641 // This tests that the syntax spans are correctly updated on content change.
642 use super::render::render_paragraphs_incremental;
643- use loro::LoroDoc;
644645- let doc = LoroDoc::new();
646- let text = doc.get_text("content");
647648 // Initial state: "#" is a valid empty heading
649- text.insert(0, "#").unwrap();
650 let (paras1, cache1, _refs1) =
651- render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default());
652653 eprintln!("State 1 ('#'): {}", paras1[0].html);
654 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···658 );
659660 // Transition: add "t" to make "#t" - no longer a heading
661- text.insert(1, "t").unwrap();
662 let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
663- &text,
664 Some(&cache1),
665 0,
666 None,
···773 // cursor snaps to adjacent paragraphs for standard breaks.
774 // Only EXTRA whitespace beyond \n\n gets gap elements.
775 let input = "Hello\n\nWorld";
776- let doc = LoroDoc::new();
777- let text = doc.get_text("content");
778- text.insert(0, input).unwrap();
779 let (paragraphs, _cache, _refs) =
780- render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default());
781782 // With standard \n\n break, we expect 2 paragraphs (no gap element)
783 // Paragraph ranges include some trailing whitespace from markdown parsing
···803 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
804 // Plain paragraphs don't consume trailing newlines like headings do
805 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
806- let doc = LoroDoc::new();
807- let text = doc.get_text("content");
808- text.insert(0, input).unwrap();
809 let (paragraphs, _cache, _refs) =
810- render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default());
811812 // With extra newlines, we expect 3 elements: para, gap, para
813 assert_eq!(
···903fn test_incremental_cache_reuse() {
904 // Verify cache is populated and can be reused
905 let input = "First para\n\nSecond para";
906- let doc = LoroDoc::new();
907- let text = doc.get_text("content");
908- text.insert(0, input).unwrap();
909910 let (paras1, cache1, _refs1) =
911- render_paragraphs_incremental(&text, None, 0, None, None, None, &ResolvedContent::default());
912 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
913914 // Second render with same content should reuse cache
915 let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
916- &text,
917 Some(&cache1),
918 0,
919 None,
···1//! Snapshot tests for the markdown editor rendering pipeline.
203use super::paragraph::ParagraphRender;
4use super::render::render_paragraphs_incremental;
05use serde::Serialize;
6use weaver_common::ResolvedContent;
7+use weaver_editor_core::{OffsetMapping, TextBuffer, find_mapping_for_char};
8+use weaver_editor_crdt::LoroTextBuffer;
910/// Serializable version of ParagraphRender for snapshot testing.
11#[derive(Debug, Serialize)]
···5556/// Helper: render markdown and convert to serializable test output.
57fn render_test(input: &str) -> Vec<TestParagraph> {
58+ let mut buffer = LoroTextBuffer::new();
59+ buffer.insert(0, input);
060 let (paragraphs, _cache, _refs) =
61+ render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
62 paragraphs.iter().map(TestParagraph::from).collect()
63}
64···639 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
640 // This tests that the syntax spans are correctly updated on content change.
641 use super::render::render_paragraphs_incremental;
0642643+ let mut buffer = LoroTextBuffer::new();
0644645 // Initial state: "#" is a valid empty heading
646+ buffer.insert(0, "#");
647 let (paras1, cache1, _refs1) =
648+ render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
649650 eprintln!("State 1 ('#'): {}", paras1[0].html);
651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···655 );
656657 // Transition: add "t" to make "#t" - no longer a heading
658+ buffer.insert(1, "t");
659 let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
660+ &buffer,
661 Some(&cache1),
662 0,
663 None,
···770 // cursor snaps to adjacent paragraphs for standard breaks.
771 // Only EXTRA whitespace beyond \n\n gets gap elements.
772 let input = "Hello\n\nWorld";
773+ let mut buffer = LoroTextBuffer::new();
774+ buffer.insert(0, input);
0775 let (paragraphs, _cache, _refs) =
776+ render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
777778 // With standard \n\n break, we expect 2 paragraphs (no gap element)
779 // Paragraph ranges include some trailing whitespace from markdown parsing
···799 // Extra whitespace beyond MIN_PARAGRAPH_BREAK (2) gets gap elements
800 // Plain paragraphs don't consume trailing newlines like headings do
801 let input = "Hello\n\n\n\nWorld"; // 4 newlines = gap of 4 > 2
802+ let mut buffer = LoroTextBuffer::new();
803+ buffer.insert(0, input);
0804 let (paragraphs, _cache, _refs) =
805+ render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
806807 // With extra newlines, we expect 3 elements: para, gap, para
808 assert_eq!(
···898fn test_incremental_cache_reuse() {
899 // Verify cache is populated and can be reused
900 let input = "First para\n\nSecond para";
901+ let mut buffer = LoroTextBuffer::new();
902+ buffer.insert(0, input);
0903904 let (paras1, cache1, _refs1) =
905+ render_paragraphs_incremental(&buffer, None, 0, None, None, None, &ResolvedContent::default());
906 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
907908 // Second render with same content should reuse cache
909 let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
910+ &buffer,
911 Some(&cache1),
912 0,
913 None,