···1132 self.composition.set(composition);
1133 }
1134000000001135 fn undo(&mut self) -> bool {
1136 // Sync Loro cursor to current position BEFORE undo
1137 // so it tracks through the undo operation.
···1132 self.composition.set(composition);
1133 }
11341135+ 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.
···1//! DOM synchronization for the markdown editor.
2//!
3-//! Handles syncing cursor/selection state between the browser DOM and our
4-//! internal document model, and updating paragraph DOM elements.
5-//!
6-//! Most DOM sync logic is in `weaver_editor_browser`. This module provides
7-//! thin wrappers that work with `SignalEditorDocument` directly.
8-9-#[allow(unused_imports)]
10-use super::document::Selection;
11-#[allow(unused_imports)]
12-use super::document::SignalEditorDocument;
13-#[allow(unused_imports)]
14-use dioxus::prelude::*;
15-use weaver_editor_core::ParagraphRender;
16-#[allow(unused_imports)]
17-use weaver_editor_core::SnapDirection;
1819// Re-export from browser crate.
20#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
21-pub use weaver_editor_browser::{dom_position_to_text_offset, update_paragraph_dom};
22-23-/// Sync internal cursor and selection state from browser DOM selection.
24-///
25-/// The optional `direction_hint` is used when snapping cursor from invisible content.
26-/// Pass `SnapDirection::Backward` for left/up arrow keys, `SnapDirection::Forward` for right/down.
27-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
28-pub fn sync_cursor_from_dom(
29- doc: &mut SignalEditorDocument,
30- editor_id: &str,
31- paragraphs: &[ParagraphRender],
32-) {
33- sync_cursor_from_dom_with_direction(doc, editor_id, paragraphs, None);
34-}
35-36-/// Sync cursor with optional direction hint for snapping.
37-///
38-/// Use this when handling arrow keys to ensure cursor snaps in the expected direction.
39-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
40-pub fn sync_cursor_from_dom_with_direction(
41- doc: &mut SignalEditorDocument,
42- editor_id: &str,
43- paragraphs: &[ParagraphRender],
44- direction_hint: Option<SnapDirection>,
45-) {
46- use wasm_bindgen::JsCast;
47-48- // Early return if paragraphs not yet populated (first render edge case)
49- if paragraphs.is_empty() {
50- return;
51- }
52-53- let window = match web_sys::window() {
54- Some(w) => w,
55- None => return,
56- };
57-58- let dom_document = match window.document() {
59- Some(d) => d,
60- None => return,
61- };
62-63- let editor_element = match dom_document.get_element_by_id(editor_id) {
64- Some(e) => e,
65- None => return,
66- };
67-68- let selection = match window.get_selection() {
69- Ok(Some(sel)) => sel,
70- _ => return,
71- };
72-73- // Get both anchor (selection start) and focus (selection end) positions
74- let anchor_node = match selection.anchor_node() {
75- Some(node) => node,
76- None => return,
77- };
78- let focus_node = match selection.focus_node() {
79- Some(node) => node,
80- None => return,
81- };
82- let anchor_offset = selection.anchor_offset() as usize;
83- let focus_offset = selection.focus_offset() as usize;
84-85- let anchor_rope = dom_position_to_text_offset(
86- &dom_document,
87- &editor_element,
88- &anchor_node,
89- anchor_offset,
90- paragraphs,
91- direction_hint,
92- );
93- let focus_rope = dom_position_to_text_offset(
94- &dom_document,
95- &editor_element,
96- &focus_node,
97- focus_offset,
98- paragraphs,
99- direction_hint,
100- );
101-102- match (anchor_rope, focus_rope) {
103- (Some(anchor), Some(focus)) => {
104- let old_offset = doc.cursor.read().offset;
105- // Warn if cursor is jumping a large distance - likely a bug
106- let jump = if focus > old_offset {
107- focus - old_offset
108- } else {
109- old_offset - focus
110- };
111- if jump > 100 {
112- tracing::warn!(
113- old_offset,
114- new_offset = focus,
115- jump,
116- "sync_cursor_from_dom: LARGE CURSOR JUMP detected"
117- );
118- }
119- doc.cursor.write().offset = focus;
120- if anchor != focus {
121- doc.selection.set(Some(Selection {
122- anchor,
123- head: focus,
124- }));
125- } else {
126- doc.selection.set(None);
127- }
128- }
129- _ => {
130- tracing::warn!("Could not map DOM selection to rope offsets");
131- }
132- }
133-}
134-135-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
136-pub fn sync_cursor_from_dom(
137- _document: &mut SignalEditorDocument,
138- _editor_id: &str,
139- _paragraphs: &[ParagraphRender],
140-) {
141-}
142-143-#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
144-pub fn sync_cursor_from_dom_with_direction(
145- _document: &mut SignalEditorDocument,
146- _editor_id: &str,
147- _paragraphs: &[ParagraphRender],
148- _direction_hint: Option<SnapDirection>,
149-) {
150-}
···1//! DOM synchronization for the markdown editor.
2//!
3+//! Most DOM sync logic is in `weaver_editor_browser`. This module re-exports
4+//! the `update_paragraph_dom` function for use in the editor component.
000000000000056// Re-export from browser crate.
7#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
8+pub use weaver_editor_browser::update_paragraph_dom;
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···10use std::collections::HashMap;
1112use super::document::{LoadedDocState, SignalEditorDocument};
13+use super::publish::load_entry_for_editing;
14use crate::fetch::Fetcher;
15use jacquard::IntoStatic;
16+use jacquard::identity::resolver::IdentityResolver;
17use jacquard::prelude::*;
18+use jacquard::smol_str::{SmolStr, ToSmolStr};
19use jacquard::types::ident::AtIdentifier;
20use jacquard::types::string::{AtUri, Cid};
21use loro::LoroDoc;
···908 }
909 }
910}
911+912+// === Editor state loading ===
913+914+/// Result of loading editor state.
915+#[derive(Clone)]
916+pub enum LoadEditorResult {
917+ /// Document state loaded successfully.
918+ Loaded(LoadedDocState),
919+ /// Loading failed with error message.
920+ Failed(String),
921+}
922+923+/// Load editor state from various sources.
924+///
925+/// This function handles the complete loading flow:
926+/// 1. Resolves notebook title to URI if provided
927+/// 2. Tries to load and merge from localStorage + PDS edit state
928+/// 3. Falls back to loading entry content if no edit state exists
929+/// 4. Creates new document with initial content if nothing exists
930+///
931+/// # Arguments
932+/// - `fetcher`: The fetcher for API calls
933+/// - `draft_key`: Unique key for localStorage (entry URI or "new:{tid}")
934+/// - `entry_uri`: Optional AT-URI of existing entry to edit
935+/// - `initial_content`: Optional initial markdown for new entries
936+/// - `target_notebook`: Optional notebook title to resolve to URI
937+pub async fn load_editor_state(
938+ fetcher: &Fetcher,
939+ draft_key: &str,
940+ entry_uri: Option<&AtUri<'static>>,
941+ initial_content: Option<&str>,
942+ target_notebook: Option<&str>,
943+) -> LoadEditorResult {
944+ // Resolve target_notebook to a URI if provided.
945+ let notebook_uri: Option<SmolStr> = if let Some(title) = target_notebook {
946+ if let Some(did) = fetcher.current_did().await {
947+ let ident = AtIdentifier::Did(did);
948+ match fetcher.get_notebook(ident, title.into()).await {
949+ Ok(Some(notebook_data)) => Some(notebook_data.0.uri.to_smolstr()),
950+ Ok(None) | Err(_) => {
951+ tracing::debug!("Could not resolve notebook '{}' to URI", title);
952+ None
953+ }
954+ }
955+ } else {
956+ None
957+ }
958+ } else {
959+ None
960+ };
961+962+ match load_and_merge_document(fetcher, draft_key, entry_uri).await {
963+ Ok(Some(mut state)) => {
964+ tracing::debug!("Loaded merged document state");
965+ if state.notebook_uri.is_none() {
966+ state.notebook_uri = notebook_uri;
967+ }
968+ return LoadEditorResult::Loaded(state);
969+ }
970+ Ok(None) => {
971+ // No existing state - check if we need to load entry content.
972+ if let Some(uri) = entry_uri {
973+ // Check that this entry belongs to the current user.
974+ if let Some(current_did) = fetcher.current_did().await {
975+ let entry_authority = uri.authority();
976+ let is_own_entry = match entry_authority {
977+ AtIdentifier::Did(did) => did == ¤t_did,
978+ AtIdentifier::Handle(handle) => {
979+ match fetcher.client.resolve_handle(handle).await {
980+ Ok(resolved_did) => resolved_did == current_did,
981+ Err(_) => false,
982+ }
983+ }
984+ };
985+ if !is_own_entry {
986+ tracing::warn!(
987+ "Cannot edit entry belonging to another user: {}",
988+ entry_authority
989+ );
990+ return LoadEditorResult::Failed(
991+ "You can only edit your own entries".to_string(),
992+ );
993+ }
994+ }
995+996+ match load_entry_for_editing(fetcher, uri).await {
997+ Ok(loaded) => {
998+ return LoadEditorResult::Loaded(
999+ create_state_from_entry(fetcher, &loaded, uri, notebook_uri).await,
1000+ );
1001+ }
1002+ Err(e) => {
1003+ tracing::error!("Failed to load entry: {}", e);
1004+ return LoadEditorResult::Failed(e.to_string());
1005+ }
1006+ }
1007+ }
1008+1009+ // New document with initial content.
1010+ let doc = LoroDoc::new();
1011+ if let Some(content) = initial_content {
1012+ let text = doc.get_text("content");
1013+ text.insert(0, content).ok();
1014+ doc.commit();
1015+ }
1016+1017+ LoadEditorResult::Loaded(LoadedDocState {
1018+ doc,
1019+ entry_ref: None,
1020+ edit_root: None,
1021+ last_diff: None,
1022+ synced_version: None,
1023+ last_seen_diffs: HashMap::new(),
1024+ resolved_content: weaver_common::ResolvedContent::default(),
1025+ notebook_uri,
1026+ })
1027+ }
1028+ Err(e) => {
1029+ tracing::error!("Failed to load document state: {}", e);
1030+ LoadEditorResult::Failed(e.to_string())
1031+ }
1032+ }
1033+}
1034+1035+/// Create LoadedDocState from a loaded entry.
1036+///
1037+/// Handles:
1038+/// - Creating LoroDoc and populating with entry data
1039+/// - Restoring embeds (images and records)
1040+/// - Pre-warming blob cache (server feature)
1041+/// - Pre-fetching embed content for initial render
1042+async fn create_state_from_entry(
1043+ fetcher: &Fetcher,
1044+ loaded: &super::publish::LoadedEntry,
1045+ uri: &AtUri<'_>,
1046+ notebook_uri: Option<SmolStr>,
1047+) -> LoadedDocState {
1048+ let doc = LoroDoc::new();
1049+ let content = doc.get_text("content");
1050+ let title = doc.get_text("title");
1051+ let path = doc.get_text("path");
1052+ let tags = doc.get_list("tags");
1053+1054+ content.insert(0, loaded.entry.content.as_ref()).ok();
1055+ title.insert(0, loaded.entry.title.as_ref()).ok();
1056+ path.insert(0, loaded.entry.path.as_ref()).ok();
1057+ if let Some(ref entry_tags) = loaded.entry.tags {
1058+ for tag in entry_tags {
1059+ let tag_str: &str = tag.as_ref();
1060+ tags.push(tag_str).ok();
1061+ }
1062+ }
1063+1064+ // Restore existing embeds from the entry.
1065+ if let Some(ref embeds) = loaded.entry.embeds {
1066+ let embeds_map = doc.get_map("embeds");
1067+1068+ if let Some(ref images) = embeds.images {
1069+ let images_list = embeds_map
1070+ .get_or_create_container("images", loro::LoroList::new())
1071+ .expect("images list");
1072+ for image in &images.images {
1073+ let json = serde_json::to_value(image).expect("Image serializes");
1074+ images_list.push(json).ok();
1075+ }
1076+ }
1077+1078+ if let Some(ref records) = embeds.records {
1079+ let records_list = embeds_map
1080+ .get_or_create_container("records", loro::LoroList::new())
1081+ .expect("records list");
1082+ for record in &records.records {
1083+ let json = serde_json::to_value(record).expect("RecordEmbed serializes");
1084+ records_list.push(json).ok();
1085+ }
1086+ }
1087+ }
1088+1089+ doc.commit();
1090+1091+ // Pre-warm blob cache for images.
1092+ #[cfg(feature = "fullstack-server")]
1093+ if let Some(ref embeds) = loaded.entry.embeds {
1094+ if let Some(ref images) = embeds.images {
1095+ let ident: &str = match uri.authority() {
1096+ AtIdentifier::Did(d) => d.as_ref(),
1097+ AtIdentifier::Handle(h) => h.as_ref(),
1098+ };
1099+ for image in &images.images {
1100+ let cid = image.image.blob().cid();
1101+ let name = image.name.as_ref().map(|n| n.as_ref());
1102+ if let Err(e) = crate::data::cache_blob(
1103+ ident.into(),
1104+ cid.as_ref().into(),
1105+ name.map(|n| n.into()),
1106+ )
1107+ .await
1108+ {
1109+ tracing::warn!("Failed to pre-warm blob cache for {}: {}", cid, e);
1110+ }
1111+ }
1112+ }
1113+ }
1114+1115+ // Pre-fetch embeds for initial render.
1116+ let resolved_content = prefetch_embeds_for_entry(fetcher, &doc, &loaded.entry.embeds).await;
1117+1118+ LoadedDocState {
1119+ doc,
1120+ entry_ref: Some(loaded.entry_ref.clone()),
1121+ edit_root: None,
1122+ last_diff: None,
1123+ synced_version: None,
1124+ last_seen_diffs: HashMap::new(),
1125+ resolved_content,
1126+ notebook_uri,
1127+ }
1128+}
1129+1130+/// Pre-fetch embed content for an entry being loaded.
1131+async fn prefetch_embeds_for_entry(
1132+ fetcher: &Fetcher,
1133+ doc: &LoroDoc,
1134+ embeds: &Option<weaver_api::sh_weaver::notebook::entry::EntryEmbeds<'_>>,
1135+) -> weaver_common::ResolvedContent {
1136+ use weaver_common::{ExtractedRef, collect_refs_from_markdown};
1137+1138+ let mut resolved_content = weaver_common::ResolvedContent::default();
1139+1140+ if let Some(embeds) = embeds {
1141+ if let Some(ref records) = embeds.records {
1142+ for record in &records.records {
1143+ let key_uri = if let Some(ref name) = record.name {
1144+ match AtUri::new(name.as_ref()) {
1145+ Ok(uri) => uri.into_static(),
1146+ Err(_) => continue,
1147+ }
1148+ } else {
1149+ record.record.uri.clone().into_static()
1150+ };
1151+1152+ match weaver_renderer::atproto::fetch_and_render(&record.record.uri, fetcher).await
1153+ {
1154+ Ok(html) => {
1155+ resolved_content.add_embed(key_uri, html, None);
1156+ }
1157+ Err(e) => {
1158+ tracing::warn!("Failed to pre-fetch embed {}: {}", record.record.uri, e);
1159+ }
1160+ }
1161+ }
1162+ }
1163+ }
1164+1165+ // Fall back to parsing markdown if no embeds in record.
1166+ if resolved_content.embed_content.is_empty() {
1167+ let text = doc.get_text("content");
1168+ let markdown = text.to_string();
1169+1170+ if !markdown.is_empty() {
1171+ tracing::debug!("Falling back to markdown parsing for embeds");
1172+ let refs = collect_refs_from_markdown(&markdown);
1173+1174+ for extracted in refs {
1175+ if let ExtractedRef::AtEmbed { uri, .. } = extracted {
1176+ let key_uri = match AtUri::new(&uri) {
1177+ Ok(u) => u.into_static(),
1178+ Err(_) => continue,
1179+ };
1180+1181+ match weaver_renderer::atproto::fetch_and_render(&key_uri, fetcher).await {
1182+ Ok(html) => {
1183+ tracing::debug!("Pre-fetched embed from markdown: {}", uri);
1184+ resolved_content.add_embed(key_uri, html, None);
1185+ }
1186+ Err(e) => {
1187+ tracing::warn!("Failed to pre-fetch embed {}: {}", uri, e);
1188+ }
1189+ }
1190+ }
1191+ }
1192+ }
1193+ }
1194+1195+ resolved_content
1196+}
+52
crates/weaver-editor-browser/src/dom_sync.rs
···9 is_valid_cursor_position,
10};
110012use crate::cursor::restore_cursor_position;
01314/// Result of syncing cursor from DOM.
15#[derive(Debug, Clone)]
···370 }
371372 None
0000000000000000000000000000000000000000000000000373}
374375/// Update paragraph DOM elements incrementally.
···9 is_valid_cursor_position,
10};
1112+use weaver_editor_core::{EditorDocument, Selection, SyntaxSpanInfo};
13+14use crate::cursor::restore_cursor_position;
15+use crate::update_syntax_visibility;
1617/// Result of syncing cursor from DOM.
18#[derive(Debug, Clone)]
···373 }
374375 None
376+}
377+378+/// Sync cursor state from DOM to an EditorDocument.
379+///
380+/// This is a generic version that works with any `EditorDocument` implementation.
381+/// It reads the browser's selection state and updates the document's cursor and selection.
382+pub fn sync_cursor_from_dom<D: EditorDocument>(
383+ doc: &mut D,
384+ editor_id: &str,
385+ paragraphs: &[ParagraphRender],
386+ direction_hint: Option<SnapDirection>,
387+) {
388+ if let Some(result) = sync_cursor_from_dom_impl(editor_id, paragraphs, direction_hint) {
389+ match result {
390+ CursorSyncResult::Cursor(offset) => {
391+ doc.set_cursor_offset(offset);
392+ doc.set_selection(None);
393+ }
394+ CursorSyncResult::Selection { anchor, head } => {
395+ doc.set_cursor_offset(head);
396+ if anchor != head {
397+ doc.set_selection(Some(Selection { anchor, head }));
398+ } else {
399+ doc.set_selection(None);
400+ }
401+ }
402+ CursorSyncResult::None => {}
403+ }
404+ }
405+}
406+407+/// Sync cursor from DOM and update syntax visibility in one call.
408+///
409+/// This is the common pattern used by most event handlers: sync the cursor
410+/// position from the browser's selection, then update which syntax elements
411+/// are visible based on the new cursor position.
412+///
413+/// Use this for: onclick, onselect, onselectstart, onselectionchange, onkeyup.
414+pub fn sync_cursor_and_visibility<D: EditorDocument>(
415+ doc: &mut D,
416+ editor_id: &str,
417+ paragraphs: &[ParagraphRender],
418+ syntax_spans: &[SyntaxSpanInfo],
419+ direction_hint: Option<SnapDirection>,
420+) {
421+ sync_cursor_from_dom(doc, editor_id, paragraphs, direction_hint);
422+ let cursor_offset = doc.cursor_offset();
423+ let selection = doc.selection();
424+ update_syntax_visibility(cursor_offset, selection.as_ref(), syntax_spans, paragraphs);
425}
426427/// Update paragraph DOM elements incrementally.