···1266/// This handles keyboard shortcuts only. Text input and deletion
1267/// are handled by beforeinput. Navigation (arrows, etc.) is passed
1268/// through to the browser.
1269-///
1270-/// # Arguments
1271-/// * `doc` - The editor document
1272-/// * `config` - Keybinding configuration
1273-/// * `combo` - The key combination from the keyboard event
1274-/// * `range` - Current cursor position / selection range
1275-///
1276-/// # Returns
1277-/// Whether the event was handled.
1278pub fn handle_keydown_with_bindings(
1279 doc: &mut EditorDocument,
1280 config: &KeybindingConfig,
···1266/// This handles keyboard shortcuts only. Text input and deletion
1267/// are handled by beforeinput. Navigation (arrows, etc.) is passed
1268/// through to the browser.
0000000001269pub fn handle_keydown_with_bindings(
1270 doc: &mut EditorDocument,
1271 config: &KeybindingConfig,
-12
crates/weaver-app/src/components/editor/cursor.rs
···13use wasm_bindgen::JsCast;
1415/// Restore cursor position in the DOM after re-render.
16-///
17-/// # Arguments
18-/// - `char_offset`: Cursor position as char offset in document
19-/// - `offset_map`: Mappings from source to DOM positions
20-/// - `editor_id`: DOM ID of the contenteditable element
21-/// - `snap_direction`: Optional direction hint for snapping from invisible content
22-///
23-/// # Algorithm
24-/// 1. Find offset mapping containing char_offset
25-/// 2. Get DOM node by mapping.node_id
26-/// 3. Walk text nodes to find UTF-16 position
27-/// 4. Set cursor with Selection API
28#[cfg(all(target_family = "wasm", target_os = "unknown"))]
29pub fn restore_cursor_position(
30 char_offset: usize,
···13use wasm_bindgen::JsCast;
1415/// Restore cursor position in the DOM after re-render.
00000000000016#[cfg(all(target_family = "wasm", target_os = "unknown"))]
17pub fn restore_cursor_position(
18 char_offset: usize,
···319 ///
320 /// MUST be called from within a reactive context (e.g., `use_hook`) to
321 /// properly initialize Dioxus Signals.
322- ///
323- /// # Arguments
324- /// * `entry` - The entry record fetched from PDS
325- /// * `entry_ref` - StrongRef to the entry (URI + CID)
326 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self {
327 let mut doc = Self::new(entry.content.to_string());
328···691 Ok(())
692 }
693694- /// Undo the last operation.
695- /// Returns true if an undo was performed.
696- /// Automatically updates cursor position from the Loro cursor.
697 pub fn undo(&mut self) -> LoroResult<bool> {
698 // Sync Loro cursor to current position BEFORE undo
699 // so it tracks through the undo operation
···709 Ok(result)
710 }
711712- /// Redo the last undone operation.
713- /// Returns true if a redo was performed.
714- /// Automatically updates cursor position from the Loro cursor.
715 pub fn redo(&mut self) -> LoroResult<bool> {
716 // Sync Loro cursor to current position BEFORE redo
717 self.sync_loro_cursor();
···319 ///
320 /// MUST be called from within a reactive context (e.g., `use_hook`) to
321 /// properly initialize Dioxus Signals.
0000322 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self {
323 let mut doc = Self::new(entry.content.to_string());
324···687 Ok(())
688 }
689690+ /// Undo the last operation. Automatically updates cursor position.
00691 pub fn undo(&mut self) -> LoroResult<bool> {
692 // Sync Loro cursor to current position BEFORE undo
693 // so it tracks through the undo operation
···703 Ok(result)
704 }
705706+ /// Redo the last undone operation. Automatically updates cursor position.
00707 pub fn redo(&mut self) -> LoroResult<bool> {
708 // Sync Loro cursor to current position BEFORE redo
709 self.sync_loro_cursor();
···181/// If the position is already valid, returns it directly. Otherwise,
182/// searches in the preferred direction first, falling back to the other
183/// direction if needed.
184-///
185-/// # Arguments
186-/// - `offset_map`: The offset mappings for the paragraph
187-/// - `char_offset`: The target char offset
188-/// - `preferred_direction`: Which direction to search first when snapping
189-///
190-/// # Returns
191-/// The snapped position, or None if no valid position exists.
192pub fn find_nearest_valid_position(
193 offset_map: &[OffsetMapping],
194 char_offset: usize,
···181/// If the position is already valid, returns it directly. Otherwise,
182/// searches in the preferred direction first, falling back to the other
183/// direction if needed.
00000000184pub fn find_nearest_valid_position(
185 offset_map: &[OffsetMapping],
186 char_offset: usize,
···62}
6364/// Result of fetching an entry for editing.
65-/// Contains the entry data and URI, but NOT an EditorDocument.
66-/// The document must be created in a reactive context (use_hook) to properly initialize Signals.
67#[derive(Clone, PartialEq)]
68pub struct LoadedEntry {
69 pub entry: Entry<'static>,
···71}
7273/// Fetch an existing entry from the PDS for editing.
74-///
75-/// Returns the entry data and URI. The caller should create an `EditorDocument`
76-/// from this data using `EditorDocument::from_entry()` inside a reactive context.
77-///
78-/// # Arguments
79-/// * `fetcher` - The fetcher for making API calls
80-/// * `uri` - The AT-URI of the entry to load (e.g., `at://did:plc:xxx/sh.weaver.notebook.entry/rkey`)
81-///
82-/// # Returns
83-/// The entry and its URI, or an error.
84pub async fn load_entry_for_editing(
85 fetcher: &Fetcher,
86 uri: &AtUri<'_>,
···154/// - Without notebook but with entry_uri in doc: uses `put_record` to update existing
155/// - Without notebook and no entry_uri: uses `create_record` for free-floating entry
156///
157-/// Draft image paths (`/image/{did}/draft/{blob_rkey}/{name}`) are rewritten to
158-/// published paths (`/image/{did}/{entry_rkey}/{name}`) before publishing.
159-///
160/// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record.
161-///
162-/// # Arguments
163-/// * `fetcher` - The authenticated fetcher/client
164-/// * `doc` - The editor document containing entry data (mutable to update entry_uri)
165-/// * `notebook_title` - Optional title of the notebook to publish to
166-/// * `draft_key` - Storage key for the draft (for cleanup)
167-///
168-/// # Returns
169-/// The AT-URI of the created/updated entry, or an error.
170pub async fn publish_entry(
171 fetcher: &Fetcher,
172 doc: &mut EditorDocument,
···62}
6364/// Result of fetching an entry for editing.
0065#[derive(Clone, PartialEq)]
66pub struct LoadedEntry {
67 pub entry: Entry<'static>,
···69}
7071/// Fetch an existing entry from the PDS for editing.
000000000072pub async fn load_entry_for_editing(
73 fetcher: &Fetcher,
74 uri: &AtUri<'_>,
···142/// - Without notebook but with entry_uri in doc: uses `put_record` to update existing
143/// - Without notebook and no entry_uri: uses `create_record` for free-floating entry
144///
145+/// Draft image paths are rewritten to published paths before publishing.
00146/// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record.
000000000147pub async fn publish_entry(
148 fetcher: &Fetcher,
149 doc: &mut EditorDocument,
-9
crates/weaver-app/src/components/editor/render.rs
···104///
105/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
106/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
107-///
108-/// # Arguments
109-/// - `text`: The document text to render
110-/// - `cache`: Previous render cache (if any)
111-/// - `edit`: Information about the most recent edit (if any)
112-/// - `image_resolver`: Optional resolver for mapping image URLs to data/CDN URLs
113-///
114-/// # Returns
115-/// Tuple of (rendered paragraphs, updated cache)
116pub fn render_paragraphs_incremental(
117 text: &LoroText,
118 cache: Option<&RenderCache>,
···104///
105/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
106/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
000000000107pub fn render_paragraphs_incremental(
108 text: &LoroText,
109 cache: Option<&RenderCache>,
···71}
7273/// Save editor state to LocalStorage (WASM only).
74-///
75-/// # Arguments
76-/// * `doc` - The editor document to save
77-/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
78#[cfg(all(target_family = "wasm", target_os = "unknown"))]
79pub fn save_to_storage(
80 doc: &EditorDocument,
···103///
104/// Returns an EditorDocument restored from CRDT snapshot if available,
105/// otherwise falls back to just the text content.
106-///
107-/// # Arguments
108-/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
109#[cfg(all(target_family = "wasm", target_os = "unknown"))]
110pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
111 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
···158///
159/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe
160/// to call outside of reactive context. Use with `load_and_merge_document`.
161-///
162-/// # Arguments
163-/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
164#[cfg(all(target_family = "wasm", target_os = "unknown"))]
165pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> {
166 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
···195}
196197/// Delete a draft from LocalStorage (WASM only).
198-///
199-/// # Arguments
200-/// * `key` - Storage key to delete
201#[cfg(all(target_family = "wasm", target_os = "unknown"))]
202pub fn delete_draft(key: &str) {
203 LocalStorage::delete(storage_key(key));
···71}
7273/// Save editor state to LocalStorage (WASM only).
000074#[cfg(all(target_family = "wasm", target_os = "unknown"))]
75pub fn save_to_storage(
76 doc: &EditorDocument,
···99///
100/// Returns an EditorDocument restored from CRDT snapshot if available,
101/// otherwise falls back to just the text content.
000102#[cfg(all(target_family = "wasm", target_os = "unknown"))]
103pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
104 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
···151///
152/// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe
153/// to call outside of reactive context. Use with `load_and_merge_document`.
000154#[cfg(all(target_family = "wasm", target_os = "unknown"))]
155pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> {
156 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
···185}
186187/// Delete a draft from LocalStorage (WASM only).
000188#[cfg(all(target_family = "wasm", target_os = "unknown"))]
189pub fn delete_draft(key: &str) {
190 LocalStorage::delete(storage_key(key));
+9-56
crates/weaver-app/src/components/editor/sync.rs
···178///
179/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root`
180/// record referencing the entry (or draft key if unpublished).
181-///
182-/// # Arguments
183-/// * `fetcher` - The authenticated fetcher
184-/// * `doc` - The editor document
185-/// * `draft_key` - The draft key (used for unpublished entries)
186-/// * `entry_uri` - Optional AT-URI of the published entry
187-/// * `entry_cid` - Optional CID of the published entry
188pub async fn create_edit_root(
189 fetcher: &Fetcher,
190 doc: &EditorDocument,
···244}
245246/// Create a diff record with updates since the last sync.
247-///
248-/// # Arguments
249-/// * `fetcher` - The authenticated fetcher
250-/// * `doc` - The editor document
251-/// * `root_uri` - URI of the edit root
252-/// * `root_cid` - CID of the edit root
253-/// * `prev_diff` - Optional reference to the previous diff
254-/// * `draft_key` - The draft key (used for doc reference)
255-/// * `entry_uri` - Optional AT-URI of the published entry
256-/// * `entry_cid` - Optional CID of the published entry
257pub async fn create_diff(
258 fetcher: &Fetcher,
259 doc: &EditorDocument,
···352///
353/// If no edit root exists, creates one with a full snapshot.
354/// If a root exists, creates a diff with updates since last sync.
355-///
356/// Updates the document's sync state on success.
357-///
358-/// # Arguments
359-/// * `fetcher` - The authenticated fetcher
360-/// * `doc` - The editor document (mutable to update sync state)
361-/// * `draft_key` - The draft key for this document
362-///
363-/// # Returns
364-/// The sync result indicating what was created.
365pub async fn sync_to_pds(
366 fetcher: &Fetcher,
367 doc: &mut EditorDocument,
···480///
481/// Finds the edit root via constellation backlinks, fetches all diffs,
482/// and returns the snapshot + updates needed to reconstruct the document.
483-///
484-/// # Arguments
485-/// * `fetcher` - The authenticated fetcher
486-/// * `entry_uri` - The AT-URI of the entry to load edit state for
487-///
488-/// # Returns
489-/// The edit state if found, or None if no edit root exists for this entry.
490pub async fn load_edit_state_from_pds(
491 fetcher: &Fetcher,
492 entry_uri: &AtUri<'_>,
···500 // Build root URI
501 let root_uri = AtUri::new(&format!(
502 "at://{}/{}/{}",
503- root_id.did(),
504 ROOT_NSID,
505- root_id.rkey().as_ref()
506 ))
507 .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))?
508 .into_static();
···530 // Fetch the root snapshot blob
531 let root_snapshot = fetch_blob(
532 fetcher,
533- &root_id.did(),
534 root_output.value.snapshot.blob().cid(),
535 )
536 .await?;
···555 > = BTreeMap::new();
556557 for diff_id in &diff_ids {
558- let rkey = diff_id.rkey();
559- let rkey_str: &str = rkey.as_ref();
560 let diff_uri = AtUri::new(&format!(
561 "at://{}/{}/{}",
562- diff_id.did(),
563 DIFF_NSID,
564 rkey_str
565 ))
···600 let diff_bytes = if let Some(ref inline) = diff.inline_diff {
601 inline.clone()
602 } else if let Some(ref snapshot) = diff.snapshot {
603- fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await?
604 } else {
605 tracing::warn!("Diff has neither inline_diff nor snapshot, skipping");
606 continue;
···622623/// Load document state by merging local storage and PDS state.
624///
625-/// This is the main entry point for loading a document with full sync support.
626-/// It:
627-/// 1. Loads from localStorage (if available)
628-/// 2. Loads from PDS (if available)
629-/// 3. Merges both using Loro's CRDT merge
630-///
631-/// The result is a `LoadedDocState` containing a pre-merged LoroDoc that can be
632-/// converted to an EditorDocument inside a reactive context using `use_hook`.
633-///
634-/// # Arguments
635-/// * `fetcher` - The authenticated fetcher
636-/// * `draft_key` - The localStorage key for this draft
637-/// * `entry_uri` - Optional AT-URI if editing an existing entry
638-///
639-/// # Returns
640-/// A `LoadedDocState` with merged state, or None if no state exists anywhere.
641pub async fn load_and_merge_document(
642 fetcher: &Fetcher,
643 draft_key: &str,
···178///
179/// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root`
180/// record referencing the entry (or draft key if unpublished).
0000000181pub async fn create_edit_root(
182 fetcher: &Fetcher,
183 doc: &EditorDocument,
···237}
238239/// Create a diff record with updates since the last sync.
0000000000240pub async fn create_diff(
241 fetcher: &Fetcher,
242 doc: &EditorDocument,
···335///
336/// If no edit root exists, creates one with a full snapshot.
337/// If a root exists, creates a diff with updates since last sync.
0338/// Updates the document's sync state on success.
00000000339pub async fn sync_to_pds(
340 fetcher: &Fetcher,
341 doc: &mut EditorDocument,
···454///
455/// Finds the edit root via constellation backlinks, fetches all diffs,
456/// and returns the snapshot + updates needed to reconstruct the document.
0000000457pub async fn load_edit_state_from_pds(
458 fetcher: &Fetcher,
459 entry_uri: &AtUri<'_>,
···467 // Build root URI
468 let root_uri = AtUri::new(&format!(
469 "at://{}/{}/{}",
470+ root_id.did,
471 ROOT_NSID,
472+ root_id.rkey.as_ref()
473 ))
474 .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))?
475 .into_static();
···497 // Fetch the root snapshot blob
498 let root_snapshot = fetch_blob(
499 fetcher,
500+ &root_id.did,
501 root_output.value.snapshot.blob().cid(),
502 )
503 .await?;
···522 > = BTreeMap::new();
523524 for diff_id in &diff_ids {
525+ let rkey_str: &str = diff_id.rkey.as_ref();
0526 let diff_uri = AtUri::new(&format!(
527 "at://{}/{}/{}",
528+ diff_id.did,
529 DIFF_NSID,
530 rkey_str
531 ))
···566 let diff_bytes = if let Some(ref inline) = diff.inline_diff {
567 inline.clone()
568 } else if let Some(ref snapshot) = diff.snapshot {
569+ fetch_blob(fetcher, &root_id.did, snapshot.blob().cid()).await?
570 } else {
571 tracing::warn!("Diff has neither inline_diff nor snapshot, skipping");
572 continue;
···588589/// Load document state by merging local storage and PDS state.
590///
591+/// Loads from localStorage and PDS (if available), then merges both using Loro's
592+/// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an
593+/// EditorDocument inside a reactive context using `use_hook`.
0000000000000594pub async fn load_and_merge_document(
595 fetcher: &Fetcher,
596 draft_key: &str,
···1819impl VisibilityState {
20 /// Calculate visibility based on cursor position and selection.
21- ///
22- /// # Arguments
23- /// - `cursor_offset`: Current cursor position (char offset)
24- /// - `selection`: Optional selection range
25- /// - `syntax_spans`: All syntax spans in the document
26- /// - `paragraphs`: All paragraphs (for block-level visibility lookup)
27 pub fn calculate(
28 cursor_offset: usize,
29 selection: Option<&Selection>,
···1819impl VisibilityState {
20 /// Calculate visibility based on cursor position and selection.
00000021 pub fn calculate(
22 cursor_offset: usize,
23 selection: Option<&Selection>,
-25
crates/weaver-app/src/components/editor/writer.rs
···176 }
177178 /// Add a pending image with a data URL for immediate preview.
179- ///
180- /// # Arguments
181- /// * `name` - The image name used in markdown (e.g., "photo.jpg")
182- /// * `data_url` - The base64 data URL for preview
183 pub fn add_pending(&mut self, name: String, data_url: String) {
184 self.images.insert(name, ResolvedImage::Pending(data_url));
185 }
186187 /// Promote a pending image to uploaded (draft) status.
188- ///
189- /// # Arguments
190- /// * `name` - The image name used in markdown
191- /// * `blob_rkey` - The rkey of the PublishedBlob record
192- /// * `ident` - The AT identifier (DID or handle) of the blob owner
193 pub fn promote_to_uploaded(
194 &mut self,
195 name: &str,
···203 }
204205 /// Add an already-uploaded draft image.
206- ///
207- /// # Arguments
208- /// * `name` - The name/URL used in markdown (e.g., "photo.jpg")
209- /// * `blob_rkey` - The rkey of the PublishedBlob record
210- /// * `ident` - The AT identifier (DID or handle) of the blob owner
211 pub fn add_uploaded(
212 &mut self,
213 name: String,
···219 }
220221 /// Add a published image.
222- ///
223- /// # Arguments
224- /// * `name` - The name/URL used in markdown (e.g., "photo.jpg")
225- /// * `entry_rkey` - The rkey of the entry record containing this image
226- /// * `ident` - The AT identifier (DID or handle) of the entry owner
227 pub fn add_published(
228 &mut self,
229 name: String,
···240 }
241242 /// Build a resolver from editor images and user identifier.
243- ///
244- /// # Arguments
245- /// * `images` - Iterator of editor images
246- /// * `ident` - The AT identifier (DID or handle) of the user
247- /// * `entry_rkey` - If Some, images are resolved as published (`/image/{ident}/{entry_rkey}/{name}`).
248- /// If None, images are resolved as drafts using their `published_blob_uri`.
249 ///
250 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
251 /// For published mode (entry_rkey=Some), all images are included.
···176 }
177178 /// Add a pending image with a data URL for immediate preview.
0000179 pub fn add_pending(&mut self, name: String, data_url: String) {
180 self.images.insert(name, ResolvedImage::Pending(data_url));
181 }
182183 /// Promote a pending image to uploaded (draft) status.
00000184 pub fn promote_to_uploaded(
185 &mut self,
186 name: &str,
···194 }
195196 /// Add an already-uploaded draft image.
00000197 pub fn add_uploaded(
198 &mut self,
199 name: String,
···205 }
206207 /// Add a published image.
00000208 pub fn add_published(
209 &mut self,
210 name: String,
···221 }
222223 /// Build a resolver from editor images and user identifier.
000000224 ///
225 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
226 /// For published mode (entry_rkey=Some), all images are included.
···32 pub entries: Vec<StrongRef<'a>>,
33}
3435-/// too many cows, so we have conversions
36pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> {
37 match cow {
38 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),
···40 }
41}
4243-/// too many cows, so we have conversions
44pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> {
45 match cow {
46 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s),
···48 }
49}
5051-/// too many cows, so we have conversions
52pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> {
53 match cow {
54 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),