atproto blogging
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) => ¤t_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, ¶s);
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 ¶s,
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, ¶s, &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, ¶s, &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, ¶s, &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, ¶s, &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, ¶s, &map,
883 ) {
884 return;
885 }
886 }
887 }
888
889 sync_cursor_and_visibility(&mut doc, editor_id, ¶s, &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}