some further cleanup

Orual bc605280 8cc4614a

+886 -725
+45 -577
crates/weaver-app/src/components/editor/component.rs
··· 1 1 //! The main MarkdownEditor component. 2 2 3 - #[allow(unused_imports)] 4 3 use super::actions::{ 5 - EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 6 - handle_keydown_with_bindings, 4 + EditorAction, KeydownResult, Range, execute_action, handle_keydown_with_bindings, 7 5 }; 8 - use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; 6 + use super::document::SignalEditorDocument; 9 7 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 10 8 use super::dom_sync::update_paragraph_dom; 11 - use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 12 - #[allow(unused_imports)] 13 - use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 9 + use super::publish::PublishButton; 14 10 use super::remote_cursors::RemoteCursors; 15 11 use super::storage; 16 - use super::sync::{SyncStatus, load_and_merge_document}; 12 + use super::sync::{LoadEditorResult, SyncStatus, load_editor_state}; 17 13 use super::toolbar::EditorToolbar; 18 14 use crate::auth::AuthState; 19 15 use crate::components::collab::CollaboratorAvatars; 20 - use crate::components::editor::ReportButton; 21 16 use crate::components::editor::collab::CollabCoordinator; 17 + use crate::components::editor::{LoadedDocState, ReportButton}; 22 18 use crate::fetch::Fetcher; 23 19 use dioxus::prelude::*; 24 20 use jacquard::IntoStatic; 25 - use jacquard::cowstr::ToCowStr; 26 - use jacquard::identity::resolver::IdentityResolver; 27 - use jacquard::smol_str::{SmolStr, ToSmolStr}; 28 - use jacquard::types::aturi::AtUri; 21 + use jacquard::smol_str::SmolStr; 29 22 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 30 23 use jacquard::types::blob::BlobRef; 31 - use jacquard::types::ident::AtIdentifier; 32 - use weaver_api::sh_weaver::embed::images::Image; 33 - use weaver_common::WeaverExt; 34 - #[allow(unused_imports)] 35 - use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; 36 - use weaver_editor_browser::{handle_copy, handle_cut, handle_paste}; 37 - use weaver_editor_browser::{platform, update_syntax_visibility}; 38 - use weaver_editor_core::EditorDocument; 24 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 25 + use weaver_editor_browser::{BeforeInputContext, BeforeInputResult, update_syntax_visibility}; 26 + use weaver_editor_browser::{ 27 + handle_compositionend, handle_compositionstart, handle_compositionupdate, handle_copy, 28 + handle_cut, handle_paste, platform, sync_cursor_and_visibility, 29 + }; 39 30 use weaver_editor_core::EditorImageResolver; 40 - #[allow(unused_imports)] 31 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 41 32 use weaver_editor_core::InputType; 42 33 use weaver_editor_core::ParagraphRender; 43 34 use weaver_editor_core::SnapDirection; 44 35 use weaver_editor_core::apply_formatting; 45 36 46 - /// Result of loading document state. 47 - enum LoadResult { 48 - /// Document state loaded (may be merged from PDS + localStorage) 49 - Loaded(LoadedDocState), 50 - /// Loading failed 51 - Failed(String), 52 - /// Still loading 53 - #[allow(dead_code)] 54 - Loading, 55 - } 56 - 57 37 /// Wrapper component that handles loading document state before rendering the editor. 58 38 /// 59 39 /// Loads and merges state from: ··· 100 80 let target_notebook = target_notebook.clone(); 101 81 102 82 async move { 103 - // Resolve target_notebook to a URI if provided 104 - let notebook_uri: Option<SmolStr> = if let Some(ref title) = target_notebook { 105 - if let Some(did) = fetcher.current_did().await { 106 - let ident = jacquard::types::ident::AtIdentifier::Did(did); 107 - match fetcher.get_notebook(ident, title.clone()).await { 108 - Ok(Some(notebook_data)) => Some(notebook_data.0.uri.to_smolstr()), 109 - Ok(None) | Err(_) => { 110 - tracing::debug!("Could not resolve notebook '{}' to URI", title); 111 - None 112 - } 113 - } 114 - } else { 115 - None 116 - } 117 - } else { 118 - None 119 - }; 120 - 121 - match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await { 122 - Ok(Some(mut state)) => { 123 - tracing::debug!("Loaded merged document state"); 124 - // If we resolved a notebook URI and state doesn't have one, use it 125 - if state.notebook_uri.is_none() { 126 - state.notebook_uri = notebook_uri; 127 - } 128 - return LoadResult::Loaded(state); 129 - } 130 - Ok(None) => { 131 - // No existing state - check if we need to load entry content 132 - if let Some(ref uri) = entry_uri { 133 - // Check that this entry belongs to the current user 134 - if let Some(current_did) = fetcher.current_did().await { 135 - let entry_authority = uri.authority(); 136 - let is_own_entry = match entry_authority { 137 - AtIdentifier::Did(did) => did == &current_did, 138 - AtIdentifier::Handle(handle) => { 139 - match fetcher.client.resolve_handle(handle).await { 140 - Ok(resolved_did) => resolved_did == current_did, 141 - Err(_) => false, 142 - } 143 - } 144 - }; 145 - if !is_own_entry { 146 - tracing::warn!( 147 - "Cannot edit entry belonging to another user: {}", 148 - entry_authority 149 - ); 150 - return LoadResult::Failed( 151 - "You can only edit your own entries".to_string(), 152 - ); 153 - } 154 - } 155 - match load_entry_for_editing(&fetcher, uri).await { 156 - Ok(loaded) => { 157 - // Create LoadedDocState from entry 158 - let doc = loro::LoroDoc::new(); 159 - let content = doc.get_text("content"); 160 - let title = doc.get_text("title"); 161 - let path = doc.get_text("path"); 162 - let tags = doc.get_list("tags"); 163 - 164 - content.insert(0, loaded.entry.content.as_ref()).ok(); 165 - title.insert(0, loaded.entry.title.as_ref()).ok(); 166 - path.insert(0, loaded.entry.path.as_ref()).ok(); 167 - if let Some(ref entry_tags) = loaded.entry.tags { 168 - for tag in entry_tags { 169 - let tag_str: &str = tag.as_ref(); 170 - tags.push(tag_str).ok(); 171 - } 172 - } 173 - 174 - // Restore existing embeds from the entry 175 - if let Some(ref embeds) = loaded.entry.embeds { 176 - let embeds_map = doc.get_map("embeds"); 177 - 178 - // Restore images 179 - if let Some(ref images) = embeds.images { 180 - let images_list = embeds_map 181 - .get_or_create_container( 182 - "images", 183 - loro::LoroList::new(), 184 - ) 185 - .expect("images list"); 186 - for image in &images.images { 187 - // Serialize image to JSON and add to list 188 - // No publishedBlobUri since these are already published 189 - let json = serde_json::to_value(image) 190 - .expect("Image serializes"); 191 - images_list.push(json).ok(); 192 - } 193 - } 194 - 195 - // Restore record embeds 196 - if let Some(ref records) = embeds.records { 197 - let records_list = embeds_map 198 - .get_or_create_container( 199 - "records", 200 - loro::LoroList::new(), 201 - ) 202 - .expect("records list"); 203 - for record in &records.records { 204 - let json = serde_json::to_value(record) 205 - .expect("RecordEmbed serializes"); 206 - records_list.push(json).ok(); 207 - } 208 - } 209 - } 210 - 211 - doc.commit(); 212 - 213 - // Pre-warm blob cache for images 214 - #[cfg(feature = "fullstack-server")] 215 - if let Some(ref embeds) = loaded.entry.embeds { 216 - if let Some(ref images) = embeds.images { 217 - let ident: &str = match uri.authority() { 218 - AtIdentifier::Did(d) => d.as_ref(), 219 - AtIdentifier::Handle(h) => h.as_ref(), 220 - }; 221 - for image in &images.images { 222 - let cid = image.image.blob().cid(); 223 - let name = image.name.as_ref().map(|n| n.as_ref()); 224 - if let Err(e) = crate::data::cache_blob( 225 - ident.into(), 226 - cid.as_ref().into(), 227 - name.map(|n| n.into()), 228 - ) 229 - .await 230 - { 231 - tracing::warn!( 232 - "Failed to pre-warm blob cache for {}: {}", 233 - cid, 234 - e 235 - ); 236 - } 237 - } 238 - } 239 - } 240 - 241 - // Pre-fetch embeds for initial render 242 - let mut resolved_content = 243 - weaver_common::ResolvedContent::default(); 244 - if let Some(ref embeds) = loaded.entry.embeds { 245 - if let Some(ref records) = embeds.records { 246 - for record in &records.records { 247 - // name is the key used in markdown, fallback to record.uri 248 - let key_uri = if let Some(ref name) = record.name { 249 - match jacquard::types::string::AtUri::new( 250 - name.as_ref(), 251 - ) { 252 - Ok(uri) => uri.into_static(), 253 - Err(_) => continue, 254 - } 255 - } else { 256 - record.record.uri.clone().into_static() 257 - }; 258 - 259 - match weaver_renderer::atproto::fetch_and_render( 260 - &record.record.uri, 261 - &fetcher, 262 - ) 263 - .await 264 - { 265 - Ok(html) => { 266 - resolved_content.add_embed(key_uri, html, None); 267 - } 268 - Err(e) => { 269 - tracing::warn!( 270 - "Failed to pre-fetch embed {}: {}", 271 - record.record.uri, 272 - e 273 - ); 274 - } 275 - } 276 - } 277 - } 278 - } 279 - if resolved_content.embed_content.is_empty() { 280 - use weaver_common::{ExtractedRef, collect_refs_from_markdown}; 281 - 282 - let text = doc.get_text("content"); 283 - let markdown = text.to_string(); 284 - 285 - if !markdown.is_empty() { 286 - tracing::debug!( 287 - "Falling back to markdown parsing for embeds" 288 - ); 289 - let refs = collect_refs_from_markdown(&markdown); 290 - 291 - for extracted in refs { 292 - if let ExtractedRef::AtEmbed { uri, .. } = extracted { 293 - let key_uri = match AtUri::new(&uri) { 294 - Ok(u) => u.into_static(), 295 - Err(_) => continue, 296 - }; 297 - 298 - match weaver_renderer::atproto::fetch_and_render( 299 - &key_uri, &fetcher, 300 - ) 301 - .await 302 - { 303 - Ok(html) => { 304 - tracing::debug!( 305 - "Pre-fetched embed from markdown: {}", 306 - uri 307 - ); 308 - resolved_content 309 - .add_embed(key_uri, html, None); 310 - } 311 - Err(e) => { 312 - tracing::warn!( 313 - "Failed to pre-fetch embed {}: {}", 314 - uri, 315 - e 316 - ); 317 - } 318 - } 319 - } 320 - } 321 - } 322 - } 323 - 324 - return LoadResult::Loaded(LoadedDocState { 325 - doc, 326 - entry_ref: Some(loaded.entry_ref), 327 - edit_root: None, 328 - last_diff: None, 329 - synced_version: None, // Fresh from entry, never synced 330 - last_seen_diffs: std::collections::HashMap::new(), 331 - resolved_content, 332 - notebook_uri: notebook_uri.clone(), 333 - }); 334 - } 335 - Err(e) => { 336 - tracing::error!("Failed to load entry: {}", e); 337 - return LoadResult::Failed(e.to_string()); 338 - } 339 - } 340 - } 341 - 342 - // New document with initial content 343 - let doc = loro::LoroDoc::new(); 344 - if let Some(ref content) = initial_content { 345 - let text = doc.get_text("content"); 346 - text.insert(0, content).ok(); 347 - doc.commit(); 348 - } 349 - 350 - LoadResult::Loaded(LoadedDocState { 351 - doc, 352 - entry_ref: None, 353 - edit_root: None, 354 - last_diff: None, 355 - synced_version: None, // New doc, never synced 356 - last_seen_diffs: std::collections::HashMap::new(), 357 - resolved_content: weaver_common::ResolvedContent::default(), 358 - notebook_uri, 359 - }) 360 - } 361 - Err(e) => { 362 - tracing::error!("Failed to load document state: {}", e); 363 - LoadResult::Failed(e.to_string()) 364 - } 365 - } 83 + load_editor_state( 84 + &fetcher, 85 + &draft_key, 86 + entry_uri.as_ref(), 87 + initial_content.as_deref(), 88 + target_notebook.as_deref(), 89 + ) 90 + .await 366 91 } 367 92 }); 368 93 369 94 match &*load_resource.read() { 370 - Some(LoadResult::Loaded(state)) => { 95 + Some(LoadEditorResult::Loaded(state)) => { 371 96 rsx! { 372 97 MarkdownEditorInner { 373 98 key: "{draft_key_for_render}", ··· 378 103 } 379 104 } 380 105 } 381 - Some(LoadResult::Failed(err)) => { 106 + Some(LoadEditorResult::Failed(err)) => { 382 107 rsx! { 383 108 div { class: "editor-error", 384 109 "Failed to load: {err}" 385 110 } 386 111 } 387 112 } 388 - Some(LoadResult::Loading) | None => { 113 + None => { 389 114 rsx! { 390 115 div { class: "editor-loading", 391 116 "Loading..." ··· 626 351 let cursor_para_updated = 627 352 update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset, false); 628 353 629 - // Only restore cursor if we actually re-rendered the paragraph it's in 630 - // if cursor_para_updated { 631 - // use wasm_bindgen::JsCast; 632 - // use wasm_bindgen::prelude::*; 633 - 634 - // // Read and consume pending snap direction 635 - // let snap_direction = doc_for_dom.pending_snap.write().take(); 636 - 637 - // // Use requestAnimationFrame to wait for browser paint 638 - // if let Some(window) = web_sys::window() { 639 - // let closure = Closure::once(move || { 640 - // if let Err(e) = super::cursor::restore_cursor_position( 641 - // cursor_offset, 642 - // &map, 643 - // editor_id, 644 - // snap_direction, 645 - // ) { 646 - // tracing::warn!("Cursor restoration failed: {:?}", e); 647 - // } 648 - // }); 649 - 650 - // let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); 651 - // closure.forget(); 652 - // } 653 - // } 654 - 655 354 // Store for next comparison AND for event handlers (write-only, no reactive read) 656 355 cached_paragraphs.set(new_paras.clone()); 657 356 ··· 808 507 809 508 Timeout::new(20, move || { 810 509 let paras = paras(); 811 - sync_cursor_from_dom(&mut doc_for_timeout, editor_id, &paras); 510 + weaver_editor_browser::sync_cursor_from_dom( 511 + &mut doc_for_timeout, 512 + editor_id, 513 + &paras, 514 + None, 515 + ); 812 516 }) 813 517 .forget(); // One-shot timer, runs and cleans up 814 518 } ··· 1122 826 "onkeyup navigation - syncing cursor from DOM" 1123 827 ); 1124 828 let paras = cached_paragraphs(); 1125 - if let Some(dir) = direction_hint { 1126 - sync_cursor_from_dom_with_direction(&mut doc, editor_id, &paras, Some(dir)); 1127 - } else { 1128 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1129 - } 1130 829 let spans = syntax_spans(); 1131 - let cursor_offset = doc.cursor.read().offset; 1132 - let selection = *doc.selection.read(); 1133 - update_syntax_visibility( 1134 - cursor_offset, 1135 - selection.as_ref(), 1136 - &spans, 1137 - &paras, 830 + sync_cursor_and_visibility( 831 + &mut doc, editor_id, &paras, &spans, direction_hint, 1138 832 ); 1139 833 } 1140 834 } ··· 1145 839 move |_evt| { 1146 840 tracing::debug!("onselect fired - syncing cursor from DOM"); 1147 841 let paras = cached_paragraphs(); 1148 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1149 842 let spans = syntax_spans(); 1150 - let cursor_offset = doc.cursor.read().offset; 1151 - let selection = *doc.selection.read(); 1152 - update_syntax_visibility( 1153 - cursor_offset, 1154 - selection.as_ref(), 1155 - &spans, 1156 - &paras, 1157 - ); 843 + sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 1158 844 } 1159 845 }, 1160 846 ··· 1163 849 move |_evt| { 1164 850 tracing::debug!("onselectstart fired - syncing cursor from DOM"); 1165 851 let paras = cached_paragraphs(); 1166 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1167 852 let spans = syntax_spans(); 1168 - let cursor_offset = doc.cursor.read().offset; 1169 - let selection = *doc.selection.read(); 1170 - update_syntax_visibility( 1171 - cursor_offset, 1172 - selection.as_ref(), 1173 - &spans, 1174 - &paras, 1175 - ); 853 + sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 1176 854 } 1177 855 }, 1178 856 ··· 1181 859 move |_evt| { 1182 860 tracing::debug!("onselectionchange fired - syncing cursor from DOM"); 1183 861 let paras = cached_paragraphs(); 1184 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1185 862 let spans = syntax_spans(); 1186 - let cursor_offset = doc.cursor.read().offset; 1187 - let selection = *doc.selection.read(); 1188 - update_syntax_visibility( 1189 - cursor_offset, 1190 - selection.as_ref(), 1191 - &spans, 1192 - &paras, 1193 - ); 863 + sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 1194 864 } 1195 865 }, 1196 866 ··· 1219 889 } 1220 890 } 1221 891 1222 - sync_cursor_from_dom(&mut doc, editor_id, &paras); 1223 - let cursor_offset = doc.cursor.read().offset; 1224 - let selection = *doc.selection.read(); 1225 - update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &paras); 892 + sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 1226 893 } 1227 894 }, 1228 895 ··· 1288 955 oncompositionstart: { 1289 956 let mut doc = document.clone(); 1290 957 move |evt: CompositionEvent| { 1291 - let data = evt.data().data(); 1292 - tracing::trace!( 1293 - data = %data, 1294 - "compositionstart" 1295 - ); 1296 - // Delete selection if present (composition replaces it) 1297 - let sel = doc.selection.write().take(); 1298 - if let Some(sel) = sel { 1299 - let (start, end) = 1300 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1301 - tracing::trace!( 1302 - start, 1303 - end, 1304 - "compositionstart: deleting selection" 1305 - ); 1306 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1307 - doc.cursor.write().offset = start; 1308 - } 1309 - 1310 - let cursor_offset = doc.cursor.read().offset; 1311 - tracing::trace!( 1312 - cursor = cursor_offset, 1313 - "compositionstart: setting composition state" 1314 - ); 1315 - doc.composition.set(Some(CompositionState { 1316 - start_offset: cursor_offset, 1317 - text: data, 1318 - })); 958 + handle_compositionstart(evt, &mut doc); 1319 959 } 1320 960 }, 1321 961 1322 962 oncompositionupdate: { 1323 963 let mut doc = document.clone(); 1324 964 move |evt: CompositionEvent| { 1325 - let data = evt.data().data(); 1326 - tracing::trace!( 1327 - data = %data, 1328 - "compositionupdate" 1329 - ); 1330 - let mut comp_guard = doc.composition.write(); 1331 - if let Some(ref mut comp) = *comp_guard { 1332 - comp.text = data; 1333 - } else { 1334 - tracing::debug!("compositionupdate without active composition state"); 1335 - } 965 + handle_compositionupdate(evt, &mut doc); 1336 966 } 1337 967 }, 1338 968 1339 969 oncompositionend: { 1340 970 let mut doc = document.clone(); 1341 971 move |evt: CompositionEvent| { 1342 - let final_text = evt.data().data(); 1343 - tracing::trace!( 1344 - data = %final_text, 1345 - "compositionend" 1346 - ); 1347 - // Record when composition ended for Safari timing workaround 1348 - doc.composition_ended_at.set(Some(web_time::Instant::now())); 1349 - 1350 - let comp = doc.composition.write().take(); 1351 - if let Some(comp) = comp { 1352 - tracing::debug!( 1353 - start_offset = comp.start_offset, 1354 - final_text = %final_text, 1355 - chars = final_text.chars().count(), 1356 - "compositionend: inserting text" 1357 - ); 1358 - 1359 - if !final_text.is_empty() { 1360 - let mut delete_start = comp.start_offset; 1361 - while delete_start > 0 { 1362 - match doc.char_at(delete_start - 1) { 1363 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 1364 - _ => break, 1365 - } 1366 - } 1367 - 1368 - let cursor_offset = doc.cursor.read().offset; 1369 - let zw_count = cursor_offset - delete_start; 1370 - if zw_count > 0 { 1371 - // Splice: delete zero-width chars and insert new char in one op 1372 - let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 1373 - doc.cursor.write().offset = delete_start + final_text.chars().count(); 1374 - } else if cursor_offset == doc.len_chars() { 1375 - // Fast path: append at end 1376 - let _ = doc.push_tracked(&final_text); 1377 - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1378 - } else { 1379 - let _ = doc.insert_tracked(cursor_offset, &final_text); 1380 - doc.cursor.write().offset = comp.start_offset + final_text.chars().count(); 1381 - } 1382 - } 1383 - } else { 1384 - tracing::debug!("compositionend without active composition state"); 1385 - } 972 + handle_compositionend(evt, &mut doc); 1386 973 } 1387 974 }, 1388 975 } ··· 1427 1014 on_image: { 1428 1015 let mut doc = document.clone(); 1429 1016 move |uploaded: super::image_upload::UploadedImage| { 1430 - // Build data URL for immediate preview 1431 - use base64::{Engine, engine::general_purpose::STANDARD}; 1432 - let data_url = format!( 1433 - "data:{};base64,{}", 1434 - uploaded.mime_type, 1435 - STANDARD.encode(&uploaded.data) 1017 + super::image_upload::handle_image_upload( 1018 + uploaded, 1019 + &mut doc, 1020 + &mut image_resolver, 1021 + &auth_state, 1022 + &fetcher, 1436 1023 ); 1437 - 1438 - // Add to resolver for immediate display 1439 - let name = uploaded.name.clone(); 1440 - image_resolver.with_mut(|resolver| { 1441 - resolver.add_pending(name.clone(), data_url); 1442 - }); 1443 - 1444 - // Insert markdown image syntax at cursor 1445 - let alt_text = if uploaded.alt.is_empty() { 1446 - name.clone() 1447 - } else { 1448 - uploaded.alt.clone() 1449 - }; 1450 - 1451 - // Check if authenticated and get DID for draft path 1452 - let auth = auth_state.read(); 1453 - let did_for_path = auth.did.clone(); 1454 - let is_authenticated = auth.is_authenticated(); 1455 - drop(auth); 1456 - 1457 - // Pre-generate TID for the blob rkey (used in draft path and upload) 1458 - let blob_tid = jacquard::types::tid::Ticker::new().next(None); 1459 - 1460 - // Build markdown with proper draft path if authenticated 1461 - let markdown = if let Some(ref did) = did_for_path { 1462 - format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) 1463 - } else { 1464 - // Fallback for unauthenticated - simple path (won't be publishable anyway) 1465 - format!("![{}](/image/{})", alt_text, name) 1466 - }; 1467 - 1468 - let pos = doc.cursor.read().offset; 1469 - let _ = doc.insert_tracked(pos, &markdown); 1470 - doc.cursor.write().offset = pos + markdown.chars().count(); 1471 - 1472 - // Upload to PDS in background if authenticated 1473 - if is_authenticated { 1474 - let fetcher = fetcher.clone(); 1475 - let name_for_upload = name.clone(); 1476 - let alt_for_upload = alt_text.clone(); 1477 - let data = uploaded.data.clone(); 1478 - let mut doc_for_spawn = doc.clone(); 1479 - 1480 - spawn(async move { 1481 - let client = fetcher.get_client(); 1482 - 1483 - // Clone data for cache pre-warming 1484 - let data_for_cache = data.clone(); 1485 - 1486 - // Use pre-generated TID as rkey for the blob record 1487 - let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 1488 - .expect("TID is valid record key"); 1489 - 1490 - // Upload blob and create temporary PublishedBlob record 1491 - match client.publish_blob(data, &name_for_upload, Some(rkey)).await { 1492 - Ok((strong_ref, published_blob)) => { 1493 - // Get DID from fetcher 1494 - let did = match fetcher.current_did().await { 1495 - Some(d) => d, 1496 - None => { 1497 - tracing::warn!("No DID available"); 1498 - return; 1499 - } 1500 - }; 1501 - 1502 - // Extract rkey from the AT-URI 1503 - let blob_rkey = match strong_ref.uri.rkey() { 1504 - Some(rkey) => rkey.0.clone().into_static(), 1505 - None => { 1506 - tracing::warn!("No rkey in PublishedBlob URI"); 1507 - return; 1508 - } 1509 - }; 1510 - 1511 - let cid = published_blob.upload.blob().cid().clone().into_static(); 1512 - 1513 - let name_for_resolver = name_for_upload.clone(); 1514 - let image = Image::new() 1515 - .alt(alt_for_upload.to_cowstr()) 1516 - .image(published_blob.upload) 1517 - .name(name_for_upload.to_cowstr()) 1518 - .build(); 1519 - doc_for_spawn.add_image(&image, Some(&strong_ref.uri)); 1520 - 1521 - // Promote from pending to uploaded in resolver 1522 - let ident = AtIdentifier::Did(did); 1523 - image_resolver.with_mut(|resolver| { 1524 - resolver.promote_to_uploaded( 1525 - &name_for_resolver, 1526 - blob_rkey, 1527 - ident, 1528 - ); 1529 - }); 1530 - 1531 - tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 1532 - 1533 - // Pre-warm server cache with blob bytes 1534 - #[cfg(feature = "fullstack-server")] 1535 - { 1536 - use jacquard::smol_str::ToSmolStr; 1537 - if let Err(e) = crate::data::cache_blob_bytes( 1538 - cid.to_smolstr(), 1539 - Some(name_for_resolver.into()), 1540 - None, 1541 - data_for_cache.into(), 1542 - ).await { 1543 - tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 1544 - } 1545 - } 1546 - } 1547 - Err(e) => { 1548 - tracing::error!(error = %e, "Failed to upload image"); 1549 - // Image stays as data URL - will work for preview but not publish 1550 - } 1551 - } 1552 - }); 1553 - } else { 1554 - tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 1555 - } 1556 1024 } 1557 1025 }, 1558 1026 }
+8
crates/weaver-app/src/components/editor/document.rs
··· 1132 1132 self.composition.set(composition); 1133 1133 } 1134 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 + 1135 1143 fn undo(&mut self) -> bool { 1136 1144 // Sync Loro cursor to current position BEFORE undo 1137 1145 // so it tracks through the undo operation.
+3 -145
crates/weaver-app/src/components/editor/dom_sync.rs
··· 1 1 //! DOM synchronization for the markdown editor. 2 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; 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. 18 5 19 6 // Re-export from browser crate. 20 7 #[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 - } 8 + pub use weaver_editor_browser::update_paragraph_dom;
+185 -1
crates/weaver-app/src/components/editor/image_upload.rs
··· 5 5 6 6 use base64::{Engine, engine::general_purpose::STANDARD}; 7 7 use dioxus::prelude::*; 8 - use jacquard::bytes::Bytes; 8 + use jacquard::IntoStatic; 9 + use jacquard::cowstr::ToCowStr; 10 + use jacquard::types::ident::AtIdentifier; 11 + use jacquard::{bytes::Bytes, types::tid::Tid}; 9 12 use mime_sniffer::MimeTypeSniffer; 13 + 14 + use super::document::SignalEditorDocument; 15 + use crate::auth::AuthState; 16 + use crate::fetch::Fetcher; 17 + use weaver_api::sh_weaver::embed::images::Image; 18 + use weaver_editor_core::{EditorDocument, EditorImageResolver}; 10 19 11 20 use crate::components::{ 12 21 button::{Button, ButtonVariant}, ··· 175 184 } 176 185 } 177 186 } 187 + 188 + /// Handle an uploaded image: add to resolver, insert markdown, and upload to PDS. 189 + /// 190 + /// This is the main handler for when an image is confirmed via the upload dialog. 191 + /// It: 192 + /// 1. Creates a data URL for immediate preview 193 + /// 2. Adds to the image resolver for display 194 + /// 3. Inserts markdown image syntax at cursor 195 + /// 4. If authenticated, uploads to PDS in background 196 + #[allow(clippy::too_many_arguments)] 197 + pub fn handle_image_upload( 198 + uploaded: UploadedImage, 199 + doc: &mut SignalEditorDocument, 200 + image_resolver: &mut Signal<EditorImageResolver>, 201 + auth_state: &Signal<AuthState>, 202 + fetcher: &Fetcher, 203 + ) { 204 + // Build data URL for immediate preview. 205 + let data_url = format!( 206 + "data:{};base64,{}", 207 + uploaded.mime_type, 208 + STANDARD.encode(&uploaded.data) 209 + ); 210 + 211 + // Add to resolver for immediate display. 212 + let name = uploaded.name.clone(); 213 + image_resolver.with_mut(|resolver| { 214 + resolver.add_pending(name.clone(), data_url); 215 + }); 216 + 217 + // Insert markdown image syntax at cursor. 218 + let alt_text = if uploaded.alt.is_empty() { 219 + name.clone() 220 + } else { 221 + uploaded.alt.clone() 222 + }; 223 + 224 + // Check if authenticated and get DID for draft path. 225 + let auth = auth_state.read(); 226 + let did_for_path = auth.did.clone(); 227 + let is_authenticated = auth.is_authenticated(); 228 + drop(auth); 229 + 230 + // Pre-generate TID for the blob rkey (used in draft path and upload). 231 + let blob_tid = jacquard::types::tid::Ticker::new().next(None); 232 + 233 + // Build markdown with proper draft path if authenticated. 234 + let markdown = if let Some(ref did) = did_for_path { 235 + format!( 236 + "![{}](/image/{}/draft/{}/{})", 237 + alt_text, 238 + did, 239 + blob_tid.as_str(), 240 + name 241 + ) 242 + } else { 243 + // Fallback for unauthenticated - simple path (won't be publishable anyway). 244 + format!("![{}](/image/{})", alt_text, name) 245 + }; 246 + 247 + let pos = doc.cursor_offset(); 248 + doc.insert(pos, &markdown); 249 + 250 + // Upload to PDS in background if authenticated. 251 + if is_authenticated { 252 + let fetcher = fetcher.clone(); 253 + let name_for_upload = name.clone(); 254 + let alt_for_upload = alt_text.clone(); 255 + let data = uploaded.data.clone(); 256 + let mut doc_for_spawn = doc.clone(); 257 + let mut resolver_for_spawn = *image_resolver; 258 + 259 + spawn(async move { 260 + upload_image_to_pds( 261 + &fetcher, 262 + &mut doc_for_spawn, 263 + &mut resolver_for_spawn, 264 + data, 265 + name_for_upload, 266 + alt_for_upload, 267 + blob_tid, 268 + ) 269 + .await; 270 + }); 271 + } else { 272 + tracing::debug!(name = %name, "Image added with data URL (not authenticated)"); 273 + } 274 + } 275 + 276 + /// Upload image to PDS and update resolver. 277 + async fn upload_image_to_pds( 278 + fetcher: &Fetcher, 279 + doc: &mut SignalEditorDocument, 280 + image_resolver: &mut Signal<EditorImageResolver>, 281 + data: Bytes, 282 + name: String, 283 + alt: String, 284 + blob_tid: Tid, 285 + ) { 286 + let client = fetcher.get_client(); 287 + use weaver_common::WeaverExt; 288 + 289 + // Clone data for cache pre-warming. 290 + #[cfg(feature = "fullstack-server")] 291 + let data_for_cache = data.clone(); 292 + 293 + // Use pre-generated TID as rkey for the blob record. 294 + let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) 295 + .expect("TID is valid record key"); 296 + 297 + // Upload blob and create temporary PublishedBlob record. 298 + match client.publish_blob(data, &name, Some(rkey)).await { 299 + Ok((strong_ref, published_blob)) => { 300 + // Get DID from fetcher. 301 + let did = match fetcher.current_did().await { 302 + Some(d) => d, 303 + None => { 304 + tracing::warn!("No DID available"); 305 + return; 306 + } 307 + }; 308 + 309 + // Extract rkey from the AT-URI. 310 + let blob_rkey = match strong_ref.uri.rkey() { 311 + Some(rkey) => rkey.0.clone().into_static(), 312 + None => { 313 + tracing::warn!("No rkey in PublishedBlob URI"); 314 + return; 315 + } 316 + }; 317 + 318 + let cid = published_blob.upload.blob().cid().clone().into_static(); 319 + 320 + let name_for_resolver = name.clone(); 321 + let image = Image::new() 322 + .alt(alt.to_cowstr()) 323 + .image(published_blob.upload) 324 + .name(name.to_cowstr()) 325 + .build(); 326 + doc.add_image(&image, Some(&strong_ref.uri)); 327 + 328 + // Promote from pending to uploaded in resolver. 329 + let ident = AtIdentifier::Did(did); 330 + image_resolver.with_mut(|resolver| { 331 + resolver.promote_to_uploaded(&name_for_resolver, blob_rkey, ident); 332 + }); 333 + 334 + tracing::info!(name = %name_for_resolver, "Image uploaded to PDS"); 335 + 336 + // Pre-warm server cache with blob bytes. 337 + #[cfg(feature = "fullstack-server")] 338 + { 339 + use jacquard::smol_str::ToSmolStr; 340 + if let Err(e) = crate::data::cache_blob_bytes( 341 + cid.to_smolstr(), 342 + Some(name_for_resolver.into()), 343 + None, 344 + data_for_cache.into(), 345 + ) 346 + .await 347 + { 348 + tracing::warn!(error = %e, "Failed to pre-warm blob cache"); 349 + } 350 + } 351 + 352 + // Suppress unused variable warning when fullstack-server is disabled. 353 + #[cfg(not(feature = "fullstack-server"))] 354 + let _ = cid; 355 + } 356 + Err(e) => { 357 + tracing::error!(error = %e, "Failed to upload image"); 358 + // Image stays as data URL - will work for preview but not publish. 359 + } 360 + } 361 + }
+289
crates/weaver-app/src/components/editor/sync.rs
··· 10 10 use std::collections::HashMap; 11 11 12 12 use super::document::{LoadedDocState, SignalEditorDocument}; 13 + use super::publish::load_entry_for_editing; 13 14 use crate::fetch::Fetcher; 14 15 use jacquard::IntoStatic; 16 + use jacquard::identity::resolver::IdentityResolver; 15 17 use jacquard::prelude::*; 18 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 16 19 use jacquard::types::ident::AtIdentifier; 17 20 use jacquard::types::string::{AtUri, Cid}; 18 21 use loro::LoroDoc; ··· 905 908 } 906 909 } 907 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 == &current_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 9 is_valid_cursor_position, 10 10 }; 11 11 12 + use weaver_editor_core::{EditorDocument, Selection, SyntaxSpanInfo}; 13 + 12 14 use crate::cursor::restore_cursor_position; 15 + use crate::update_syntax_visibility; 13 16 14 17 /// Result of syncing cursor from DOM. 15 18 #[derive(Debug, Clone)] ··· 370 373 } 371 374 372 375 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); 373 425 } 374 426 375 427 /// Update paragraph DOM elements incrementally.
+108
crates/weaver-editor-browser/src/events.rs
··· 561 561 false 562 562 } 563 563 } 564 + 565 + // === Composition (IME) event handlers === 566 + 567 + /// Handle composition start event. 568 + /// 569 + /// Clears any existing selection (composition replaces it) and sets up 570 + /// composition state tracking. 571 + #[cfg(feature = "dioxus")] 572 + pub fn handle_compositionstart<D: EditorDocument>( 573 + evt: dioxus_core::Event<dioxus_html::CompositionData>, 574 + doc: &mut D, 575 + ) { 576 + let data = evt.data().data(); 577 + tracing::trace!(data = %data, "compositionstart"); 578 + 579 + // Delete selection if present (composition replaces it). 580 + if let Some(sel) = doc.selection() { 581 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 582 + tracing::trace!(start, end, "compositionstart: deleting selection"); 583 + doc.delete(start..end); 584 + doc.set_cursor_offset(start); 585 + doc.set_selection(None); 586 + } 587 + 588 + let cursor_offset = doc.cursor_offset(); 589 + tracing::trace!(cursor = cursor_offset, "compositionstart: setting composition state"); 590 + doc.set_composition(Some(weaver_editor_core::CompositionState { 591 + start_offset: cursor_offset, 592 + text: data, 593 + })); 594 + } 595 + 596 + /// Handle composition update event. 597 + /// 598 + /// Updates the composition text as the user types or selects IME suggestions. 599 + #[cfg(feature = "dioxus")] 600 + pub fn handle_compositionupdate<D: EditorDocument>( 601 + evt: dioxus_core::Event<dioxus_html::CompositionData>, 602 + doc: &mut D, 603 + ) { 604 + let data = evt.data().data(); 605 + tracing::trace!(data = %data, "compositionupdate"); 606 + 607 + if let Some(mut comp) = doc.composition() { 608 + comp.text = data; 609 + doc.set_composition(Some(comp)); 610 + } else { 611 + tracing::debug!("compositionupdate without active composition state"); 612 + } 613 + } 614 + 615 + /// Handle composition end event. 616 + /// 617 + /// Finalizes the composition by inserting the final text into the document. 618 + /// Also handles zero-width character cleanup that some IMEs leave behind. 619 + #[cfg(feature = "dioxus")] 620 + pub fn handle_compositionend<D: EditorDocument>( 621 + evt: dioxus_core::Event<dioxus_html::CompositionData>, 622 + doc: &mut D, 623 + ) { 624 + let final_text = evt.data().data(); 625 + tracing::trace!(data = %final_text, "compositionend"); 626 + 627 + // Record when composition ended for Safari timing workaround. 628 + doc.set_composition_ended_now(); 629 + 630 + let comp = doc.composition(); 631 + doc.set_composition(None); 632 + 633 + if let Some(comp) = comp { 634 + tracing::debug!( 635 + start_offset = comp.start_offset, 636 + final_text = %final_text, 637 + chars = final_text.chars().count(), 638 + "compositionend: inserting text" 639 + ); 640 + 641 + if !final_text.is_empty() { 642 + // Clean up zero-width characters that IMEs sometimes leave behind. 643 + let mut delete_start = comp.start_offset; 644 + while delete_start > 0 { 645 + match doc.char_at(delete_start - 1) { 646 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 647 + _ => break, 648 + } 649 + } 650 + 651 + let cursor_offset = doc.cursor_offset(); 652 + let zw_count = cursor_offset - delete_start; 653 + 654 + if zw_count > 0 { 655 + // Splice: delete zero-width chars and insert new char in one op. 656 + doc.replace(delete_start..delete_start + zw_count, &final_text); 657 + doc.set_cursor_offset(delete_start + final_text.chars().count()); 658 + } else if cursor_offset == doc.len_chars() { 659 + // Fast path: append at end. 660 + doc.push(&final_text); 661 + doc.set_cursor_offset(comp.start_offset + final_text.chars().count()); 662 + } else { 663 + // Insert at cursor position. 664 + doc.insert(cursor_offset, &final_text); 665 + doc.set_cursor_offset(comp.start_offset + final_text.chars().count()); 666 + } 667 + } 668 + } else { 669 + tracing::debug!("compositionend without active composition state"); 670 + } 671 + }
+6 -2
crates/weaver-editor-browser/src/lib.rs
··· 63 63 64 64 // DOM sync types 65 65 pub use dom_sync::{ 66 - BrowserCursorSync, CursorSyncResult, dom_position_to_text_offset, sync_cursor_from_dom_impl, 67 - update_paragraph_dom, 66 + BrowserCursorSync, CursorSyncResult, dom_position_to_text_offset, sync_cursor_and_visibility, 67 + sync_cursor_from_dom, sync_cursor_from_dom_impl, update_paragraph_dom, 68 68 }; 69 69 70 70 // Event handling ··· 88 88 pub use clipboard::{BrowserClipboard, write_html_to_clipboard}; 89 89 #[cfg(feature = "dioxus")] 90 90 pub use clipboard::{handle_copy, handle_cut, handle_paste}; 91 + 92 + // Composition (IME) handlers 93 + #[cfg(feature = "dioxus")] 94 + pub use events::{handle_compositionend, handle_compositionstart, handle_compositionupdate};
+48
crates/weaver-editor-core/src/document.rs
··· 64 64 /// Set the composition state. 65 65 fn set_composition(&mut self, composition: Option<CompositionState>); 66 66 67 + /// Get the timestamp when composition last ended (Safari timing workaround). 68 + /// 69 + /// Returns None if composition never ended or implementation doesn't track it. 70 + fn composition_ended_at(&self) -> Option<web_time::Instant>; 71 + 72 + /// Record that composition ended now (Safari timing workaround). 73 + /// 74 + /// Implementations that don't need Safari workarounds can make this a no-op. 75 + fn set_composition_ended_now(&mut self); 76 + 67 77 // === Required: Cursor snap hint === 68 78 69 79 /// Get the pending snap direction hint. ··· 221 231 edit 222 232 } 223 233 234 + /// Append text at end of document. 235 + /// 236 + /// This is a fast path for appending - delegates to buffer's push() 237 + /// which may have an optimized implementation. 238 + fn push(&mut self, text: &str) -> EditInfo { 239 + let offset = self.buffer().len_chars(); 240 + let contains_newline = text.contains('\n'); 241 + let in_block_syntax_zone = self.is_in_block_syntax_zone(offset); 242 + 243 + self.buffer_mut().push(text); 244 + 245 + let inserted_len = text.chars().count(); 246 + self.set_cursor_offset(offset + inserted_len); 247 + 248 + let edit = EditInfo { 249 + edit_char_pos: offset, 250 + inserted_len, 251 + deleted_len: 0, 252 + contains_newline, 253 + in_block_syntax_zone, 254 + doc_len_after: self.buffer().len_chars(), 255 + timestamp: Instant::now(), 256 + }; 257 + 258 + self.set_last_edit(Some(edit.clone())); 259 + edit 260 + } 261 + 224 262 /// Delete the current selection, if any. 225 263 fn delete_selection(&mut self) -> Option<EditInfo> { 226 264 let sel = self.selection()?; ··· 279 317 selection: Option<Selection>, 280 318 last_edit: Option<EditInfo>, 281 319 composition: Option<CompositionState>, 320 + composition_ended_at: Option<web_time::Instant>, 282 321 pending_snap: Option<crate::SnapDirection>, 283 322 } 284 323 ··· 297 336 selection: None, 298 337 last_edit: None, 299 338 composition: None, 339 + composition_ended_at: None, 300 340 pending_snap: None, 301 341 } 302 342 } ··· 353 393 354 394 fn set_composition(&mut self, composition: Option<CompositionState>) { 355 395 self.composition = composition; 396 + } 397 + 398 + fn composition_ended_at(&self) -> Option<web_time::Instant> { 399 + self.composition_ended_at 400 + } 401 + 402 + fn set_composition_ended_now(&mut self) { 403 + self.composition_ended_at = Some(web_time::Instant::now()); 356 404 } 357 405 358 406 fn pending_snap(&self) -> Option<crate::SnapDirection> {
+14
crates/weaver-editor-core/src/text.rs
··· 28 28 /// Insert text at char offset. 29 29 fn insert(&mut self, char_offset: usize, text: &str); 30 30 31 + /// Append text at end. 32 + /// 33 + /// Default implementation calls insert at len_chars(). Override if 34 + /// the underlying buffer has a more efficient append operation. 35 + fn push(&mut self, text: &str) { 36 + self.insert(self.len_chars(), text); 37 + } 38 + 31 39 /// Delete char range. 32 40 fn delete(&mut self, char_range: Range<usize>); 33 41 ··· 149 157 doc_len_after: self.rope.len_chars(), 150 158 timestamp: Instant::now(), 151 159 }); 160 + } 161 + 162 + // Ropey's insert is O(log n) regardless of position, so push is the same. 163 + // Override for consistency with trait. 164 + fn push(&mut self, text: &str) { 165 + self.insert(self.rope.len_chars(), text); 152 166 } 153 167 154 168 fn delete(&mut self, char_range: Range<usize>) {
+18
crates/weaver-editor-crdt/src/buffer.rs
··· 252 252 }); 253 253 } 254 254 255 + fn push(&mut self, text: &str) { 256 + let char_offset = self.content.len_unicode(); 257 + let contains_newline = text.contains('\n'); 258 + let in_block_syntax_zone = self.is_in_block_syntax_zone(char_offset); 259 + 260 + self.content.push_str(text).ok(); 261 + 262 + self.inner.borrow_mut().last_edit = Some(EditInfo { 263 + edit_char_pos: char_offset, 264 + inserted_len: text.chars().count(), 265 + deleted_len: 0, 266 + contains_newline, 267 + in_block_syntax_zone, 268 + doc_len_after: self.content.len_unicode(), 269 + timestamp: Instant::now(), 270 + }); 271 + } 272 + 255 273 fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> { 256 274 if char_range.end > self.content.len_unicode() { 257 275 return None;
+110
docs/graph-data.json
··· 2001 2001 "created_at": "2026-01-06T17:56:50.444969376-05:00", 2002 2002 "updated_at": "2026-01-06T17:56:50.444969376-05:00", 2003 2003 "metadata_json": "{\"confidence\":95}" 2004 + }, 2005 + { 2006 + "id": 184, 2007 + "change_id": "179b522e-15d2-44e2-8070-ba5323450d39", 2008 + "node_type": "outcome", 2009 + "title": "Clipboard handlers moved to browser crate - handle_paste/cut/copy in clipboard.rs with feature-gated dioxus deps (dioxus-core, dioxus-html, dioxus-web), input.rs deleted from app", 2010 + "description": null, 2011 + "status": "pending", 2012 + "created_at": "2026-01-06T18:54:49.482341819-05:00", 2013 + "updated_at": "2026-01-06T18:54:49.482341819-05:00", 2014 + "metadata_json": "{\"confidence\":95}" 2015 + }, 2016 + { 2017 + "id": 185, 2018 + "change_id": "57abe043-ecbe-409d-8d0e-3ee2597856b9", 2019 + "node_type": "outcome", 2020 + "title": "Math click handler moved to browser crate - handle_math_click() and get_math_click_offset() in events.rs, onclick handler in component.rs simplified from ~35 to ~15 lines", 2021 + "description": null, 2022 + "status": "pending", 2023 + "created_at": "2026-01-06T18:54:54.389267509-05:00", 2024 + "updated_at": "2026-01-06T18:54:54.389267509-05:00", 2025 + "metadata_json": "{\"confidence\":95}" 2026 + }, 2027 + { 2028 + "id": 186, 2029 + "change_id": "0b0c7b27-0bf2-4587-a2a8-34dfc9be3a28", 2030 + "node_type": "outcome", 2031 + "title": "Composition handlers moved to browser crate - handle_compositionstart/update/end in events.rs with feature-gated dioxus, added composition_ended_at to EditorDocument trait for Safari workaround, added push() to TextBuffer and EditorDocument traits", 2032 + "description": null, 2033 + "status": "pending", 2034 + "created_at": "2026-01-06T19:07:41.730306306-05:00", 2035 + "updated_at": "2026-01-06T19:07:41.730306306-05:00", 2036 + "metadata_json": "{\"confidence\":95}" 2037 + }, 2038 + { 2039 + "id": 187, 2040 + "change_id": "f94e4212-4d47-442d-aedc-bdacaf4babd8", 2041 + "node_type": "outcome", 2042 + "title": "Image upload extracted: handle_image_upload() and upload_image_to_pds() in image_upload.rs, component on_image handler reduced from ~130 to ~10 lines", 2043 + "description": null, 2044 + "status": "pending", 2045 + "created_at": "2026-01-06T19:18:49.734649882-05:00", 2046 + "updated_at": "2026-01-06T19:18:49.734649882-05:00", 2047 + "metadata_json": "{\"confidence\":95}" 2048 + }, 2049 + { 2050 + "id": 188, 2051 + "change_id": "ff80e6e7-36d0-426f-8db4-a64705ce0376", 2052 + "node_type": "outcome", 2053 + "title": "Loading logic extracted: load_editor_state(), create_state_from_entry(), prefetch_embeds_for_entry() in sync.rs. MarkdownEditor reduced from ~350 to ~85 lines", 2054 + "description": null, 2055 + "status": "pending", 2056 + "created_at": "2026-01-06T19:26:35.964103095-05:00", 2057 + "updated_at": "2026-01-06T19:26:35.964103095-05:00", 2058 + "metadata_json": "{\"confidence\":95}" 2004 2059 } 2005 2060 ], 2006 2061 "edges": [ ··· 4038 4093 "weight": 1.0, 4039 4094 "rationale": "Action completed", 4040 4095 "created_at": "2026-01-06T17:56:55.250053755-05:00" 4096 + }, 4097 + { 4098 + "id": 187, 4099 + "from_node_id": 182, 4100 + "to_node_id": 184, 4101 + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4102 + "to_change_id": "179b522e-15d2-44e2-8070-ba5323450d39", 4103 + "edge_type": "leads_to", 4104 + "weight": 1.0, 4105 + "rationale": "Clipboard handlers extracted as part of component simplification", 4106 + "created_at": "2026-01-06T18:54:59.446313320-05:00" 4107 + }, 4108 + { 4109 + "id": 188, 4110 + "from_node_id": 182, 4111 + "to_node_id": 185, 4112 + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4113 + "to_change_id": "57abe043-ecbe-409d-8d0e-3ee2597856b9", 4114 + "edge_type": "leads_to", 4115 + "weight": 1.0, 4116 + "rationale": "Math click handler extracted as part of component simplification", 4117 + "created_at": "2026-01-06T18:54:59.463812727-05:00" 4118 + }, 4119 + { 4120 + "id": 189, 4121 + "from_node_id": 182, 4122 + "to_node_id": 186, 4123 + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4124 + "to_change_id": "0b0c7b27-0bf2-4587-a2a8-34dfc9be3a28", 4125 + "edge_type": "leads_to", 4126 + "weight": 1.0, 4127 + "rationale": "Composition handlers extracted as part of component simplification", 4128 + "created_at": "2026-01-06T19:07:47.591441650-05:00" 4129 + }, 4130 + { 4131 + "id": 190, 4132 + "from_node_id": 186, 4133 + "to_node_id": 187, 4134 + "from_change_id": "0b0c7b27-0bf2-4587-a2a8-34dfc9be3a28", 4135 + "to_change_id": "f94e4212-4d47-442d-aedc-bdacaf4babd8", 4136 + "edge_type": "leads_to", 4137 + "weight": 1.0, 4138 + "rationale": "Image upload extraction completed", 4139 + "created_at": "2026-01-06T19:19:01.368609959-05:00" 4140 + }, 4141 + { 4142 + "id": 191, 4143 + "from_node_id": 182, 4144 + "to_node_id": 188, 4145 + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4146 + "to_change_id": "ff80e6e7-36d0-426f-8db4-a64705ce0376", 4147 + "edge_type": "leads_to", 4148 + "weight": 1.0, 4149 + "rationale": "Loading logic extraction part of component.rs split", 4150 + "created_at": "2026-01-06T19:26:48.871648902-05:00" 4041 4151 } 4042 4152 ] 4043 4153 }