at main 1028 lines 47 kB view raw
1//! The main MarkdownEditor component. 2 3use super::actions::{ 4 EditorAction, KeydownResult, Range, execute_action, handle_keydown_with_bindings, 5}; 6use super::document::SignalEditorDocument; 7#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 8use super::dom_sync::update_paragraph_dom; 9use super::publish::PublishButton; 10use super::remote_cursors::RemoteCursors; 11use super::storage; 12use super::sync::{LoadEditorResult, SyncStatus, load_editor_state}; 13use super::toolbar::EditorToolbar; 14use crate::auth::AuthState; 15use crate::components::collab::CollaboratorAvatars; 16use crate::components::editor::collab::CollabCoordinator; 17use crate::components::editor::{LoadedDocState, ReportButton}; 18use crate::fetch::Fetcher; 19use dioxus::prelude::*; 20use jacquard::IntoStatic; 21use jacquard::smol_str::SmolStr; 22#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 23use jacquard::types::blob::BlobRef; 24#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 25use weaver_editor_browser::{BeforeInputContext, BeforeInputResult, update_syntax_visibility}; 26use weaver_editor_browser::{ 27 handle_compositionend, handle_compositionstart, handle_compositionupdate, handle_copy, 28 handle_cut, handle_paste, platform, sync_cursor_and_visibility, 29}; 30use weaver_editor_core::EditorImageResolver; 31#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 32use weaver_editor_core::InputType; 33use weaver_editor_core::ParagraphRender; 34use weaver_editor_core::SnapDirection; 35use weaver_editor_core::apply_formatting; 36 37/// Wrapper component that handles loading document state before rendering the editor. 38/// 39/// Loads and merges state from: 40/// - localStorage (local CRDT snapshot) 41/// - PDS edit state (if editing published entry) 42/// - Entry content (if no edit state exists) 43/// 44/// # Props 45/// - `initial_content`: Optional initial markdown content (for new entries) 46/// - `entry_uri`: Optional AT-URI of an existing entry to edit 47/// - `target_notebook`: Optional notebook title to add the entry to when publishing 48/// - `entry_index`: Optional index of entries for wikilink validation 49#[component] 50pub fn MarkdownEditor( 51 initial_content: Option<String>, 52 entry_uri: Option<String>, 53 target_notebook: Option<SmolStr>, 54 entry_index: Option<weaver_common::EntryIndex>, 55) -> Element { 56 let fetcher = use_context::<Fetcher>(); 57 58 let draft_key = use_hook(|| { 59 entry_uri.clone().unwrap_or_else(|| { 60 format!( 61 "new:{}", 62 jacquard::types::tid::Ticker::new().next(None).as_str() 63 ) 64 }) 65 }); 66 67 let parsed_uri = entry_uri.as_ref().and_then(|s| { 68 jacquard::types::string::AtUri::new(s) 69 .ok() 70 .map(|u| u.into_static()) 71 }); 72 let draft_key_for_render = draft_key.clone(); 73 let target_notebook_for_render = target_notebook.clone(); 74 75 let load_resource = use_resource(move || { 76 let fetcher = fetcher.clone(); 77 let draft_key = draft_key.clone(); 78 let entry_uri = parsed_uri.clone(); 79 let initial_content = initial_content.clone(); 80 let target_notebook = target_notebook.clone(); 81 82 async move { 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 91 } 92 }); 93 94 match &*load_resource.read() { 95 Some(LoadEditorResult::Loaded(state)) => { 96 rsx! { 97 MarkdownEditorInner { 98 key: "{draft_key_for_render}", 99 draft_key: draft_key_for_render.clone(), 100 loaded_state: state.clone(), 101 target_notebook: target_notebook_for_render.clone(), 102 entry_index: entry_index.clone(), 103 } 104 } 105 } 106 Some(LoadEditorResult::Failed(err)) => { 107 rsx! { 108 div { class: "editor-error", 109 "Failed to load: {err}" 110 } 111 } 112 } 113 None => { 114 rsx! { 115 div { class: "editor-loading", 116 "Loading..." 117 } 118 } 119 } 120 } 121} 122 123/// Inner markdown editor component (actual editor implementation). 124/// 125/// # Features 126/// - Loro CRDT-based text storage with undo/redo support 127/// - Event interception for full control over editing operations 128/// - Toolbar formatting buttons 129/// - LocalStorage auto-save with debouncing 130/// - PDS sync with auto-save 131/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 132#[component] 133fn MarkdownEditorInner( 134 draft_key: String, 135 loaded_state: LoadedDocState, 136 target_notebook: Option<SmolStr>, 137 /// Optional entry index for wikilink validation in the editor 138 entry_index: Option<weaver_common::EntryIndex>, 139) -> Element { 140 // Context for authenticated API calls 141 let fetcher = use_context::<Fetcher>(); 142 let auth_state = use_context::<Signal<AuthState>>(); 143 144 #[allow(unused_mut)] 145 let mut document = use_hook(|| { 146 let mut doc = SignalEditorDocument::from_loaded_state(loaded_state.clone()); 147 148 // Seed collected_refs with existing record embeds so they get fetched/rendered 149 let record_embeds = doc.record_embeds(); 150 if !record_embeds.is_empty() { 151 let refs: Vec<weaver_common::ExtractedRef> = record_embeds 152 .into_iter() 153 .filter_map(|embed| { 154 embed.name.map(|name| weaver_common::ExtractedRef::AtEmbed { 155 uri: name.to_string(), 156 alt_text: None, 157 }) 158 }) 159 .collect(); 160 doc.set_collected_refs(refs); 161 } 162 163 storage::save_to_storage(&doc, &draft_key).ok(); 164 doc 165 }); 166 let editor_id = "markdown-editor"; 167 let mut render_cache = use_signal(|| weaver_editor_browser::RenderCache::default()); 168 169 // Populate resolver from existing images if editing a published entry 170 let mut image_resolver: Signal<EditorImageResolver> = use_signal(|| { 171 let images = document.images(); 172 if let (false, Some(ref r)) = (images.is_empty(), document.entry_ref()) { 173 let ident = r.uri.authority().clone().into_static(); 174 let entry_rkey = r.uri.rkey().map(|rk| rk.0.clone().into_static()); 175 EditorImageResolver::from_images(&images, ident, entry_rkey) 176 } else { 177 EditorImageResolver::default() 178 } 179 }); 180 // Use pre-resolved content from loaded state (avoids embed pop-in) 181 let resolved_content = use_signal(|| loaded_state.resolved_content.clone()); 182 183 // Presence snapshot for remote collaborators (updated by collab coordinator) 184 let presence = use_signal(weaver_common::transport::PresenceSnapshot::default); 185 186 // Resource URI for real-time collab (entry URI if editing published entry) 187 let collab_resource_uri = document.entry_ref().map(|r| r.uri.to_string()); 188 189 let doc_for_memo = document.clone(); 190 let doc_for_refs = document.clone(); 191 let entry_index_for_memo = entry_index.clone(); 192 #[allow(unused_mut)] 193 let mut paragraphs = use_memo(move || { 194 // Read content_changed to establish reactive dependency 195 let _ = doc_for_memo.content_changed.read(); 196 let edit = doc_for_memo.last_edit(); 197 let cache = render_cache.peek(); 198 let resolver = image_resolver(); 199 let resolved = resolved_content(); 200 201 tracing::trace!( 202 "Rendering with {} pre-resolved embeds", 203 resolved.embed_content.len() 204 ); 205 206 let cursor_offset = doc_for_memo.cursor.read().offset; 207 let result = weaver_editor_core::render_paragraphs_incremental( 208 doc_for_memo.buffer(), 209 Some(&cache), 210 cursor_offset, 211 edit.as_ref(), 212 Some(&resolver), 213 entry_index_for_memo.as_ref(), 214 &resolved, 215 ); 216 let paras = result.paragraphs; 217 let new_cache = result.cache; 218 let refs = result.collected_refs; 219 let mut doc_for_spawn = doc_for_refs.clone(); 220 dioxus::prelude::spawn(async move { 221 render_cache.set(new_cache); 222 doc_for_spawn.set_collected_refs(refs); 223 }); 224 225 paras 226 }); 227 228 // Background fetch for AT embeds via worker 229 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 230 { 231 use dioxus::prelude::Writable; 232 use weaver_embed_worker::{EmbedWorkerHost, EmbedWorkerOutput}; 233 234 let resolved_content_for_fetch = resolved_content; 235 let mut embed_host: Signal<Option<EmbedWorkerHost>> = use_signal(|| None); 236 237 // Spawn embed worker on mount 238 let doc_for_embeds = document.clone(); 239 use_effect(move || { 240 // Callback for worker responses - uses write_unchecked since we're in a Fn closure 241 let on_output = move |output: EmbedWorkerOutput| match output { 242 EmbedWorkerOutput::Embeds { 243 results, 244 errors, 245 fetch_ms, 246 } => { 247 if !results.is_empty() { 248 let mut rc = resolved_content_for_fetch.write_unchecked(); 249 for (uri_str, html) in results { 250 if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri_str) { 251 rc.add_embed(at_uri, html, None); 252 } 253 } 254 tracing::debug!( 255 count = rc.embed_content.len(), 256 fetch_ms, 257 "embed worker fetched embeds" 258 ); 259 } 260 for (uri, err) in errors { 261 tracing::warn!("embed worker failed to fetch {}: {}", uri, err); 262 } 263 } 264 EmbedWorkerOutput::CacheCleared => { 265 tracing::debug!("embed worker cache cleared"); 266 } 267 }; 268 269 let host = EmbedWorkerHost::spawn("/embed_worker.js", on_output); 270 embed_host.set(Some(host)); 271 tracing::info!("Embed worker spawned"); 272 }); 273 274 // Send embeds to worker when collected_refs changes 275 use_effect(move || { 276 let refs = doc_for_embeds.collected_refs.read(); 277 let current_resolved = resolved_content_for_fetch.peek(); 278 279 // Find AT embeds that need fetching 280 let to_fetch: Vec<String> = refs 281 .iter() 282 .filter_map(|r| match r { 283 weaver_common::ExtractedRef::AtEmbed { uri, .. } => { 284 // Skip if already resolved 285 if let Ok(at_uri) = jacquard::types::string::AtUri::new_owned(uri) { 286 if current_resolved.get_embed_content(&at_uri).is_none() { 287 return Some(uri.clone()); 288 } 289 } 290 None 291 } 292 _ => None, 293 }) 294 .collect(); 295 296 if to_fetch.is_empty() { 297 return; 298 } 299 300 // Send to worker 301 if let Some(ref host) = *embed_host.peek() { 302 host.fetch_embeds(to_fetch); 303 } 304 }); 305 } 306 307 let mut new_tag = use_signal(String::new); 308 309 #[allow(unused)] 310 let offset_map = use_memo(move || { 311 paragraphs() 312 .iter() 313 .flat_map(|p| p.offset_map.iter().cloned()) 314 .collect::<Vec<_>>() 315 }); 316 let syntax_spans = use_memo(move || { 317 paragraphs() 318 .iter() 319 .flat_map(|p| p.syntax_spans.iter().cloned()) 320 .collect::<Vec<_>>() 321 }); 322 #[allow(unused_mut)] 323 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 324 325 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 326 let mut doc_for_dom = document.clone(); 327 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 328 use_effect(move || { 329 // Skip DOM updates during IME composition - browser controls the preview 330 if doc_for_dom.composition.read().is_some() { 331 tracing::debug!("skipping DOM update during composition"); 332 return; 333 } 334 335 tracing::trace!( 336 cursor = doc_for_dom.cursor.read().offset, 337 len = doc_for_dom.len_chars(), 338 "DOM update proceeding (not in composition)" 339 ); 340 341 let cursor_offset = doc_for_dom.cursor.read().offset; 342 let selection = *doc_for_dom.selection.read(); 343 344 let new_paras = paragraphs(); 345 let map = offset_map(); 346 let spans = syntax_spans(); 347 348 // Use peek() to avoid creating reactive dependency on cached_paragraphs 349 let prev = cached_paragraphs.peek().clone(); 350 351 let cursor_para_updated = 352 update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset, false); 353 354 // Store for next comparison AND for event handlers (write-only, no reactive read) 355 cached_paragraphs.set(new_paras.clone()); 356 357 // Update syntax visibility after DOM changes 358 update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); 359 }); 360 361 // Track last saved frontiers to detect changes (peek-only, no subscriptions) 362 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 363 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 364 365 // Store interval handle so it's dropped when component unmounts (prevents panic) 366 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 367 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None); 368 369 // Autosave interval - saves to localStorage when document changes 370 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 371 { 372 let doc_for_autosave = document.clone(); 373 let draft_key_for_autosave = draft_key.clone(); 374 use_effect(move || { 375 let mut doc = doc_for_autosave.clone(); 376 let draft_key = draft_key_for_autosave.clone(); 377 378 let interval = gloo_timers::callback::Interval::new(500, move || { 379 let current_frontiers = doc.state_frontiers(); 380 381 // Only save if frontiers changed (document was edited) 382 let needs_save = { 383 let last_frontiers = last_saved_frontiers.peek(); 384 match &*last_frontiers { 385 None => true, 386 Some(last) => &current_frontiers != last, 387 } 388 }; 389 390 if !needs_save { 391 return; 392 } 393 394 doc.sync_loro_cursor(); 395 let _ = storage::save_to_storage(&doc, &draft_key); 396 last_saved_frontiers.set(Some(current_frontiers)); 397 }); 398 399 interval_holder.set(Some(interval)); 400 }); 401 } 402 403 // Set up beforeinput listener for all text input handling. 404 // This is the primary handler for text insertion, deletion, etc. 405 // Keydown only handles shortcuts now. 406 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 407 type BeforeInputClosure = wasm_bindgen::closure::Closure<dyn FnMut(web_sys::InputEvent)>; 408 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 409 let mut beforeinput_closure: Signal<Option<BeforeInputClosure>> = use_signal(|| None); 410 411 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 412 let doc_for_beforeinput = document.clone(); 413 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 414 use_effect(move || { 415 use gloo_timers::callback::Timeout; 416 use wasm_bindgen::JsCast; 417 use wasm_bindgen::prelude::*; 418 419 let window = match web_sys::window() { 420 Some(w) => w, 421 None => return, 422 }; 423 let dom_document = match window.document() { 424 Some(d) => d, 425 None => return, 426 }; 427 let editor = match dom_document.get_element_by_id(editor_id) { 428 Some(e) => e, 429 None => return, 430 }; 431 432 let mut doc = doc_for_beforeinput.clone(); 433 let cached_paras = cached_paragraphs; 434 435 let closure: BeforeInputClosure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 436 let input_type_str = evt.input_type(); 437 tracing::debug!(input_type = %input_type_str, "beforeinput"); 438 439 let plat = platform::platform(); 440 let input_type = weaver_editor_browser::parse_browser_input_type(&input_type_str); 441 let is_composing = evt.is_composing(); 442 443 // Get target range from the event if available 444 let paras = cached_paras.peek().clone(); 445 let target_range = 446 weaver_editor_browser::get_target_range_from_event(&evt, editor_id, &paras); 447 let data = weaver_editor_browser::get_data_from_event(&evt); 448 let ctx = BeforeInputContext { 449 input_type: input_type.clone(), 450 data, 451 target_range, 452 is_composing, 453 platform: &plat, 454 }; 455 456 let current_range = weaver_editor_browser::get_current_range(&doc); 457 let result = weaver_editor_browser::handle_beforeinput(&mut doc, &ctx, current_range); 458 459 match result { 460 BeforeInputResult::Handled => { 461 evt.prevent_default(); 462 } 463 BeforeInputResult::PassThrough => { 464 // Let browser handle (e.g., during composition) 465 } 466 BeforeInputResult::HandledAsync => { 467 evt.prevent_default(); 468 // Async follow-up will happen elsewhere 469 } 470 BeforeInputResult::DeferredCheck { fallback_action } => { 471 // Android backspace workaround: let browser try first, 472 // check in 50ms if anything happened, if not execute fallback 473 let mut doc_for_timeout = doc.clone(); 474 let doc_len_before = doc.len_chars(); 475 476 Timeout::new(50, move || { 477 if doc_for_timeout.len_chars() == doc_len_before { 478 tracing::debug!("Android backspace fallback triggered"); 479 // Refocus to work around virtual keyboard issues 480 if let Some(window) = web_sys::window() { 481 if let Some(dom_doc) = window.document() { 482 if let Some(elem) = dom_doc.get_element_by_id(editor_id) { 483 if let Some(html_elem) = 484 elem.dyn_ref::<web_sys::HtmlElement>() 485 { 486 let _ = html_elem.blur(); 487 let _ = html_elem.focus(); 488 } 489 } 490 } 491 } 492 execute_action(&mut doc_for_timeout, &fallback_action); 493 } 494 }) 495 .forget(); // One-shot timer, runs and cleans up 496 } 497 } 498 499 // Android workaround: When swipe keyboard picks a suggestion, 500 // DOM mutations fire before selection moves. Defer cursor sync. 501 if plat.android && matches!(input_type, InputType::InsertText) { 502 if let Some(data) = evt.data() { 503 if data.contains(' ') || data.len() > 3 { 504 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 505 let paras = cached_paras; 506 let mut doc_for_timeout = doc.clone(); 507 508 Timeout::new(20, move || { 509 let paras = paras(); 510 weaver_editor_browser::sync_cursor_from_dom( 511 &mut doc_for_timeout, 512 editor_id, 513 &paras, 514 None, 515 ); 516 }) 517 .forget(); // One-shot timer, runs and cleans up 518 } 519 } 520 } 521 }) 522 as Box<dyn FnMut(web_sys::InputEvent)>); 523 524 let _ = editor 525 .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); 526 527 // Store closure in signal for proper lifecycle management 528 beforeinput_closure.set(Some(closure)); 529 }); 530 531 // Clean up event listener on unmount 532 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 533 use_drop(move || { 534 if let Some(closure) = beforeinput_closure.peek().as_ref() { 535 if let Some(window) = web_sys::window() { 536 if let Some(dom_document) = window.document() { 537 if let Some(editor) = dom_document.get_element_by_id(editor_id) { 538 use wasm_bindgen::JsCast; 539 let _ = editor.remove_event_listener_with_callback( 540 "beforeinput", 541 closure.as_ref().unchecked_ref(), 542 ); 543 } 544 } 545 } 546 } 547 }); 548 549 rsx! { 550 Stylesheet { href: asset!("/assets/styling/editor.css") } 551 CollabCoordinator { 552 document: document.clone(), 553 resource_uri: collab_resource_uri.clone().unwrap_or(draft_key.clone()), 554 presence, 555 div { class: "markdown-editor-container", 556 // Title bar 557 div { class: "editor-title-bar", 558 input { 559 r#type: "text", 560 class: "title-input", 561 aria_label: "Entry title", 562 placeholder: "Entry title...", 563 value: "{document.title()}", 564 oninput: { 565 let doc = document.clone(); 566 move |e| { 567 doc.set_title(&e.value()); 568 } 569 }, 570 } 571 } 572 573 // Meta row - path, tags, publish 574 div { class: "editor-meta-row", 575 div { class: "meta-path", 576 label { "Path" } 577 input { 578 r#type: "text", 579 class: "path-input", 580 aria_label: "URL path", 581 placeholder: "url-slug", 582 value: "{document.path()}", 583 oninput: { 584 let doc = document.clone(); 585 move |e| { 586 doc.set_path(&e.value()); 587 } 588 }, 589 } 590 } 591 592 div { class: "meta-tags", 593 label { "Tags" } 594 div { class: "tags-container", 595 for tag in document.tags() { 596 span { 597 class: "tag-chip", 598 "{tag}" 599 button { 600 class: "tag-remove", 601 aria_label: "Remove tag {tag}", 602 onclick: { 603 let doc = document.clone(); 604 let tag_to_remove = tag.clone(); 605 move |_| { 606 doc.remove_tag(&tag_to_remove); 607 } 608 }, 609 "×" 610 } 611 } 612 } 613 input { 614 r#type: "text", 615 class: "tag-input", 616 aria_label: "Add tag", 617 placeholder: "Add tag...", 618 value: "{new_tag}", 619 oninput: move |e| new_tag.set(e.value()), 620 onkeydown: { 621 let doc = document.clone(); 622 move |e| { 623 use dioxus::prelude::keyboard_types::Key; 624 if e.key() == Key::Enter && !new_tag().trim().is_empty() { 625 e.prevent_default(); 626 let tag = new_tag().trim().to_string(); 627 doc.add_tag(&tag); 628 new_tag.set(String::new()); 629 } 630 } 631 }, 632 } 633 } 634 } 635 636 div { class: "meta-actions", 637 // Show collaborator avatars when editing an existing entry 638 if let Some(entry_ref) = document.entry_ref() { 639 { 640 let title = document.title(); 641 rsx! { 642 CollaboratorAvatars { 643 resource_uri: entry_ref.uri.clone(), 644 resource_cid: entry_ref.cid.to_string(), 645 resource_title: if title.is_empty() { None } else { Some(title) }, 646 } 647 } 648 } 649 } 650 651 { 652 // Enable collaborative sync for any published entry (both owners and collaborators) 653 let is_published = document.entry_ref().is_some(); 654 655 // Refresh callback: fetch and merge collaborator changes (incremental) 656 let on_refresh = if is_published { 657 let fetcher_for_refresh = fetcher.clone(); 658 let doc_for_refresh = document.clone(); 659 let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 660 661 Some(EventHandler::new(move |_| { 662 let fetcher = fetcher_for_refresh.clone(); 663 let mut doc = doc_for_refresh.clone(); 664 let uri = entry_uri.clone(); 665 666 spawn(async move { 667 if let Some(uri) = uri { 668 // Get last seen diffs for incremental sync 669 let last_seen = doc.last_seen_diffs.read().clone(); 670 671 match super::sync::load_all_edit_states_from_pds(&fetcher, &uri, &last_seen).await { 672 Ok(Some(pds_state)) => { 673 if let Err(e) = doc.import_updates(&pds_state.root_snapshot) { 674 tracing::error!("Failed to import collaborator updates: {:?}", e); 675 } else { 676 tracing::info!("Successfully merged collaborator updates"); 677 // Update the last seen diffs for next incremental sync 678 *doc.last_seen_diffs.write() = pds_state.last_seen_diffs; 679 } 680 } 681 Ok(None) => { 682 tracing::debug!("No collaborator updates found"); 683 } 684 Err(e) => { 685 tracing::error!("Failed to fetch collaborator updates: {}", e); 686 } 687 } 688 } 689 }); 690 })) 691 } else { 692 None 693 }; 694 695 rsx! { 696 SyncStatus { 697 document: document.clone(), 698 draft_key: draft_key.to_string(), 699 on_refresh, 700 is_collaborative: is_published, 701 } 702 } 703 } 704 705 PublishButton { 706 document: document.clone(), 707 draft_key: draft_key.to_string(), 708 target_notebook: target_notebook.as_ref().map(|s| s.to_string()), 709 } 710 } 711 } 712 713 // Editor content 714 div { class: "editor-content-wrapper", 715 // Remote collaborator cursors overlay 716 RemoteCursors { presence, document: document.clone(), render_cache } 717 div { 718 id: "{editor_id}", 719 class: "editor-content", 720 contenteditable: "true", 721 role: "textbox", 722 aria_multiline: "true", 723 aria_label: "Document content", 724 725 onkeydown: { 726 let mut doc = document.clone(); 727 let keybindings = super::actions::default_keybindings(platform::platform()); 728 move |evt| { 729 use dioxus::prelude::keyboard_types::Key; 730 use std::time::Duration; 731 732 let plat = platform::platform(); 733 let mods = evt.modifiers(); 734 let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 735 736 // During IME composition: 737 // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 738 // - Allow Escape to cancel composition 739 // - Block text input (let browser handle composition preview) 740 if doc.composition.read().is_some() { 741 if evt.key() == Key::Escape { 742 tracing::debug!("Escape pressed - cancelling composition"); 743 doc.composition.set(None); 744 return; 745 } 746 747 // Allow modifier shortcuts through during composition 748 if !has_modifier { 749 tracing::debug!( 750 key = ?evt.key(), 751 "keydown during composition - delegating to browser" 752 ); 753 return; 754 } 755 // Fall through to handle the shortcut 756 } 757 758 // Safari workaround: After Japanese IME composition ends, both 759 // compositionend and keydown fire for Enter. Ignore keydown 760 // within 500ms of composition end to prevent double-newline. 761 if plat.safari && evt.key() == Key::Enter { 762 if let Some(ended_at) = *doc.composition_ended_at.read() { 763 if ended_at.elapsed() < Duration::from_millis(500) { 764 tracing::debug!( 765 "Safari: ignoring Enter within 500ms of compositionend" 766 ); 767 return; 768 } 769 } 770 } 771 772 // Try keybindings first (for shortcuts like Ctrl+B, Ctrl+Z, etc.) 773 let combo = super::actions::keycombo_from_dioxus_event(&evt.data()); 774 let cursor_offset = doc.cursor.read().offset; 775 let selection = *doc.selection.read(); 776 let range = selection 777 .map(|s| Range::new(s.anchor.min(s.head), s.anchor.max(s.head))) 778 .unwrap_or_else(|| Range::caret(cursor_offset)); 779 match handle_keydown_with_bindings(&mut doc, &keybindings, combo, range) { 780 KeydownResult::Handled => { 781 evt.prevent_default(); 782 return; 783 } 784 KeydownResult::PassThrough => { 785 // Navigation keys - let browser handle, sync in keyup 786 return; 787 } 788 KeydownResult::NotHandled => { 789 // Text input - let beforeinput handle it 790 } 791 } 792 793 // Text input keys: let beforeinput handle them 794 // We don't prevent default here - beforeinput will do that 795 } 796 }, 797 798 onkeyup: { 799 let mut doc = document.clone(); 800 move |evt| { 801 use dioxus::prelude::keyboard_types::Key; 802 803 // Arrow keys with direction hint for snapping 804 let direction_hint = match evt.key() { 805 Key::ArrowLeft | Key::ArrowUp => Some(SnapDirection::Backward), 806 Key::ArrowRight | Key::ArrowDown => Some(SnapDirection::Forward), 807 _ => None, 808 }; 809 810 // Navigation keys (with or without Shift for selection) 811 // We sync cursor from DOM for these because we let the browser handle them 812 let navigation = matches!( 813 evt.key(), 814 Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 815 Key::Home | Key::End | Key::PageUp | Key::PageDown 816 ); 817 818 // Ctrl+A/Cmd+A is handled by browser natively, onselectionchange syncs it. 819 820 if navigation { 821 tracing::debug!( 822 key = ?evt.key(), 823 "onkeyup navigation - syncing cursor from DOM" 824 ); 825 let paras = cached_paragraphs(); 826 let spans = syntax_spans(); 827 sync_cursor_and_visibility( 828 &mut doc, editor_id, &paras, &spans, direction_hint, 829 ); 830 } 831 } 832 }, 833 834 onselect: { 835 let mut doc = document.clone(); 836 move |_evt| { 837 tracing::debug!("onselect fired - syncing cursor from DOM"); 838 let paras = cached_paragraphs(); 839 let spans = syntax_spans(); 840 sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 841 } 842 }, 843 844 onselectstart: { 845 let mut doc = document.clone(); 846 move |_evt| { 847 tracing::debug!("onselectstart fired - syncing cursor from DOM"); 848 let paras = cached_paragraphs(); 849 let spans = syntax_spans(); 850 sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 851 } 852 }, 853 854 onselectionchange: { 855 let mut doc = document.clone(); 856 move |_evt| { 857 tracing::debug!("onselectionchange fired - syncing cursor from DOM"); 858 let paras = cached_paragraphs(); 859 let spans = syntax_spans(); 860 sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 861 } 862 }, 863 864 onclick: { 865 let mut doc = document.clone(); 866 move |evt| { 867 tracing::debug!("onclick fired - syncing cursor from DOM"); 868 let paras = cached_paragraphs(); 869 let spans = syntax_spans(); 870 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 871 let _ = evt; 872 873 // Check if click target is a math-clickable element. 874 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 875 { 876 let map = offset_map(); 877 use dioxus::web::WebEventExt; 878 879 let web_evt = evt.as_web_event(); 880 if let Some(target) = web_evt.target() { 881 if weaver_editor_browser::handle_math_click( 882 &target, &mut doc, &spans, &paras, &map, 883 ) { 884 return; 885 } 886 } 887 } 888 889 sync_cursor_and_visibility(&mut doc, editor_id, &paras, &spans, None); 890 } 891 }, 892 893 // Android workaround: Handle Enter in keypress instead of keydown. 894 // Chrome Android fires confused composition events on Enter in keydown, 895 // but keypress fires after composition state settles. 896 onkeypress: { 897 let mut doc = document.clone(); 898 move |evt| { 899 use dioxus::prelude::keyboard_types::Key; 900 901 let plat = platform::platform(); 902 if plat.android && evt.key() == Key::Enter { 903 tracing::debug!("Android: handling Enter in keypress"); 904 evt.prevent_default(); 905 906 // Get current range 907 let range = if let Some(sel) = *doc.selection.read() { 908 Range::new(sel.anchor.min(sel.head), sel.anchor.max(sel.head)) 909 } else { 910 Range::caret(doc.cursor.read().offset) 911 }; 912 913 let action = EditorAction::InsertParagraph { range }; 914 execute_action(&mut doc, &action); 915 } 916 } 917 }, 918 919 onpaste: { 920 let mut doc = document.clone(); 921 move |evt| { 922 handle_paste(evt, &mut doc); 923 } 924 }, 925 926 oncut: { 927 let mut doc = document.clone(); 928 move |evt| { 929 handle_cut(evt, &mut doc); 930 } 931 }, 932 933 oncopy: { 934 let doc = document.clone(); 935 move |evt| { 936 handle_copy(evt, &doc); 937 } 938 }, 939 940 onblur: { 941 let mut doc = document.clone(); 942 move |_| { 943 // Cancel any in-progress IME composition on focus loss 944 let had_composition = doc.composition.read().is_some(); 945 if had_composition { 946 tracing::debug!("onblur: clearing active composition"); 947 } 948 doc.composition.set(None); 949 } 950 }, 951 952 oncompositionstart: { 953 let mut doc = document.clone(); 954 move |evt: CompositionEvent| { 955 handle_compositionstart(evt, &mut doc); 956 } 957 }, 958 959 oncompositionupdate: { 960 let mut doc = document.clone(); 961 move |evt: CompositionEvent| { 962 handle_compositionupdate(evt, &mut doc); 963 } 964 }, 965 966 oncompositionend: { 967 let mut doc = document.clone(); 968 move |evt: CompositionEvent| { 969 handle_compositionend(evt, &mut doc); 970 } 971 }, 972 } 973 div { class: "editor-debug", 974 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" }, 975 // Collab debug info 976 { 977 if let Some(debug_state) = crate::collab_context::try_use_collab_debug() { 978 let ds = debug_state.read(); 979 rsx! { 980 div { class: "collab-debug", 981 if let Some(ref node_id) = ds.node_id { 982 span { title: "{node_id}", "Node: {&node_id[..8.min(node_id.len())]}…" } 983 } 984 if ds.is_joined { 985 span { class: "joined", "✓ Joined" } 986 } 987 span { "Peers: {ds.discovered_peers}" } 988 if let Some(ref err) = ds.last_error { 989 span { class: "error", title: "{err}", "" } 990 } 991 } 992 } 993 } else { 994 rsx! {} 995 } 996 }, 997 ReportButton { 998 email: "editor-bugs@weaver.sh".to_string(), 999 editor_id: "markdown-editor".to_string(), 1000 } 1001 } 1002 } 1003 1004 EditorToolbar { 1005 on_format: { 1006 let mut doc = document.clone(); 1007 move |action| { 1008 apply_formatting(&mut doc, action); 1009 } 1010 }, 1011 on_image: { 1012 let mut doc = document.clone(); 1013 move |uploaded: super::image_upload::UploadedImage| { 1014 super::image_upload::handle_image_upload( 1015 uploaded, 1016 &mut doc, 1017 &mut image_resolver, 1018 &auth_state, 1019 &fetcher, 1020 ); 1021 } 1022 }, 1023 } 1024 1025 } 1026 } 1027 } 1028}