atproto blogging
1//! Core data structures for the markdown editor.
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 bump `content_changed` Signal to trigger paragraph re-renders
11//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
12
13use dioxus::prelude::*;
14use loro::{
15 Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, VersionVector,
16 cursor::Cursor,
17};
18
19use jacquard::IntoStatic;
20use jacquard::from_json_value;
21use jacquard::smol_str::SmolStr;
22use jacquard::types::string::AtUri;
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;
26use weaver_api::sh_weaver::notebook::entry::Entry;
27use weaver_editor_core::EditorDocument;
28use weaver_editor_core::TextBuffer;
29use weaver_editor_core::UndoManager;
30pub use weaver_editor_core::{
31 Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection,
32};
33use weaver_editor_crdt::LoroTextBuffer;
34
35/// Single source of truth for editor state.
36///
37/// Contains the document text (backed by Loro CRDT via LoroTextBuffer), cursor position,
38/// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry`
39/// schema with CRDT containers for each field.
40///
41/// # Reactive Architecture
42///
43/// The document itself is NOT wrapped in a Signal. Instead, individual fields
44/// that need reactivity are wrapped in Signals:
45/// - `cursor`, `selection`, `composition` - high-frequency, cursor-only updates
46/// - `content_changed` - bumped to trigger paragraph re-renders when content changes
47///
48/// Use `use_hook(|| SignalEditorDocument::new(...))` in components, not `use_signal`.
49///
50/// # Cloning
51///
52/// SignalEditorDocument is cheap to clone - LoroTextBuffer and Loro types are Arc-backed,
53/// and Signals are Copy. Closures can capture clones without overhead.
54#[derive(Clone)]
55pub struct SignalEditorDocument {
56 /// The text buffer wrapping LoroDoc with undo/redo and cursor tracking.
57 /// Access the underlying LoroDoc via `buffer.doc()`.
58 buffer: LoroTextBuffer,
59
60 // --- Entry schema containers (Loro handles interior mutability) ---
61 // These are obtained from buffer.doc() but cached for convenience.
62 /// Entry title (maps to entry.title)
63 title: LoroText,
64
65 /// URL path/slug (maps to entry.path)
66 path: LoroText,
67
68 /// ISO datetime string (maps to entry.createdAt)
69 created_at: LoroText,
70
71 /// Tags list (maps to entry.tags)
72 tags: LoroList,
73
74 /// Embeds container (maps to entry.embeds)
75 /// Contains nested containers: images (LoroList), externals (LoroList), etc.
76 embeds: LoroMap,
77
78 // --- Entry tracking (reactive) ---
79 /// StrongRef to the entry if editing an existing record.
80 /// None for new entries that haven't been published yet.
81 /// Signal so cloned docs share the same state after publish.
82 pub entry_ref: Signal<Option<StrongRef<'static>>>,
83
84 /// AT-URI of the notebook this draft belongs to (for re-publishing)
85 pub notebook_uri: Signal<Option<SmolStr>>,
86
87 // --- Edit sync state (for PDS sync) ---
88 /// StrongRef to the sh.weaver.edit.root record for this edit session.
89 /// None if we haven't synced to PDS yet.
90 pub edit_root: Signal<Option<StrongRef<'static>>>,
91
92 /// StrongRef to the most recent sh.weaver.edit.diff record.
93 /// Used for the `prev` field when creating new diffs.
94 /// None if no diffs have been created yet (only root exists).
95 pub last_diff: Signal<Option<StrongRef<'static>>>,
96
97 /// Version vector at the time of last sync to PDS.
98 /// Used to export only changes since last sync.
99 /// None if never synced.
100 /// Signal so cloned docs share the same sync state.
101 last_synced_version: Signal<Option<VersionVector>>,
102
103 /// Last seen diff URI per collaborator root.
104 /// Maps root URI -> last diff URI we've imported from that root.
105 /// The diff rkey (TID) is time-sortable, so we skip diffs with rkey <= this.
106 pub last_seen_diffs: Signal<std::collections::HashMap<AtUri<'static>, AtUri<'static>>>,
107
108 // --- Reactive editor state (Signal-wrapped for fine-grained updates) ---
109 /// Current cursor position. Signal so cursor changes don't dirty content memos.
110 pub cursor: Signal<CursorState>,
111
112 /// Active selection if any. Signal for same reason as cursor.
113 pub selection: Signal<Option<Selection>>,
114
115 /// IME composition state. Signal so composition updates are isolated.
116 pub composition: Signal<Option<CompositionState>>,
117
118 /// Timestamp when the last composition ended.
119 /// Used for Safari workaround: ignore Enter keydown within 500ms of compositionend.
120 pub composition_ended_at: Signal<Option<web_time::Instant>>,
121
122 /// Bumped when content changes to trigger paragraph re-renders.
123 /// Actual EditInfo is obtained from `buffer.last_edit()`.
124 pub content_changed: Signal<()>,
125
126 /// Pending snap direction for cursor restoration after edits.
127 /// Set by input handlers, consumed by cursor restoration.
128 pub pending_snap: Signal<Option<weaver_editor_core::SnapDirection>>,
129
130 /// Collected refs (wikilinks, AT embeds) from the most recent render.
131 /// Updated by the render pipeline, read by publish for populating records.
132 pub collected_refs: Signal<Vec<weaver_common::ExtractedRef>>,
133}
134
135/// Pre-loaded document state that can be created outside of reactive context.
136///
137/// This struct holds the raw LoroDoc (which is safe outside reactive context)
138/// along with sync state metadata. Use `SignalEditorDocument::from_loaded_state()`
139/// inside a `use_hook` to convert this into a reactive SignalEditorDocument.
140///
141/// Note: Clone is a shallow/reference clone for LoroDoc (Arc-backed).
142/// PartialEq always returns false since we can't meaningfully compare docs.
143#[derive(Clone)]
144pub struct LoadedDocState {
145 /// The Loro document with all content already loaded/merged.
146 pub doc: LoroDoc,
147 /// StrongRef to the entry if editing an existing record.
148 pub entry_ref: Option<StrongRef<'static>>,
149 /// StrongRef to the sh.weaver.edit.root record (for PDS sync).
150 pub edit_root: Option<StrongRef<'static>>,
151 /// StrongRef to the most recent sh.weaver.edit.diff record.
152 pub last_diff: Option<StrongRef<'static>>,
153 /// Version vector of the last known PDS state.
154 /// Used to determine what changes need to be synced.
155 /// None if never synced to PDS.
156 pub synced_version: Option<VersionVector>,
157 /// Last seen diff URIs per collaborator root.
158 /// Used for incremental sync on subsequent refreshes.
159 pub last_seen_diffs: std::collections::HashMap<AtUri<'static>, AtUri<'static>>,
160 /// Pre-resolved embed content fetched during load.
161 /// Avoids embed pop-in on initial render.
162 pub resolved_content: weaver_common::ResolvedContent,
163 /// Notebook URI for re-publishing to the same notebook.
164 pub notebook_uri: Option<SmolStr>,
165}
166
167impl PartialEq for LoadedDocState {
168 fn eq(&self, _other: &Self) -> bool {
169 // LoadedDocState contains LoroDoc which can't be meaningfully compared.
170 // Return false to ensure components re-render when passed as props.
171 false
172 }
173}
174
175impl SignalEditorDocument {
176 /// Create a new editor document with the given content.
177 /// Sets `created_at` to current time.
178 ///
179 /// # Note
180 /// This creates Dioxus Signals for reactive fields. Call from within
181 /// a component using `use_hook(|| SignalEditorDocument::new(...))`.
182 pub fn new(initial_content: String) -> Self {
183 // Create the LoroTextBuffer which owns the LoroDoc
184 let mut buffer = LoroTextBuffer::new();
185 let doc = buffer.doc().clone();
186
187 // Get other containers from the doc
188 let title = doc.get_text("title");
189 let path = doc.get_text("path");
190 let created_at = doc.get_text("created_at");
191 let tags = doc.get_list("tags");
192 let embeds = doc.get_map("embeds");
193
194 // Insert initial content if any
195 if !initial_content.is_empty() {
196 buffer.insert(0, &initial_content);
197 }
198
199 // Set created_at to current time (ISO 8601)
200 let now = Self::current_datetime_string();
201 created_at
202 .insert(0, &now)
203 .expect("failed to set created_at");
204
205 Self {
206 buffer,
207 title,
208 path,
209 created_at,
210 tags,
211 embeds,
212 entry_ref: Signal::new(None),
213 notebook_uri: Signal::new(None),
214 edit_root: Signal::new(None),
215 last_diff: Signal::new(None),
216 last_synced_version: Signal::new(None),
217 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
218 cursor: Signal::new(CursorState {
219 offset: 0,
220 affinity: Affinity::Before,
221 }),
222 selection: Signal::new(None),
223 composition: Signal::new(None),
224 composition_ended_at: Signal::new(None),
225 content_changed: Signal::new(()),
226 pending_snap: Signal::new(None),
227 collected_refs: Signal::new(Vec::new()),
228 }
229 }
230
231 /// Create a SignalEditorDocument from a fetched Entry.
232 ///
233 /// MUST be called from within a reactive context (e.g., `use_hook`) to
234 /// properly initialize Dioxus Signals.
235 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self {
236 let mut doc = Self::new(entry.content.to_string());
237
238 // Set metadata
239 doc.set_title(&entry.title);
240 doc.set_path(&entry.path);
241 doc.set_created_at(&entry.created_at.to_string());
242
243 // Add tags
244 if let Some(ref tags) = entry.tags {
245 for tag in tags.iter() {
246 doc.add_tag(tag.as_ref());
247 }
248 }
249
250 // Add existing images (no published_blob_uri needed - they're already in the entry)
251 if let Some(ref embeds) = entry.embeds {
252 if let Some(ref images) = embeds.images {
253 for img in &images.images {
254 doc.add_image(&img.clone().into_static(), None);
255 }
256 }
257
258 if let Some(ref records) = embeds.records {
259 for record in &records.records {
260 doc.add_record(&record.clone().into_static());
261 }
262 }
263 }
264
265 // Set the entry_ref so subsequent publishes update this record
266 doc.set_entry_ref(Some(entry_ref));
267
268 doc
269 }
270
271 /// Generate current datetime as ISO 8601 string.
272 #[cfg(target_family = "wasm")]
273 fn current_datetime_string() -> String {
274 js_sys::Date::new_0()
275 .to_iso_string()
276 .as_string()
277 .unwrap_or_default()
278 }
279
280 #[cfg(not(target_family = "wasm"))]
281 fn current_datetime_string() -> String {
282 // Fallback for non-wasm (tests, etc.)
283 chrono::Utc::now().to_rfc3339()
284 }
285
286 /// Get the underlying LoroText for read operations on content.
287 pub fn loro_text(&self) -> &LoroText {
288 self.buffer.content()
289 }
290
291 /// Get the underlying LoroDoc for subscriptions and advanced operations.
292 pub fn loro_doc(&self) -> &LoroDoc {
293 self.buffer.doc()
294 }
295
296 /// Get direct access to the LoroTextBuffer.
297 pub fn buffer(&self) -> &LoroTextBuffer {
298 &self.buffer
299 }
300
301 /// Get mutable access to the LoroTextBuffer.
302 pub fn buffer_mut(&mut self) -> &mut LoroTextBuffer {
303 &mut self.buffer
304 }
305
306 // --- Content accessors ---
307
308 /// Get the markdown content as a string.
309 pub fn content(&self) -> String {
310 weaver_editor_core::TextBuffer::to_string(&self.buffer)
311 }
312
313 /// Convert the document content to a string (alias for content()).
314 pub fn to_string(&self) -> String {
315 weaver_editor_core::TextBuffer::to_string(&self.buffer)
316 }
317
318 /// Get the length of the content in characters.
319 pub fn len_chars(&self) -> usize {
320 weaver_editor_core::TextBuffer::len_chars(&self.buffer)
321 }
322
323 /// Get the length of the content in UTF-8 bytes.
324 pub fn len_bytes(&self) -> usize {
325 weaver_editor_core::TextBuffer::len_bytes(&self.buffer)
326 }
327
328 /// Get the length of the content in UTF-16 code units.
329 pub fn len_utf16(&self) -> usize {
330 self.buffer.content().len_utf16()
331 }
332
333 /// Check if the content is empty.
334 pub fn is_empty(&self) -> bool {
335 weaver_editor_core::TextBuffer::len_chars(&self.buffer) == 0
336 }
337
338 // --- Entry metadata accessors ---
339
340 /// Get the entry title.
341 pub fn title(&self) -> String {
342 self.title.to_string()
343 }
344
345 /// Set the entry title (replaces existing).
346 /// Takes &self because Loro has interior mutability.
347 pub fn set_title(&self, new_title: &str) {
348 let current_len = self.title.len_unicode();
349 if current_len > 0 {
350 self.title.delete(0, current_len).ok();
351 }
352 self.title.insert(0, new_title).ok();
353 }
354
355 /// Get the URL path/slug.
356 pub fn path(&self) -> String {
357 self.path.to_string()
358 }
359
360 /// Set the URL path/slug (replaces existing).
361 /// Takes &self because Loro has interior mutability.
362 pub fn set_path(&self, new_path: &str) {
363 let current_len = self.path.len_unicode();
364 if current_len > 0 {
365 self.path.delete(0, current_len).ok();
366 }
367 self.path.insert(0, new_path).ok();
368 }
369
370 /// Get the created_at timestamp (ISO 8601 string).
371 pub fn created_at(&self) -> String {
372 self.created_at.to_string()
373 }
374
375 /// Set the created_at timestamp (usually only called once on creation or when loading).
376 /// Takes &self because Loro has interior mutability.
377 pub fn set_created_at(&self, datetime: &str) {
378 let current_len = self.created_at.len_unicode();
379 if current_len > 0 {
380 self.created_at.delete(0, current_len).ok();
381 }
382 self.created_at.insert(0, datetime).ok();
383 }
384
385 // --- Entry ref accessors ---
386
387 /// Get the StrongRef to the entry if editing an existing record.
388 pub fn entry_ref(&self) -> Option<StrongRef<'static>> {
389 self.entry_ref.read().clone()
390 }
391
392 /// Set the StrongRef when editing an existing entry.
393 pub fn set_entry_ref(&mut self, entry: Option<StrongRef<'static>>) {
394 self.entry_ref.set(entry);
395 }
396
397 /// Get the notebook URI if this draft belongs to a notebook.
398 pub fn notebook_uri(&self) -> Option<SmolStr> {
399 self.notebook_uri.read().clone()
400 }
401
402 /// Set the notebook URI for re-publishing to the same notebook.
403 pub fn set_notebook_uri(&mut self, uri: Option<SmolStr>) {
404 self.notebook_uri.set(uri);
405 }
406
407 // --- Tags accessors ---
408
409 /// Get all tags as a vector of strings.
410 pub fn tags(&self) -> Vec<String> {
411 let len = self.tags.len();
412 (0..len)
413 .filter_map(|i| match self.tags.get(i)? {
414 loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()),
415 _ => None,
416 })
417 .collect()
418 }
419
420 /// Add a tag (if not already present).
421 /// Takes &self because Loro has interior mutability.
422 pub fn add_tag(&self, tag: &str) {
423 let existing = self.tags();
424 if !existing.iter().any(|t| t == tag) {
425 self.tags.push(LoroValue::String(tag.into())).ok();
426 }
427 }
428
429 /// Remove a tag by value.
430 /// Takes &self because Loro has interior mutability.
431 pub fn remove_tag(&self, tag: &str) {
432 let len = self.tags.len();
433 for i in (0..len).rev() {
434 if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) {
435 if s.as_str() == tag {
436 self.tags.delete(i, 1).ok();
437 break;
438 }
439 }
440 }
441 }
442
443 /// Clear all tags.
444 /// Takes &self because Loro has interior mutability.
445 pub fn clear_tags(&self) {
446 let len = self.tags.len();
447 if len > 0 {
448 self.tags.delete(0, len).ok();
449 }
450 }
451
452 // --- Images accessors ---
453
454 /// Get the images LoroList from embeds, creating it if needed.
455 fn get_images_list(&self) -> LoroList {
456 self.embeds
457 .get_or_create_container("images", LoroList::new())
458 .unwrap()
459 }
460
461 /// Get all images as a Vec.
462 pub fn images(&self) -> Vec<EditorImage> {
463 let images_list = self.get_images_list();
464 let mut result = Vec::new();
465
466 for i in 0..images_list.len() {
467 if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) {
468 result.push(editor_image);
469 }
470 }
471
472 result
473 }
474
475 /// Convert a LoroValue at the given index to an EditorImage.
476 fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option<EditorImage> {
477 let value = list.get(index)?;
478
479 // Extract LoroValue from ValueOrContainer
480 let loro_value = value.as_value()?;
481
482 // Convert LoroValue to serde_json::Value
483 let json = loro_value.to_json_value();
484
485 // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data
486 let image: Image<'static> = from_json_value::<Image>(json).ok()?;
487
488 // Extract our tracking field from extra_data
489 let published_blob_uri = image
490 .extra_data
491 .as_ref()
492 .and_then(|m| m.get("publishedBlobUri"))
493 .and_then(|d| d.as_str())
494 .and_then(|s| AtUri::new(s).ok())
495 .map(|uri| uri.into_static());
496
497 Some(EditorImage {
498 image,
499 published_blob_uri,
500 })
501 }
502
503 /// Add an image to the embeds.
504 /// The Image is serialized to JSON with our publishedBlobUri added.
505 pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) {
506 // Serialize the Image to serde_json::Value
507 let mut json = serde_json::to_value(image).expect("Image serializes");
508
509 // Add our tracking field (not part of lexicon, stored in extra_data on deserialize)
510 if let Some(uri) = published_blob_uri {
511 json.as_object_mut()
512 .unwrap()
513 .insert("publishedBlobUri".into(), uri.as_str().into());
514 }
515
516 // Insert into the images list
517 let images_list = self.get_images_list();
518 images_list.push(json).ok();
519 }
520
521 pub fn add_record(&mut self, record: &RecordEmbed<'_>) {
522 // Serialize the Record embed to serde_json::Value
523 let json = serde_json::to_value(record).expect("Record serializes");
524
525 // Insert into the record list
526 let record_list = self.get_records_list();
527 record_list.push(json).ok();
528 }
529
530 pub fn remove_record(&mut self, index: usize) {
531 let record_list = self.get_records_list();
532 if index < record_list.len() {
533 record_list.delete(index, 1).ok();
534 }
535 }
536
537 /// Remove an image by index.
538 pub fn remove_image(&mut self, index: usize) {
539 let images_list = self.get_images_list();
540 if index < images_list.len() {
541 images_list.delete(index, 1).ok();
542 }
543 }
544
545 /// Get a single image by index.
546 pub fn get_image(&self, index: usize) -> Option<EditorImage> {
547 let images_list = self.get_images_list();
548 self.loro_value_to_editor_image(&images_list, index)
549 }
550
551 /// Get the number of images.
552 pub fn images_len(&self) -> usize {
553 self.get_images_list().len()
554 }
555
556 /// Update the alt text of an image at the given index.
557 pub fn update_image_alt(&mut self, index: usize, alt: &str) {
558 let images_list = self.get_images_list();
559 if let Some(value) = images_list.get(index) {
560 if let Some(loro_value) = value.as_value() {
561 let mut json = loro_value.to_json_value();
562 if let Some(obj) = json.as_object_mut() {
563 obj.insert("alt".into(), alt.into());
564 // Replace the entire value at this index
565 images_list.delete(index, 1).ok();
566 images_list.insert(index, json).ok();
567 }
568 }
569 }
570 }
571
572 // --- Record embed methods ---
573
574 /// Get the records LoroList from embeds, creating it if needed.
575 fn get_records_list(&self) -> LoroList {
576 self.embeds
577 .get_or_create_container("records", LoroList::new())
578 .unwrap()
579 }
580
581 /// Get all record embeds as a Vec.
582 pub fn record_embeds(&self) -> Vec<RecordEmbed<'static>> {
583 let records_list = self.get_records_list();
584 let mut result = Vec::new();
585
586 for i in 0..records_list.len() {
587 if let Some(record_embed) = self.loro_value_to_record_embed(&records_list, i) {
588 result.push(record_embed);
589 }
590 }
591
592 result
593 }
594
595 /// Convert a LoroValue at the given index to a RecordEmbed.
596 fn loro_value_to_record_embed(
597 &self,
598 list: &LoroList,
599 index: usize,
600 ) -> Option<RecordEmbed<'static>> {
601 let value = list.get(index)?;
602 let loro_value = value.as_value()?;
603 let json = loro_value.to_json_value();
604 from_json_value::<RecordEmbed>(json)
605 .ok()
606 .map(|r| r.into_static())
607 }
608
609 /// Insert text into content and bump content_changed for re-rendering.
610 /// Edit info is tracked automatically by the buffer.
611 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> {
612 self.buffer.insert(pos, text);
613 self.content_changed.set(());
614 Ok(())
615 }
616
617 /// Push text to end of content. Faster than insert for appending.
618 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> {
619 let pos = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
620 self.buffer.insert(pos, text);
621 self.content_changed.set(());
622 Ok(())
623 }
624
625 /// Remove text range from content and bump content_changed for re-rendering.
626 /// Edit info is tracked automatically by the buffer.
627 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> {
628 self.buffer.delete(start..start + len);
629 self.content_changed.set(());
630 Ok(())
631 }
632
633 /// Replace text in content (atomic splice) and bump content_changed.
634 /// Edit info is tracked automatically by the buffer.
635 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> {
636 self.buffer.replace(start..start + len, text);
637 self.content_changed.set(());
638 Ok(())
639 }
640
641 /// Undo the last operation. Automatically updates cursor position.
642 pub fn undo(&mut self) -> LoroResult<bool> {
643 // Sync Loro cursor to current position BEFORE undo
644 // so it tracks through the undo operation
645 self.sync_loro_cursor();
646
647 let result = self.buffer.undo();
648 if result {
649 // After undo, query Loro cursor for new position
650 self.sync_cursor_from_loro();
651 // Signal content change for re-render
652 self.content_changed.set(());
653 }
654 Ok(result)
655 }
656
657 /// Redo the last undone operation. Automatically updates cursor position.
658 pub fn redo(&mut self) -> LoroResult<bool> {
659 // Sync Loro cursor to current position BEFORE redo
660 self.sync_loro_cursor();
661
662 let result = self.buffer.redo();
663 if result {
664 // After redo, query Loro cursor for new position
665 self.sync_cursor_from_loro();
666 // Signal content change for re-render
667 self.content_changed.set(());
668 }
669 Ok(result)
670 }
671
672 /// Check if undo is available.
673 pub fn can_undo(&self) -> bool {
674 UndoManager::can_undo(&self.buffer)
675 }
676
677 /// Check if redo is available.
678 pub fn can_redo(&self) -> bool {
679 UndoManager::can_redo(&self.buffer)
680 }
681
682 /// Get a slice of the content text.
683 /// Returns None if the range is invalid.
684 pub fn slice(&self, start: usize, end: usize) -> Option<SmolStr> {
685 self.buffer.slice(start..end)
686 }
687
688 /// Sync the Loro cursor to the current cursor.offset position.
689 /// Call this after OUR edits where we know the new cursor position.
690 pub fn sync_loro_cursor(&mut self) {
691 let offset = self.cursor.read().offset;
692 tracing::debug!(offset, "sync_loro_cursor: saving cursor position to Loro");
693 self.buffer.sync_cursor(offset);
694 }
695
696 /// Update cursor.offset from the Loro cursor's tracked position.
697 /// Call this after undo/redo or remote edits where the position may have shifted.
698 /// Returns the new offset, or None if the cursor couldn't be resolved.
699 pub fn sync_cursor_from_loro(&mut self) -> Option<usize> {
700 let old_offset = self.cursor.read().offset;
701 let new_offset = self.buffer.resolve_cursor()?;
702 let jump = if new_offset > old_offset {
703 new_offset - old_offset
704 } else {
705 old_offset - new_offset
706 };
707 if jump > 100 {
708 tracing::warn!(
709 old_offset,
710 new_offset,
711 jump,
712 "sync_cursor_from_loro: LARGE CURSOR JUMP detected"
713 );
714 }
715 tracing::debug!(
716 old_offset,
717 new_offset,
718 "sync_cursor_from_loro: updating cursor from Loro"
719 );
720 self.cursor.with_mut(|c| c.offset = new_offset);
721 Some(new_offset)
722 }
723
724 /// Get the Loro cursor for serialization.
725 pub fn loro_cursor(&self) -> Option<Cursor> {
726 self.buffer.loro_cursor()
727 }
728
729 /// Set the Loro cursor (used when restoring from storage).
730 pub fn set_loro_cursor(&mut self, cursor: Option<Cursor>) {
731 tracing::debug!(has_cursor = cursor.is_some(), "set_loro_cursor called");
732 self.buffer.set_loro_cursor(cursor);
733 // Sync cursor.offset from the restored Loro cursor
734 if self.buffer.loro_cursor().is_some() {
735 self.sync_cursor_from_loro();
736 }
737 }
738
739 /// Export the document as a binary snapshot.
740 /// This captures all CRDT state including undo history.
741 pub fn export_snapshot(&self) -> Vec<u8> {
742 self.buffer.export_snapshot()
743 }
744
745 /// Get the current state frontiers for change detection.
746 /// Frontiers represent the "version" of the document state.
747 pub fn state_frontiers(&self) -> Frontiers {
748 self.buffer.doc().state_frontiers()
749 }
750
751 /// Get the current version vector.
752 pub fn version_vector(&self) -> VersionVector {
753 self.buffer.version()
754 }
755
756 /// Get the last edit info for incremental rendering.
757 /// This comes from the buffer's internal tracking.
758 pub fn last_edit(&self) -> Option<EditInfo> {
759 self.buffer.last_edit()
760 }
761
762 /// Bump the content_changed signal to trigger re-renders.
763 /// Call this after remote imports or other external content changes.
764 pub fn notify_content_changed(&mut self) {
765 self.content_changed.set(());
766 }
767
768 // --- Collected refs accessors ---
769
770 /// Update collected refs from the render pipeline.
771 pub fn set_collected_refs(&mut self, refs: Vec<weaver_common::ExtractedRef>) {
772 self.collected_refs.set(refs);
773 }
774
775 /// Get AT URIs from collected embeds for populating entry.embeds.records.
776 ///
777 /// Filters for AtEmbed refs and parses to AtUri. Invalid URIs are skipped.
778 pub fn at_embed_uris(&self) -> Vec<AtUri<'static>> {
779 self.collected_refs
780 .read()
781 .iter()
782 .filter_map(|r| match r {
783 weaver_common::ExtractedRef::AtEmbed { uri, .. } => {
784 AtUri::new(uri).ok().map(|u| u.into_static())
785 }
786 _ => None,
787 })
788 .collect()
789 }
790
791 // --- Edit sync methods ---
792
793 /// Get the edit root StrongRef if set.
794 pub fn edit_root(&self) -> Option<StrongRef<'static>> {
795 self.edit_root.read().clone()
796 }
797
798 /// Set the edit root after creating or finding the root record.
799 pub fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
800 self.edit_root.set(root);
801 }
802
803 /// Get the last diff StrongRef if set.
804 pub fn last_diff(&self) -> Option<StrongRef<'static>> {
805 self.last_diff.read().clone()
806 }
807
808 /// Set the last diff after creating a new diff record.
809 pub fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
810 self.last_diff.set(diff);
811 }
812
813 /// Get the last seen diff URI for a collaborator root.
814 pub fn last_seen_diff_for_root(&self, root_uri: &AtUri<'_>) -> Option<AtUri<'static>> {
815 self.last_seen_diffs
816 .read()
817 .get(&root_uri.clone().into_static())
818 .cloned()
819 }
820
821 /// Update the last seen diff for a collaborator root.
822 pub fn set_last_seen_diff(&mut self, root_uri: AtUri<'static>, diff_uri: AtUri<'static>) {
823 self.last_seen_diffs.write().insert(root_uri, diff_uri);
824 }
825
826 /// Check if there are unsynced changes since the last PDS sync.
827 pub fn has_unsynced_changes(&self) -> bool {
828 match &*self.last_synced_version.read() {
829 Some(synced_vv) => self.buffer.version() != *synced_vv,
830 None => true, // Never synced, so there are changes
831 }
832 }
833
834 /// Export updates since the last sync.
835 /// Returns None if there are no changes to export.
836 /// After successful upload, call `mark_synced()` to update the sync marker.
837 pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
838 let from_vv = self.last_synced_version.read().clone().unwrap_or_default();
839 self.buffer.export_updates_since(&from_vv)
840 }
841
842 /// Mark the current state as synced.
843 /// Call this after successfully uploading a diff to the PDS.
844 pub fn mark_synced(&mut self) {
845 self.last_synced_version.set(Some(self.buffer.version()));
846 }
847
848 /// Import updates from a PDS diff blob.
849 /// Used when loading edit history from the PDS.
850 pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> {
851 let len_before = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
852 let vv_before = self.buffer.version();
853
854 self.buffer
855 .import(updates)
856 .map_err(|e| loro::LoroError::DecodeError(e.to_string().into()))?;
857
858 let len_after = weaver_editor_core::TextBuffer::len_chars(&self.buffer);
859 let vv_after = self.buffer.version();
860 let vv_changed = vv_before != vv_after;
861 let len_changed = len_before != len_after;
862
863 tracing::debug!(
864 len_before,
865 len_after,
866 len_changed,
867 vv_changed,
868 "import_updates: merge result"
869 );
870
871 // Only trigger re-render if something actually changed
872 if vv_changed {
873 self.content_changed.set(());
874 }
875 Ok(())
876 }
877
878 /// Export updates since the given version vector.
879 /// Used for real-time P2P sync where we track broadcast version separately from PDS sync.
880 pub fn export_updates_from(&self, from_vv: &VersionVector) -> Option<Vec<u8>> {
881 self.buffer.export_updates_since(from_vv)
882 }
883
884 /// Set the sync state when loading from PDS.
885 /// This sets the version marker to the current state so we don't
886 /// re-upload what we just downloaded.
887 pub fn set_synced_from_pds(
888 &mut self,
889 edit_root: StrongRef<'static>,
890 last_diff: Option<StrongRef<'static>>,
891 ) {
892 self.edit_root.set(Some(edit_root));
893 self.last_diff.set(last_diff);
894 self.last_synced_version.set(Some(self.buffer.version()));
895 }
896
897 /// Create a new SignalEditorDocument from a binary snapshot.
898 /// Falls back to empty document if import fails.
899 ///
900 /// If `loro_cursor` is provided, it will be used to restore the cursor position.
901 /// Otherwise, falls back to `fallback_offset`.
902 ///
903 /// Note: Undo/redo is session-only. The UndoManager tracks operations as they
904 /// happen in real-time; it cannot rebuild history from imported CRDT ops.
905 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`.
906 ///
907 /// # Note
908 /// This creates Dioxus Signals for reactive fields. Call from within
909 /// a component using `use_hook`.
910 pub fn from_snapshot(
911 snapshot: &[u8],
912 loro_cursor: Option<Cursor>,
913 fallback_offset: usize,
914 ) -> Self {
915 // Create buffer from snapshot
916 let buffer = if snapshot.is_empty() {
917 LoroTextBuffer::new()
918 } else {
919 match LoroTextBuffer::from_snapshot(snapshot) {
920 Ok(buf) => buf,
921 Err(e) => {
922 tracing::warn!("Failed to import snapshot: {:?}, creating empty doc", e);
923 LoroTextBuffer::new()
924 }
925 }
926 };
927
928 let doc = buffer.doc().clone();
929
930 // Get other containers from the doc
931 let title = doc.get_text("title");
932 let path = doc.get_text("path");
933 let created_at = doc.get_text("created_at");
934 let tags = doc.get_list("tags");
935 let embeds = doc.get_map("embeds");
936
937 // Try to restore cursor from Loro cursor, fall back to offset
938 let max_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
939 let cursor_offset = if let Some(ref lc) = loro_cursor {
940 doc.get_cursor_pos(lc)
941 .map(|r| r.current.pos)
942 .unwrap_or(fallback_offset)
943 } else {
944 fallback_offset
945 };
946
947 let cursor_state = CursorState {
948 offset: cursor_offset.min(max_offset),
949 affinity: Affinity::Before,
950 };
951
952 // Set up the Loro cursor
953 let buffer = buffer;
954 if let Some(lc) = loro_cursor {
955 buffer.set_loro_cursor(Some(lc));
956 } else {
957 buffer.sync_cursor(cursor_state.offset);
958 }
959
960 Self {
961 buffer,
962 title,
963 path,
964 created_at,
965 tags,
966 embeds,
967 entry_ref: Signal::new(None),
968 notebook_uri: Signal::new(None),
969 edit_root: Signal::new(None),
970 last_diff: Signal::new(None),
971 last_synced_version: Signal::new(None),
972 last_seen_diffs: Signal::new(std::collections::HashMap::new()),
973 cursor: Signal::new(cursor_state),
974 selection: Signal::new(None),
975 composition: Signal::new(None),
976 composition_ended_at: Signal::new(None),
977 content_changed: Signal::new(()),
978 pending_snap: Signal::new(None),
979 collected_refs: Signal::new(Vec::new()),
980 }
981 }
982
983 /// Create a SignalEditorDocument from pre-loaded state.
984 ///
985 /// Use this when loading from PDS/localStorage merge outside reactive context.
986 /// The `LoadedDocState` contains a pre-merged LoroDoc; this method wraps it
987 /// with the reactive Signals needed for the editor UI.
988 ///
989 /// # Note
990 /// This creates Dioxus Signals. Call from within a component using `use_hook`.
991 pub fn from_loaded_state(state: LoadedDocState) -> Self {
992 // Create buffer from the loaded doc
993 let buffer = LoroTextBuffer::from_doc(state.doc.clone(), "content");
994 let doc = buffer.doc().clone();
995
996 // Get other containers from the doc
997 let title = doc.get_text("title");
998 let path = doc.get_text("path");
999 let created_at = doc.get_text("created_at");
1000 let tags = doc.get_list("tags");
1001 let embeds = doc.get_map("embeds");
1002
1003 // Position cursor at end of content
1004 let cursor_offset = weaver_editor_core::TextBuffer::len_chars(&buffer);
1005 let cursor_state = CursorState {
1006 offset: cursor_offset,
1007 affinity: Affinity::Before,
1008 };
1009
1010 // Set up the Loro cursor
1011 let buffer = buffer;
1012 buffer.sync_cursor(cursor_offset);
1013
1014 Self {
1015 buffer,
1016 title,
1017 path,
1018 created_at,
1019 tags,
1020 embeds,
1021 entry_ref: Signal::new(state.entry_ref),
1022 notebook_uri: Signal::new(state.notebook_uri),
1023 edit_root: Signal::new(state.edit_root),
1024 last_diff: Signal::new(state.last_diff),
1025 // Use the synced version from state (tracks the PDS version vector)
1026 last_synced_version: Signal::new(state.synced_version),
1027 last_seen_diffs: Signal::new(state.last_seen_diffs),
1028 cursor: Signal::new(cursor_state),
1029 selection: Signal::new(None),
1030 composition: Signal::new(None),
1031 composition_ended_at: Signal::new(None),
1032 content_changed: Signal::new(()),
1033 pending_snap: Signal::new(None),
1034 collected_refs: Signal::new(Vec::new()),
1035 }
1036 }
1037}
1038
1039impl PartialEq for SignalEditorDocument {
1040 fn eq(&self, _other: &Self) -> bool {
1041 // SignalEditorDocument uses interior mutability, so we can't meaningfully compare.
1042 // Return false to ensure components re-render when passed as props.
1043 false
1044 }
1045}
1046
1047impl weaver_editor_crdt::CrdtDocument for SignalEditorDocument {
1048 fn export_snapshot(&self) -> Vec<u8> {
1049 self.export_snapshot()
1050 }
1051
1052 fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
1053 self.export_updates_since_sync()
1054 }
1055
1056 fn import(&mut self, data: &[u8]) -> Result<(), weaver_editor_crdt::CrdtError> {
1057 self.import_updates(data)
1058 .map_err(|e| weaver_editor_crdt::CrdtError::Import(e.to_string()))
1059 }
1060
1061 fn version(&self) -> VersionVector {
1062 self.version_vector()
1063 }
1064
1065 fn edit_root(&self) -> Option<StrongRef<'static>> {
1066 SignalEditorDocument::edit_root(self)
1067 }
1068
1069 fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
1070 SignalEditorDocument::set_edit_root(self, root);
1071 }
1072
1073 fn last_diff(&self) -> Option<StrongRef<'static>> {
1074 SignalEditorDocument::last_diff(self)
1075 }
1076
1077 fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
1078 SignalEditorDocument::set_last_diff(self, diff);
1079 }
1080
1081 fn mark_synced(&mut self) {
1082 SignalEditorDocument::mark_synced(self);
1083 }
1084
1085 fn has_unsynced_changes(&self) -> bool {
1086 SignalEditorDocument::has_unsynced_changes(self)
1087 }
1088}
1089
1090impl EditorDocument for SignalEditorDocument {
1091 type Buffer = LoroTextBuffer;
1092
1093 fn buffer(&self) -> &Self::Buffer {
1094 &self.buffer
1095 }
1096
1097 fn buffer_mut(&mut self) -> &mut Self::Buffer {
1098 &mut self.buffer
1099 }
1100
1101 fn cursor(&self) -> CursorState {
1102 *self.cursor.read()
1103 }
1104
1105 fn set_cursor(&mut self, cursor: CursorState) {
1106 self.cursor.set(cursor);
1107 }
1108
1109 fn selection(&self) -> Option<Selection> {
1110 self.selection.read().clone()
1111 }
1112
1113 fn set_selection(&mut self, selection: Option<Selection>) {
1114 self.selection.set(selection);
1115 }
1116
1117 fn last_edit(&self) -> Option<EditInfo> {
1118 self.buffer.last_edit()
1119 }
1120
1121 fn set_last_edit(&mut self, _edit: Option<EditInfo>) {
1122 // Buffer tracks edit info internally. We use this hook to
1123 // bump content_changed for reactive re-rendering.
1124 self.content_changed.set(());
1125 }
1126
1127 fn composition(&self) -> Option<CompositionState> {
1128 self.composition.read().clone()
1129 }
1130
1131 fn set_composition(&mut self, composition: Option<CompositionState>) {
1132 self.composition.set(composition);
1133 }
1134
1135 fn composition_ended_at(&self) -> Option<web_time::Instant> {
1136 *self.composition_ended_at.read()
1137 }
1138
1139 fn set_composition_ended_now(&mut self) {
1140 self.composition_ended_at.set(Some(web_time::Instant::now()));
1141 }
1142
1143 fn undo(&mut self) -> bool {
1144 // Sync Loro cursor to current position BEFORE undo
1145 // so it tracks through the undo operation.
1146 self.sync_loro_cursor();
1147
1148 let result = self.buffer.undo();
1149 if result {
1150 // After undo, query Loro cursor for new position.
1151 self.sync_cursor_from_loro();
1152 // Signal content change for re-render.
1153 self.content_changed.set(());
1154 }
1155 result
1156 }
1157
1158 fn redo(&mut self) -> bool {
1159 // Sync Loro cursor to current position BEFORE redo.
1160 self.sync_loro_cursor();
1161
1162 let result = self.buffer.redo();
1163 if result {
1164 // After redo, query Loro cursor for new position.
1165 self.sync_cursor_from_loro();
1166 // Signal content change for re-render.
1167 self.content_changed.set(());
1168 }
1169 result
1170 }
1171
1172 fn pending_snap(&self) -> Option<weaver_editor_core::SnapDirection> {
1173 *self.pending_snap.read()
1174 }
1175
1176 fn set_pending_snap(&mut self, snap: Option<weaver_editor_core::SnapDirection>) {
1177 self.pending_snap.set(snap);
1178 }
1179}