···22//!
33//! Uses Loro CRDT for text storage with built-in undo/redo support.
44//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration.
55+//!
66+//! # Reactive Architecture
77+//!
88+//! Individual fields are wrapped in Dioxus Signals for fine-grained reactivity:
99+//! - Cursor/selection changes don't trigger content re-renders
1010+//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation
1111+//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
5121313+use std::cell::RefCell;
1414+use std::rc::Rc;
1515+1616+use dioxus::prelude::*;
617use loro::{
718 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
819 cursor::{Cursor, Side},
···3041/// Contains the document text (backed by Loro CRDT), cursor position,
3142/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
3243/// schema with CRDT containers for each field.
3333-#[derive(Debug)]
4444+///
4545+/// # Reactive Architecture
4646+///
4747+/// The document itself is NOT wrapped in a Signal. Instead, individual fields
4848+/// that need reactivity are wrapped in Signals:
4949+/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
5050+/// - `last_edit` - triggers paragraph re-renders when content changes
5151+///
5252+/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`.
5353+///
5454+/// # Cloning
5555+///
5656+/// EditorDocument is cheap to clone - Loro types are Arc-backed handles,
5757+/// and Signals are Copy. Closures can capture clones without overhead.
5858+#[derive(Clone)]
3459pub struct EditorDocument {
3560 /// The Loro document containing all editor state.
3661 doc: LoroDoc,
37623838- // --- Entry schema containers ---
6363+ // --- Entry schema containers (Loro handles interior mutability) ---
3964 /// Markdown content (maps to entry.content)
4065 content: LoroText,
4166···6085 /// None for new entries that haven't been published yet.
6186 entry_uri: Option<AtUri<'static>>,
62876363- // --- Editor state ---
8888+ // --- Editor state (non-reactive) ---
6489 /// Undo manager for the document.
6565- undo_mgr: UndoManager,
6666-6767- /// Current cursor position (char offset) - fast local cache.
6868- /// This is the authoritative position for immediate operations.
6969- pub cursor: CursorState,
9090+ undo_mgr: Rc<RefCell<UndoManager>>,
70917192 /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
7293 /// Recreated after our own edits, queried after undo/redo/remote edits.
7394 loro_cursor: Option<Cursor>,
74957575- /// Active selection if any
7676- pub selection: Option<Selection>,
9696+ // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
9797+ /// Current cursor position. Signal so cursor changes don't dirty content memos.
9898+ pub cursor: Signal<CursorState>,
77997878- /// IME composition state (for Phase 3)
7979- pub composition: Option<CompositionState>,
100100+ /// Active selection if any. Signal for same reason as cursor.
101101+ pub selection: Signal<Option<Selection>>,
102102+103103+ /// IME composition state. Signal so composition updates are isolated.
104104+ pub composition: Signal<Option<CompositionState>>,
8010581106 /// Timestamp when the last composition ended.
82107 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
8383- pub composition_ended_at: Option<web_time::Instant>,
108108+ pub composition_ended_at: Signal<Option<web_time::Instant>>,
8410985110 /// Most recent edit info for incremental rendering optimization.
8686- /// Used to determine if we can skip full re-parsing.
8787- pub last_edit: Option<EditInfo>,
111111+ /// Signal so paragraphs memo can subscribe to content changes only.
112112+ pub last_edit: Signal<Option<EditInfo>>,
113113+114114+ /// Pending snap direction for cursor restoration after edits.
115115+ /// Set by input handlers, consumed by cursor restoration.
116116+ pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>,
88117}
8911890119/// Cursor state including position and affinity.
···121150}
122151123152/// Information about the most recent edit, used for incremental rendering optimization.
124124-#[derive(Clone, Debug, Default)]
153153+/// Derives PartialEq so it can be used with Dioxus memos for change detection.
154154+#[derive(Clone, Debug, Default, PartialEq)]
125155pub struct EditInfo {
126156 /// Character offset where the edit occurred
127157 pub edit_char_pos: usize,
···170200171201 /// Create a new editor document with the given content.
172202 /// Sets `created_at` to current time.
203203+ ///
204204+ /// # Note
205205+ /// This creates Dioxus Signals for reactive fields. Call from within
206206+ /// a component using `use_hook(|| EditorDocument::new(...))`.
173207 pub fn new(initial_content: String) -> Self {
174208 let doc = LoroDoc::new();
175209···211245 tags,
212246 embeds,
213247 entry_uri: None,
214214- undo_mgr,
215215- cursor: CursorState {
248248+ undo_mgr: Rc::new(RefCell::new(undo_mgr)),
249249+ loro_cursor,
250250+ // Reactive editor state - wrapped in Signals
251251+ cursor: Signal::new(CursorState {
216252 offset: 0,
217253 affinity: Affinity::Before,
218218- },
219219- loro_cursor,
220220- selection: None,
221221- composition: None,
222222- composition_ended_at: None,
223223- last_edit: None,
254254+ }),
255255+ selection: Signal::new(None),
256256+ composition: Signal::new(None),
257257+ composition_ended_at: Signal::new(None),
258258+ last_edit: Signal::new(None),
259259+ pending_snap: Signal::new(None),
224260 }
225261 }
226262···284320 }
285321286322 /// Set the entry title (replaces existing).
287287- pub fn set_title(&mut self, new_title: &str) {
323323+ /// Takes &self because Loro has interior mutability.
324324+ pub fn set_title(&self, new_title: &str) {
288325 let current_len = self.title.len_unicode();
289326 if current_len > 0 {
290327 self.title.delete(0, current_len).ok();
···298335 }
299336300337 /// Set the URL path/slug (replaces existing).
301301- pub fn set_path(&mut self, new_path: &str) {
338338+ /// Takes &self because Loro has interior mutability.
339339+ pub fn set_path(&self, new_path: &str) {
302340 let current_len = self.path.len_unicode();
303341 if current_len > 0 {
304342 self.path.delete(0, current_len).ok();
···312350 }
313351314352 /// Set the created_at timestamp (usually only called once on creation or when loading).
315315- pub fn set_created_at(&mut self, datetime: &str) {
353353+ /// Takes &self because Loro has interior mutability.
354354+ pub fn set_created_at(&self, datetime: &str) {
316355 let current_len = self.created_at.len_unicode();
317356 if current_len > 0 {
318357 self.created_at.delete(0, current_len).ok();
···346385 }
347386348387 /// Add a tag (if not already present).
349349- pub fn add_tag(&mut self, tag: &str) {
388388+ /// Takes &self because Loro has interior mutability.
389389+ pub fn add_tag(&self, tag: &str) {
350390 let existing = self.tags();
351391 if !existing.iter().any(|t| t == tag) {
352392 self.tags.push(LoroValue::String(tag.into())).ok();
···354394 }
355395356396 /// Remove a tag by value.
357357- pub fn remove_tag(&mut self, tag: &str) {
397397+ /// Takes &self because Loro has interior mutability.
398398+ pub fn remove_tag(&self, tag: &str) {
358399 let len = self.tags.len();
359400 for i in (0..len).rev() {
360401 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
···367408 }
368409369410 /// Clear all tags.
370370- pub fn clear_tags(&mut self) {
411411+ /// Takes &self because Loro has interior mutability.
412412+ pub fn clear_tags(&self) {
371413 let len = self.tags.len();
372414 if len > 0 {
373415 self.tags.delete(0, len).ok();
···484526 let len_before = self.content.len_unicode();
485527 let result = self.content.insert(pos, text);
486528 let len_after = self.content.len_unicode();
487487- self.last_edit = Some(EditInfo {
529529+ self.last_edit.set(Some(EditInfo {
488530 edit_char_pos: pos,
489531 inserted_len: len_after.saturating_sub(len_before),
490532 deleted_len: 0,
491533 contains_newline: text.contains('\n'),
492534 in_block_syntax_zone,
493535 doc_len_after: len_after,
494494- });
536536+ }));
495537 result
496538 }
497539···501543 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
502544 let result = self.content.push_str(text);
503545 let len_after = self.content.len_unicode();
504504- self.last_edit = Some(EditInfo {
546546+ self.last_edit.set(Some(EditInfo {
505547 edit_char_pos: pos,
506548 inserted_len: text.chars().count(),
507549 deleted_len: 0,
508550 contains_newline: text.contains('\n'),
509551 in_block_syntax_zone,
510552 doc_len_after: len_after,
511511- });
553553+ }));
512554 result
513555 }
514556···519561 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
520562521563 let result = self.content.delete(start, len);
522522- self.last_edit = Some(EditInfo {
564564+ self.last_edit.set(Some(EditInfo {
523565 edit_char_pos: start,
524566 inserted_len: 0,
525567 deleted_len: len,
526568 contains_newline,
527569 in_block_syntax_zone,
528570 doc_len_after: self.content.len_unicode(),
529529- });
571571+ }));
530572 result
531573 }
532574···545587 // because: len_after = len_before - deleted + inserted
546588 let inserted_len = (len_after + len).saturating_sub(len_before);
547589548548- self.last_edit = Some(EditInfo {
590590+ self.last_edit.set(Some(EditInfo {
549591 edit_char_pos: start,
550592 inserted_len,
551593 deleted_len: len,
552594 contains_newline: delete_has_newline || text.contains('\n'),
553595 in_block_syntax_zone,
554596 doc_len_after: len_after,
555555- });
597597+ }));
556598 Ok(())
557599 }
558600···564606 // so it tracks through the undo operation
565607 self.sync_loro_cursor();
566608567567- let result = self.undo_mgr.undo()?;
609609+ let result = self.undo_mgr.borrow_mut().undo()?;
568610 if result {
569611 // After undo, query Loro cursor for new position
570612 self.sync_cursor_from_loro();
613613+ // Signal content change for re-render
614614+ self.last_edit.set(None);
571615 }
572616 Ok(result)
573617 }
···579623 // Sync Loro cursor to current position BEFORE redo
580624 self.sync_loro_cursor();
581625582582- let result = self.undo_mgr.redo()?;
626626+ let result = self.undo_mgr.borrow_mut().redo()?;
583627 if result {
584628 // After redo, query Loro cursor for new position
585629 self.sync_cursor_from_loro();
630630+ // Signal content change for re-render
631631+ self.last_edit.set(None);
586632 }
587633 Ok(result)
588634 }
589635590636 /// Check if undo is available.
591637 pub fn can_undo(&self) -> bool {
592592- self.undo_mgr.can_undo()
638638+ self.undo_mgr.borrow().can_undo()
593639 }
594640595641 /// Check if redo is available.
596642 pub fn can_redo(&self) -> bool {
597597- self.undo_mgr.can_redo()
643643+ self.undo_mgr.borrow().can_redo()
598644 }
599645600646 /// Get a slice of the content text.
···606652 /// Sync the Loro cursor to the current cursor.offset position.
607653 /// Call this after OUR edits where we know the new cursor position.
608654 pub fn sync_loro_cursor(&mut self) {
609609- self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default());
655655+ let offset = self.cursor.read().offset;
656656+ self.loro_cursor = self.content.get_cursor(offset, Side::default());
610657 }
611658612659 /// Update cursor.offset from the Loro cursor's tracked position.
···615662 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
616663 let loro_cursor = self.loro_cursor.as_ref()?;
617664 let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
618618- let new_offset = result.current.pos;
619619- self.cursor.offset = new_offset.min(self.len_chars());
620620- Some(self.cursor.offset)
665665+ let new_offset = result.current.pos.min(self.len_chars());
666666+ self.cursor.with_mut(|c| c.offset = new_offset);
667667+ Some(new_offset)
621668 }
622669623670 /// Get the Loro cursor for serialization.
···646693 self.doc.state_frontiers()
647694 }
648695696696+ /// Get the last edit info for incremental rendering.
697697+ /// Reading this creates a reactive dependency on content changes.
698698+ pub fn last_edit(&self) -> Option<EditInfo> {
699699+ self.last_edit.read().clone()
700700+ }
701701+649702 /// Create a new EditorDocument from a binary snapshot.
650703 /// Falls back to empty document if import fails.
651704 ///
···655708 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
656709 /// happen in real-time; it cannot rebuild history from imported CRDT ops.
657710 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
711711+ ///
712712+ /// # Note
713713+ /// This creates Dioxus Signals for reactive fields. Call from within
714714+ /// a component using `use_hook`.
658715 pub fn from_snapshot(
659716 snapshot: &[u8],
660717 loro_cursor: Option<Cursor>,
···691748 fallback_offset
692749 };
693750694694- let cursor = CursorState {
751751+ let cursor_state = CursorState {
695752 offset: cursor_offset.min(max_offset),
696753 affinity: Affinity::Before,
697754 };
698755699756 // If no Loro cursor provided, create one at the restored position
700757 let loro_cursor =
701701- loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default()));
758758+ loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default()));
702759703760 Self {
704761 doc,
···709766 tags,
710767 embeds,
711768 entry_uri: None,
712712- undo_mgr,
713713- cursor,
769769+ undo_mgr: Rc::new(RefCell::new(undo_mgr)),
714770 loro_cursor,
715715- selection: None,
716716- composition: None,
717717- composition_ended_at: None,
718718- last_edit: None,
771771+ // Reactive editor state - wrapped in Signals
772772+ cursor: Signal::new(cursor_state),
773773+ selection: Signal::new(None),
774774+ composition: Signal::new(None),
775775+ composition_ended_at: Signal::new(None),
776776+ last_edit: Signal::new(None),
777777+ pending_snap: Signal::new(None),
719778 }
720779 }
721780}
722781723723-// EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone.
724724-// This is intentional - the document should be the single source of truth.
725725-726726-impl Clone for EditorDocument {
727727- fn clone(&self) -> Self {
728728- // Use snapshot export/import for a complete clone including all containers
729729- let snapshot = self.export_snapshot();
730730- let mut new_doc =
731731- Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset);
732732-733733- // Copy non-CRDT state
734734- new_doc.cursor = self.cursor;
735735- new_doc.sync_loro_cursor();
736736- new_doc.selection = self.selection;
737737- new_doc.composition = self.composition.clone();
738738- new_doc.composition_ended_at = self.composition_ended_at;
739739- new_doc.last_edit = self.last_edit.clone();
740740- new_doc.entry_uri = self.entry_uri.clone();
741741- new_doc
782782+impl PartialEq for EditorDocument {
783783+ fn eq(&self, _other: &Self) -> bool {
784784+ // EditorDocument uses interior mutability, so we can't meaningfully compare.
785785+ // Return false to ensure components re-render when passed as props.
786786+ false
742787 }
743788}