···227228/* Hidden syntax spans - collapsed when cursor is not near */
229.md-syntax-inline.hidden,
230-.md-syntax-block.hidden {
0231 display: none;
232}
233
···227228/* Hidden syntax spans - collapsed when cursor is not near */
229.md-syntax-inline.hidden,
230+.md-syntax-block.hidden,
231+.image-alt.hidden {
232 display: none;
233}
234
···2//!
3//! Uses Loro CRDT for text storage with built-in undo/redo support.
4//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration.
0000000500006use loro::{
7 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
8 cursor::{Cursor, Side},
···30/// Contains the document text (backed by Loro CRDT), cursor position,
31/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
32/// schema with CRDT containers for each field.
33-#[derive(Debug)]
0000000000000034pub struct EditorDocument {
35 /// The Loro document containing all editor state.
36 doc: LoroDoc,
3738- // --- Entry schema containers ---
39 /// Markdown content (maps to entry.content)
40 content: LoroText,
41···60 /// None for new entries that haven't been published yet.
61 entry_uri: Option<AtUri<'static>>,
6263- // --- Editor state ---
64 /// Undo manager for the document.
65- undo_mgr: UndoManager,
66-67- /// Current cursor position (char offset) - fast local cache.
68- /// This is the authoritative position for immediate operations.
69- pub cursor: CursorState,
7071 /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
72 /// Recreated after our own edits, queried after undo/redo/remote edits.
73 loro_cursor: Option<Cursor>,
7475- /// Active selection if any
76- pub selection: Option<Selection>,
07778- /// IME composition state (for Phase 3)
79- pub composition: Option<CompositionState>,
0008081 /// Timestamp when the last composition ended.
82 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
83- pub composition_ended_at: Option<web_time::Instant>,
8485 /// Most recent edit info for incremental rendering optimization.
86- /// Used to determine if we can skip full re-parsing.
87- pub last_edit: Option<EditInfo>,
000088}
8990/// Cursor state including position and affinity.
···121}
122123/// Information about the most recent edit, used for incremental rendering optimization.
124-#[derive(Clone, Debug, Default)]
0125pub struct EditInfo {
126 /// Character offset where the edit occurred
127 pub edit_char_pos: usize,
···170171 /// Create a new editor document with the given content.
172 /// Sets `created_at` to current time.
0000173 pub fn new(initial_content: String) -> Self {
174 let doc = LoroDoc::new();
175···211 tags,
212 embeds,
213 entry_uri: None,
214- undo_mgr,
215- cursor: CursorState {
00216 offset: 0,
217 affinity: Affinity::Before,
218- },
219- loro_cursor,
220- selection: None,
221- composition: None,
222- composition_ended_at: None,
223- last_edit: None,
224 }
225 }
226···284 }
285286 /// Set the entry title (replaces existing).
287- pub fn set_title(&mut self, new_title: &str) {
0288 let current_len = self.title.len_unicode();
289 if current_len > 0 {
290 self.title.delete(0, current_len).ok();
···298 }
299300 /// Set the URL path/slug (replaces existing).
301- pub fn set_path(&mut self, new_path: &str) {
0302 let current_len = self.path.len_unicode();
303 if current_len > 0 {
304 self.path.delete(0, current_len).ok();
···312 }
313314 /// Set the created_at timestamp (usually only called once on creation or when loading).
315- pub fn set_created_at(&mut self, datetime: &str) {
0316 let current_len = self.created_at.len_unicode();
317 if current_len > 0 {
318 self.created_at.delete(0, current_len).ok();
···346 }
347348 /// Add a tag (if not already present).
349- pub fn add_tag(&mut self, tag: &str) {
0350 let existing = self.tags();
351 if !existing.iter().any(|t| t == tag) {
352 self.tags.push(LoroValue::String(tag.into())).ok();
···354 }
355356 /// Remove a tag by value.
357- pub fn remove_tag(&mut self, tag: &str) {
0358 let len = self.tags.len();
359 for i in (0..len).rev() {
360 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
···367 }
368369 /// Clear all tags.
370- pub fn clear_tags(&mut self) {
0371 let len = self.tags.len();
372 if len > 0 {
373 self.tags.delete(0, len).ok();
···484 let len_before = self.content.len_unicode();
485 let result = self.content.insert(pos, text);
486 let len_after = self.content.len_unicode();
487- self.last_edit = Some(EditInfo {
488 edit_char_pos: pos,
489 inserted_len: len_after.saturating_sub(len_before),
490 deleted_len: 0,
491 contains_newline: text.contains('\n'),
492 in_block_syntax_zone,
493 doc_len_after: len_after,
494- });
495 result
496 }
497···501 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
502 let result = self.content.push_str(text);
503 let len_after = self.content.len_unicode();
504- self.last_edit = Some(EditInfo {
505 edit_char_pos: pos,
506 inserted_len: text.chars().count(),
507 deleted_len: 0,
508 contains_newline: text.contains('\n'),
509 in_block_syntax_zone,
510 doc_len_after: len_after,
511- });
512 result
513 }
514···519 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
520521 let result = self.content.delete(start, len);
522- self.last_edit = Some(EditInfo {
523 edit_char_pos: start,
524 inserted_len: 0,
525 deleted_len: len,
526 contains_newline,
527 in_block_syntax_zone,
528 doc_len_after: self.content.len_unicode(),
529- });
530 result
531 }
532···545 // because: len_after = len_before - deleted + inserted
546 let inserted_len = (len_after + len).saturating_sub(len_before);
547548- self.last_edit = Some(EditInfo {
549 edit_char_pos: start,
550 inserted_len,
551 deleted_len: len,
552 contains_newline: delete_has_newline || text.contains('\n'),
553 in_block_syntax_zone,
554 doc_len_after: len_after,
555- });
556 Ok(())
557 }
558···564 // so it tracks through the undo operation
565 self.sync_loro_cursor();
566567- let result = self.undo_mgr.undo()?;
568 if result {
569 // After undo, query Loro cursor for new position
570 self.sync_cursor_from_loro();
00571 }
572 Ok(result)
573 }
···579 // Sync Loro cursor to current position BEFORE redo
580 self.sync_loro_cursor();
581582- let result = self.undo_mgr.redo()?;
583 if result {
584 // After redo, query Loro cursor for new position
585 self.sync_cursor_from_loro();
00586 }
587 Ok(result)
588 }
589590 /// Check if undo is available.
591 pub fn can_undo(&self) -> bool {
592- self.undo_mgr.can_undo()
593 }
594595 /// Check if redo is available.
596 pub fn can_redo(&self) -> bool {
597- self.undo_mgr.can_redo()
598 }
599600 /// Get a slice of the content text.
···606 /// Sync the Loro cursor to the current cursor.offset position.
607 /// Call this after OUR edits where we know the new cursor position.
608 pub fn sync_loro_cursor(&mut self) {
609- self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default());
0610 }
611612 /// Update cursor.offset from the Loro cursor's tracked position.
···615 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
616 let loro_cursor = self.loro_cursor.as_ref()?;
617 let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
618- let new_offset = result.current.pos;
619- self.cursor.offset = new_offset.min(self.len_chars());
620- Some(self.cursor.offset)
621 }
622623 /// Get the Loro cursor for serialization.
···646 self.doc.state_frontiers()
647 }
648000000649 /// Create a new EditorDocument from a binary snapshot.
650 /// Falls back to empty document if import fails.
651 ///
···655 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
656 /// happen in real-time; it cannot rebuild history from imported CRDT ops.
657 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
0000658 pub fn from_snapshot(
659 snapshot: &[u8],
660 loro_cursor: Option<Cursor>,
···691 fallback_offset
692 };
693694- let cursor = CursorState {
695 offset: cursor_offset.min(max_offset),
696 affinity: Affinity::Before,
697 };
698699 // If no Loro cursor provided, create one at the restored position
700 let loro_cursor =
701- loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default()));
702703 Self {
704 doc,
···709 tags,
710 embeds,
711 entry_uri: None,
712- undo_mgr,
713- cursor,
714 loro_cursor,
715- selection: None,
716- composition: None,
717- composition_ended_at: None,
718- last_edit: None,
000719 }
720 }
721}
722723-// EditorDocument can't derive Clone because LoroDoc/LoroText/UndoManager don't implement Clone.
724-// This is intentional - the document should be the single source of truth.
725-726-impl Clone for EditorDocument {
727- fn clone(&self) -> Self {
728- // Use snapshot export/import for a complete clone including all containers
729- let snapshot = self.export_snapshot();
730- let mut new_doc =
731- Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset);
732-733- // Copy non-CRDT state
734- new_doc.cursor = self.cursor;
735- new_doc.sync_loro_cursor();
736- new_doc.selection = self.selection;
737- new_doc.composition = self.composition.clone();
738- new_doc.composition_ended_at = self.composition_ended_at;
739- new_doc.last_edit = self.last_edit.clone();
740- new_doc.entry_uri = self.entry_uri.clone();
741- new_doc
742 }
743}
···2//!
3//! Uses Loro CRDT for text storage with built-in undo/redo support.
4//! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration.
5+//!
6+//! # Reactive Architecture
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::cell::RefCell;
14+use std::rc::Rc;
15+16+use dioxus::prelude::*;
17use loro::{
18 ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
19 cursor::{Cursor, Side},
···41/// Contains the document text (backed by Loro CRDT), cursor position,
42/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
43/// schema with CRDT containers for each field.
44+///
45+/// # Reactive Architecture
46+///
47+/// The document itself is NOT wrapped in a Signal. Instead, individual fields
48+/// that need reactivity are wrapped in Signals:
49+/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
50+/// - `last_edit` - triggers paragraph re-renders when content changes
51+///
52+/// Use `use_hook(|| EditorDocument::new(...))` in components, not `use_signal`.
53+///
54+/// # Cloning
55+///
56+/// EditorDocument is cheap to clone - Loro types are Arc-backed handles,
57+/// and Signals are Copy. Closures can capture clones without overhead.
58+#[derive(Clone)]
59pub struct EditorDocument {
60 /// The Loro document containing all editor state.
61 doc: LoroDoc,
6263+ // --- Entry schema containers (Loro handles interior mutability) ---
64 /// Markdown content (maps to entry.content)
65 content: LoroText,
66···85 /// None for new entries that haven't been published yet.
86 entry_uri: Option<AtUri<'static>>,
8788+ // --- Editor state (non-reactive) ---
89 /// Undo manager for the document.
90+ undo_mgr: Rc<RefCell<UndoManager>>,
00009192 /// CRDT-aware cursor that tracks position through remote edits and undo/redo.
93 /// Recreated after our own edits, queried after undo/redo/remote edits.
94 loro_cursor: Option<Cursor>,
9596+ // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
97+ /// Current cursor position. Signal so cursor changes don't dirty content memos.
98+ pub cursor: Signal<CursorState>,
99100+ /// Active selection if any. Signal for same reason as cursor.
101+ pub selection: Signal<Option<Selection>>,
102+103+ /// IME composition state. Signal so composition updates are isolated.
104+ pub composition: Signal<Option<CompositionState>>,
105106 /// Timestamp when the last composition ended.
107 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
108+ pub composition_ended_at: Signal<Option<web_time::Instant>>,
109110 /// Most recent edit info for incremental rendering optimization.
111+ /// Signal so paragraphs memo can subscribe to content changes only.
112+ pub last_edit: Signal<Option<EditInfo>>,
113+114+ /// Pending snap direction for cursor restoration after edits.
115+ /// Set by input handlers, consumed by cursor restoration.
116+ pub pending_snap: Signal<Option<super::offset_map::SnapDirection>>,
117}
118119/// Cursor state including position and affinity.
···150}
151152/// Information about the most recent edit, used for incremental rendering optimization.
153+/// Derives PartialEq so it can be used with Dioxus memos for change detection.
154+#[derive(Clone, Debug, Default, PartialEq)]
155pub struct EditInfo {
156 /// Character offset where the edit occurred
157 pub edit_char_pos: usize,
···200201 /// Create a new editor document with the given content.
202 /// Sets `created_at` to current time.
203+ ///
204+ /// # Note
205+ /// This creates Dioxus Signals for reactive fields. Call from within
206+ /// a component using `use_hook(|| EditorDocument::new(...))`.
207 pub fn new(initial_content: String) -> Self {
208 let doc = LoroDoc::new();
209···245 tags,
246 embeds,
247 entry_uri: None,
248+ undo_mgr: Rc::new(RefCell::new(undo_mgr)),
249+ loro_cursor,
250+ // Reactive editor state - wrapped in Signals
251+ cursor: Signal::new(CursorState {
252 offset: 0,
253 affinity: Affinity::Before,
254+ }),
255+ selection: Signal::new(None),
256+ composition: Signal::new(None),
257+ composition_ended_at: Signal::new(None),
258+ last_edit: Signal::new(None),
259+ pending_snap: Signal::new(None),
260 }
261 }
262···320 }
321322 /// Set the entry title (replaces existing).
323+ /// Takes &self because Loro has interior mutability.
324+ pub fn set_title(&self, new_title: &str) {
325 let current_len = self.title.len_unicode();
326 if current_len > 0 {
327 self.title.delete(0, current_len).ok();
···335 }
336337 /// Set the URL path/slug (replaces existing).
338+ /// Takes &self because Loro has interior mutability.
339+ pub fn set_path(&self, new_path: &str) {
340 let current_len = self.path.len_unicode();
341 if current_len > 0 {
342 self.path.delete(0, current_len).ok();
···350 }
351352 /// Set the created_at timestamp (usually only called once on creation or when loading).
353+ /// Takes &self because Loro has interior mutability.
354+ pub fn set_created_at(&self, datetime: &str) {
355 let current_len = self.created_at.len_unicode();
356 if current_len > 0 {
357 self.created_at.delete(0, current_len).ok();
···385 }
386387 /// Add a tag (if not already present).
388+ /// Takes &self because Loro has interior mutability.
389+ pub fn add_tag(&self, tag: &str) {
390 let existing = self.tags();
391 if !existing.iter().any(|t| t == tag) {
392 self.tags.push(LoroValue::String(tag.into())).ok();
···394 }
395396 /// Remove a tag by value.
397+ /// Takes &self because Loro has interior mutability.
398+ pub fn remove_tag(&self, tag: &str) {
399 let len = self.tags.len();
400 for i in (0..len).rev() {
401 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
···408 }
409410 /// Clear all tags.
411+ /// Takes &self because Loro has interior mutability.
412+ pub fn clear_tags(&self) {
413 let len = self.tags.len();
414 if len > 0 {
415 self.tags.delete(0, len).ok();
···526 let len_before = self.content.len_unicode();
527 let result = self.content.insert(pos, text);
528 let len_after = self.content.len_unicode();
529+ self.last_edit.set(Some(EditInfo {
530 edit_char_pos: pos,
531 inserted_len: len_after.saturating_sub(len_before),
532 deleted_len: 0,
533 contains_newline: text.contains('\n'),
534 in_block_syntax_zone,
535 doc_len_after: len_after,
536+ }));
537 result
538 }
539···543 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos);
544 let result = self.content.push_str(text);
545 let len_after = self.content.len_unicode();
546+ self.last_edit.set(Some(EditInfo {
547 edit_char_pos: pos,
548 inserted_len: text.chars().count(),
549 deleted_len: 0,
550 contains_newline: text.contains('\n'),
551 in_block_syntax_zone,
552 doc_len_after: len_after,
553+ }));
554 result
555 }
556···561 let in_block_syntax_zone = self.is_in_block_syntax_zone(start);
562563 let result = self.content.delete(start, len);
564+ self.last_edit.set(Some(EditInfo {
565 edit_char_pos: start,
566 inserted_len: 0,
567 deleted_len: len,
568 contains_newline,
569 in_block_syntax_zone,
570 doc_len_after: self.content.len_unicode(),
571+ }));
572 result
573 }
574···587 // because: len_after = len_before - deleted + inserted
588 let inserted_len = (len_after + len).saturating_sub(len_before);
589590+ self.last_edit.set(Some(EditInfo {
591 edit_char_pos: start,
592 inserted_len,
593 deleted_len: len,
594 contains_newline: delete_has_newline || text.contains('\n'),
595 in_block_syntax_zone,
596 doc_len_after: len_after,
597+ }));
598 Ok(())
599 }
600···606 // so it tracks through the undo operation
607 self.sync_loro_cursor();
608609+ let result = self.undo_mgr.borrow_mut().undo()?;
610 if result {
611 // After undo, query Loro cursor for new position
612 self.sync_cursor_from_loro();
613+ // Signal content change for re-render
614+ self.last_edit.set(None);
615 }
616 Ok(result)
617 }
···623 // Sync Loro cursor to current position BEFORE redo
624 self.sync_loro_cursor();
625626+ let result = self.undo_mgr.borrow_mut().redo()?;
627 if result {
628 // After redo, query Loro cursor for new position
629 self.sync_cursor_from_loro();
630+ // Signal content change for re-render
631+ self.last_edit.set(None);
632 }
633 Ok(result)
634 }
635636 /// Check if undo is available.
637 pub fn can_undo(&self) -> bool {
638+ self.undo_mgr.borrow().can_undo()
639 }
640641 /// Check if redo is available.
642 pub fn can_redo(&self) -> bool {
643+ self.undo_mgr.borrow().can_redo()
644 }
645646 /// Get a slice of the content text.
···652 /// Sync the Loro cursor to the current cursor.offset position.
653 /// Call this after OUR edits where we know the new cursor position.
654 pub fn sync_loro_cursor(&mut self) {
655+ let offset = self.cursor.read().offset;
656+ self.loro_cursor = self.content.get_cursor(offset, Side::default());
657 }
658659 /// Update cursor.offset from the Loro cursor's tracked position.
···662 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
663 let loro_cursor = self.loro_cursor.as_ref()?;
664 let result = self.doc.get_cursor_pos(loro_cursor).ok()?;
665+ let new_offset = result.current.pos.min(self.len_chars());
666+ self.cursor.with_mut(|c| c.offset = new_offset);
667+ Some(new_offset)
668 }
669670 /// Get the Loro cursor for serialization.
···693 self.doc.state_frontiers()
694 }
695696+ /// Get the last edit info for incremental rendering.
697+ /// Reading this creates a reactive dependency on content changes.
698+ pub fn last_edit(&self) -> Option<EditInfo> {
699+ self.last_edit.read().clone()
700+ }
701+702 /// Create a new EditorDocument from a binary snapshot.
703 /// Falls back to empty document if import fails.
704 ///
···708 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
709 /// happen in real-time; it cannot rebuild history from imported CRDT ops.
710 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
711+ ///
712+ /// # Note
713+ /// This creates Dioxus Signals for reactive fields. Call from within
714+ /// a component using `use_hook`.
715 pub fn from_snapshot(
716 snapshot: &[u8],
717 loro_cursor: Option<Cursor>,
···748 fallback_offset
749 };
750751+ let cursor_state = CursorState {
752 offset: cursor_offset.min(max_offset),
753 affinity: Affinity::Before,
754 };
755756 // If no Loro cursor provided, create one at the restored position
757 let loro_cursor =
758+ loro_cursor.or_else(|| content.get_cursor(cursor_state.offset, Side::default()));
759760 Self {
761 doc,
···766 tags,
767 embeds,
768 entry_uri: None,
769+ undo_mgr: Rc::new(RefCell::new(undo_mgr)),
0770 loro_cursor,
771+ // Reactive editor state - wrapped in Signals
772+ cursor: Signal::new(cursor_state),
773+ selection: Signal::new(None),
774+ composition: Signal::new(None),
775+ composition_ended_at: Signal::new(None),
776+ last_edit: Signal::new(None),
777+ pending_snap: Signal::new(None),
778 }
779 }
780}
781782+impl PartialEq for EditorDocument {
783+ fn eq(&self, _other: &Self) -> bool {
784+ // EditorDocument uses interior mutability, so we can't meaningfully compare.
785+ // Return false to ensure components re-render when passed as props.
786+ false
00000000000000787 }
788}