notebook entry stuff!

Orual 25836bdb c959a17f

+2884 -1723
+315 -10
crates/weaver-app/assets/styling/editor.css
··· 8 8 } 9 9 10 10 .markdown-editor-container { 11 - display: flex; 12 - flex-direction: row; 11 + display: grid; 12 + grid-template-columns: 1fr auto; 13 + grid-template-rows: auto auto 1fr; 13 14 height: 100%; 14 15 max-width: 1200px; 15 16 margin: 0 auto; 17 + padding: 0 20px; 16 18 font-family: var(--font-body); 17 19 background: var(--color-base); 18 20 color: var(--color-text); 21 + gap: 0 8px; 19 22 } 20 23 24 + /* Title bar */ 25 + .editor-title-bar { 26 + grid-column: 1; 27 + padding: 16px 0 8px 0; 28 + } 29 + 30 + .editor-title-bar .title-input { 31 + width: 100%; 32 + padding: 8px 0; 33 + border: none; 34 + border-bottom: 2px solid var(--color-border); 35 + background: transparent; 36 + color: var(--color-text); 37 + font-family: var(--font-body); 38 + font-size: 24px; 39 + font-weight: 500; 40 + outline: none; 41 + } 42 + 43 + .editor-title-bar .title-input:focus { 44 + border-bottom-color: var(--color-primary); 45 + } 46 + 47 + .editor-title-bar .title-input::placeholder { 48 + color: var(--color-muted); 49 + } 50 + 51 + /* Meta row - path, tags, and publish inline */ 52 + .editor-meta-row { 53 + grid-column: 1; 54 + display: flex; 55 + gap: 24px; 56 + padding: 8px 0 16px 0; 57 + align-items: flex-end; 58 + } 59 + 60 + .editor-meta-row label { 61 + display: block; 62 + font-size: 11px; 63 + font-weight: 500; 64 + color: var(--color-muted); 65 + text-transform: uppercase; 66 + letter-spacing: 0.05em; 67 + margin-bottom: 4px; 68 + } 69 + 70 + .meta-path { 71 + flex-shrink: 0; 72 + } 73 + 74 + .meta-path .path-input { 75 + padding: 4px 8px; 76 + border: 1px solid var(--color-border); 77 + border-radius: 4px; 78 + background: var(--color-surface); 79 + color: var(--color-text); 80 + font-family: var(--font-mono); 81 + font-size: 13px; 82 + min-width: 150px; 83 + } 84 + 85 + .meta-path .path-input:focus { 86 + outline: none; 87 + border-color: var(--color-primary); 88 + } 89 + 90 + .meta-tags { 91 + flex: 1; 92 + } 93 + 94 + .tags-container { 95 + display: flex; 96 + flex-wrap: wrap; 97 + gap: 6px; 98 + align-items: center; 99 + } 100 + 101 + .tag-chip { 102 + display: inline-flex; 103 + align-items: center; 104 + gap: 4px; 105 + padding: 4px 8px; 106 + background: var(--color-overlay); 107 + border-radius: 12px; 108 + font-size: 13px; 109 + color: var(--color-text); 110 + } 111 + 112 + .tag-remove { 113 + background: none; 114 + border: none; 115 + color: var(--color-muted); 116 + cursor: pointer; 117 + padding: 0 2px; 118 + font-size: 14px; 119 + line-height: 1; 120 + } 121 + 122 + .tag-remove:hover { 123 + color: var(--color-love); 124 + } 125 + 126 + .tag-input { 127 + padding: 4px 8px; 128 + border: 1px dashed var(--color-border); 129 + border-radius: 12px; 130 + background: transparent; 131 + color: var(--color-text); 132 + font-family: var(--font-body); 133 + font-size: 13px; 134 + min-width: 80px; 135 + } 136 + 137 + .tag-input:focus { 138 + outline: none; 139 + border-color: var(--color-primary); 140 + border-style: solid; 141 + } 142 + 143 + /* Editor content wrapper */ 21 144 .editor-content-wrapper { 145 + grid-column: 1; 22 146 display: flex; 23 147 flex-direction: column; 24 - flex: 1; 25 148 min-height: 0; 26 149 } 27 150 ··· 30 153 padding: 20px; 31 154 overflow-y: auto; 32 155 outline: none; 156 + min-height: 700px; 33 157 line-height: var(--spacing-line-height); 34 158 font-size: 16px; 35 159 background: var(--color-surface); ··· 42 166 } 43 167 44 168 .editor-toolbar { 169 + grid-column: 2; 170 + grid-row: 3; 45 171 display: flex; 46 172 flex-direction: column; 47 173 gap: 4px; 48 174 padding: 8px; 49 175 background: var(--color-base); 50 - flex-shrink: 0; 176 + align-self: start; 51 177 min-width: 60px; 52 178 } 53 179 ··· 75 201 76 202 .editor-debug { 77 203 padding: 8px; 204 + padding-right: 0; 78 205 background: var(--color-base); 79 206 font-family: var(--font-mono); 80 207 font-size: 12px; 81 - flex-shrink: 0; 208 + width: 100%; 209 + display: flex; 210 + flex-direction: row; 211 + justify-content: space-between; 82 212 color: var(--color-muted); 83 213 } 84 214 ··· 119 249 120 250 /* Editor page header with report button */ 121 251 .editor-header { 122 - display: flex; 123 - justify-content: space-between; 124 - align-items: center; 125 - padding: 1rem 6rem; 252 + padding-left: 6rem; 126 253 background: var(--color-base); 127 254 } 128 255 ··· 139 266 border-radius: 4px; 140 267 color: var(--color-text); 141 268 cursor: pointer; 142 - margin-right: 3.5rem; 143 269 font-size: 0.9rem; 144 270 font-family: var(--font-body); 145 271 } ··· 260 386 .report-submit:hover { 261 387 opacity: 0.9; 262 388 } 389 + 390 + /* Publish button and dialog - matches report dialog theming */ 391 + .publish-button { 392 + padding: 0.5rem 1rem; 393 + background: var(--color-primary); 394 + border: none; 395 + border-radius: 4px; 396 + color: var(--color-base); 397 + cursor: pointer; 398 + font-weight: 500; 399 + font-family: var(--font-body); 400 + margin-left: auto; 401 + flex-shrink: 0; 402 + } 403 + 404 + .publish-button:hover:not(:disabled) { 405 + opacity: 0.9; 406 + } 407 + 408 + .publish-button:disabled { 409 + opacity: 0.5; 410 + cursor: not-allowed; 411 + } 412 + 413 + .publish-dialog-overlay { 414 + position: fixed; 415 + top: 0; 416 + left: 0; 417 + right: 0; 418 + bottom: 0; 419 + background: rgba(0, 0, 0, 0.6); 420 + display: flex; 421 + align-items: center; 422 + justify-content: center; 423 + z-index: 1000; 424 + } 425 + 426 + .publish-dialog { 427 + background: var(--color-surface); 428 + border: 1px solid var(--color-border); 429 + border-radius: 8px; 430 + padding: 1.5rem; 431 + max-width: 500px; 432 + width: 90%; 433 + color: var(--color-text); 434 + } 435 + 436 + .publish-dialog h2 { 437 + margin: 0 0 1rem 0; 438 + color: var(--color-emphasis); 439 + } 440 + 441 + .publish-field { 442 + margin-bottom: 1rem; 443 + } 444 + 445 + .publish-field label { 446 + display: block; 447 + margin-bottom: 0.5rem; 448 + font-size: 12px; 449 + font-weight: 500; 450 + color: var(--color-muted); 451 + text-transform: uppercase; 452 + letter-spacing: 0.05em; 453 + } 454 + 455 + .publish-input { 456 + width: 100%; 457 + padding: 8px 12px; 458 + border: 1px solid var(--color-border); 459 + border-radius: 4px; 460 + background: var(--color-base); 461 + color: var(--color-text); 462 + font-family: var(--font-body); 463 + font-size: 14px; 464 + box-sizing: border-box; 465 + } 466 + 467 + .publish-input:focus { 468 + outline: none; 469 + border-color: var(--color-primary); 470 + } 471 + 472 + .publish-preview { 473 + background: var(--color-base); 474 + padding: 1rem; 475 + border-radius: 4px; 476 + margin-bottom: 1rem; 477 + font-size: 0.9rem; 478 + } 479 + 480 + .publish-preview p { 481 + margin: 0.25rem 0; 482 + color: var(--color-subtle); 483 + } 484 + 485 + .publish-error { 486 + background: var(--color-love); 487 + color: var(--color-base); 488 + padding: 0.75rem; 489 + border-radius: 4px; 490 + margin-bottom: 1rem; 491 + font-size: 0.9rem; 492 + } 493 + 494 + .publish-success { 495 + text-align: center; 496 + padding: 1rem 0; 497 + } 498 + 499 + .publish-success p { 500 + color: var(--color-foam); 501 + margin-bottom: 1rem; 502 + } 503 + 504 + .publish-success a { 505 + color: var(--color-primary); 506 + text-decoration: none; 507 + } 508 + 509 + .publish-success a:hover { 510 + text-decoration: underline; 511 + } 512 + 513 + .publish-done { 514 + margin-top: 1rem; 515 + padding: 0.5rem 1.5rem; 516 + background: var(--color-primary); 517 + border: none; 518 + border-radius: 4px; 519 + color: var(--color-base); 520 + cursor: pointer; 521 + font-family: var(--font-body); 522 + } 523 + 524 + .publish-actions { 525 + display: flex; 526 + gap: 1rem; 527 + justify-content: flex-end; 528 + } 529 + 530 + .publish-cancel { 531 + padding: 0.5rem 1rem; 532 + background: transparent; 533 + border: 1px solid var(--color-border); 534 + border-radius: 4px; 535 + color: var(--color-text); 536 + cursor: pointer; 537 + font-family: var(--font-body); 538 + } 539 + 540 + .publish-cancel:hover:not(:disabled) { 541 + background: var(--color-overlay); 542 + } 543 + 544 + .publish-cancel:disabled { 545 + opacity: 0.5; 546 + cursor: not-allowed; 547 + } 548 + 549 + .publish-submit { 550 + padding: 0.5rem 1rem; 551 + background: var(--color-primary); 552 + border: none; 553 + border-radius: 4px; 554 + color: var(--color-base); 555 + cursor: pointer; 556 + font-weight: 500; 557 + font-family: var(--font-body); 558 + } 559 + 560 + .publish-submit:hover:not(:disabled) { 561 + opacity: 0.9; 562 + } 563 + 564 + .publish-submit:disabled { 565 + opacity: 0.5; 566 + cursor: not-allowed; 567 + }
+1 -1
crates/weaver-app/src/components/accordion/mod.rs
··· 1 1 mod component; 2 - pub use component::*; 2 + pub use component::*;
+1 -1
crates/weaver-app/src/components/avatar/mod.rs
··· 1 1 mod component; 2 - pub use component::*; 2 + pub use component::*;
+1 -1
crates/weaver-app/src/components/button/mod.rs
··· 1 1 mod component; 2 - pub use component::*; 2 + pub use component::*;
+1 -1
crates/weaver-app/src/components/dialog/mod.rs
··· 1 1 mod component; 2 - pub use component::*; 2 + pub use component::*;
+658
crates/weaver-app/src/components/editor/component.rs
··· 1 + //! The main MarkdownEditor component. 2 + 3 + use dioxus::prelude::*; 4 + 5 + use crate::components::editor::ReportButton; 6 + 7 + use super::document::{CompositionState, EditorDocument}; 8 + use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom}; 9 + use super::formatting; 10 + use super::input::{ 11 + get_char_at, handle_copy, handle_cut, handle_keydown, handle_paste, should_intercept_key, 12 + }; 13 + use super::paragraph::ParagraphRender; 14 + use super::platform; 15 + use super::publish::PublishButton; 16 + use super::render; 17 + use super::storage; 18 + use super::toolbar::EditorToolbar; 19 + use super::visibility::update_syntax_visibility; 20 + use super::writer::SyntaxSpanInfo; 21 + 22 + /// Main markdown editor component. 23 + /// 24 + /// # Props 25 + /// - `initial_content`: Optional initial markdown content 26 + /// 27 + /// # Features 28 + /// - Loro CRDT-based text storage with undo/redo support 29 + /// - Event interception for full control over editing operations 30 + /// - Toolbar formatting buttons 31 + /// - LocalStorage auto-save with debouncing 32 + /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 33 + #[component] 34 + pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 35 + // Try to restore from localStorage (includes CRDT state for undo history) 36 + // Use "current" as the default draft key for now 37 + let draft_key = "current"; 38 + let mut document = use_signal(move || { 39 + storage::load_from_storage(draft_key) 40 + .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 41 + }); 42 + let editor_id = "markdown-editor"; 43 + 44 + // Cache for incremental paragraph rendering 45 + let mut render_cache = use_signal(|| render::RenderCache::default()); 46 + 47 + // Render paragraphs with incremental caching 48 + let paragraphs = use_memo(move || { 49 + let doc = document(); 50 + let cache = render_cache.peek(); 51 + let edit = doc.last_edit.as_ref(); 52 + 53 + let (paras, new_cache) = 54 + render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); 55 + 56 + // Update cache for next render (write-only via spawn to avoid reactive loop) 57 + dioxus::prelude::spawn(async move { 58 + render_cache.set(new_cache); 59 + }); 60 + 61 + paras 62 + }); 63 + 64 + // Flatten offset maps from all paragraphs 65 + let offset_map = use_memo(move || { 66 + paragraphs() 67 + .iter() 68 + .flat_map(|p| p.offset_map.iter().cloned()) 69 + .collect::<Vec<_>>() 70 + }); 71 + 72 + // Flatten syntax spans from all paragraphs 73 + let syntax_spans = use_memo(move || { 74 + paragraphs() 75 + .iter() 76 + .flat_map(|p| p.syntax_spans.iter().cloned()) 77 + .collect::<Vec<_>>() 78 + }); 79 + 80 + // Cache paragraphs for change detection AND for event handlers to access 81 + let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 82 + 83 + // Update DOM when paragraphs change (incremental rendering) 84 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 85 + use_effect(move || { 86 + tracing::debug!("DOM update effect triggered"); 87 + 88 + // Read document once to avoid multiple borrows 89 + let doc = document(); 90 + 91 + tracing::debug!( 92 + composition_active = doc.composition.is_some(), 93 + cursor = doc.cursor.offset, 94 + "DOM update: checking state" 95 + ); 96 + 97 + // Skip DOM updates during IME composition - browser controls the preview 98 + if doc.composition.is_some() { 99 + tracing::debug!("skipping DOM update during composition"); 100 + return; 101 + } 102 + 103 + tracing::debug!( 104 + cursor = doc.cursor.offset, 105 + len = doc.len_chars(), 106 + "DOM update proceeding (not in composition)" 107 + ); 108 + 109 + let cursor_offset = doc.cursor.offset; 110 + let selection = doc.selection; 111 + drop(doc); // Release borrow before other operations 112 + 113 + let new_paras = paragraphs(); 114 + let map = offset_map(); 115 + let spans = syntax_spans(); 116 + 117 + // Use peek() to avoid creating reactive dependency on cached_paragraphs 118 + let prev = cached_paragraphs.peek().clone(); 119 + 120 + let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); 121 + 122 + // Only restore cursor if we actually re-rendered the paragraph it's in 123 + if cursor_para_updated { 124 + use wasm_bindgen::JsCast; 125 + use wasm_bindgen::prelude::*; 126 + 127 + // Use requestAnimationFrame to wait for browser paint 128 + if let Some(window) = web_sys::window() { 129 + let closure = Closure::once(move || { 130 + if let Err(e) = 131 + super::cursor::restore_cursor_position(cursor_offset, &map, editor_id) 132 + { 133 + tracing::warn!("Cursor restoration failed: {:?}", e); 134 + } 135 + }); 136 + 137 + let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); 138 + closure.forget(); 139 + } 140 + } 141 + 142 + // Store for next comparison AND for event handlers (write-only, no reactive read) 143 + cached_paragraphs.set(new_paras.clone()); 144 + 145 + // Update syntax visibility after DOM changes 146 + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); 147 + }); 148 + 149 + // Track last saved frontiers to detect changes (peek-only, no subscriptions) 150 + let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 151 + 152 + // Auto-save with periodic check (no reactive dependency to avoid loops) 153 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 154 + use_effect(move || { 155 + // Check every 500ms if there are unsaved changes 156 + let interval = gloo_timers::callback::Interval::new(500, move || { 157 + // Peek both signals without creating reactive dependencies 158 + let current_frontiers = document.peek().state_frontiers(); 159 + 160 + // Only save if frontiers changed (document was edited) 161 + let needs_save = { 162 + let last_frontiers = last_saved_frontiers.peek(); 163 + match &*last_frontiers { 164 + None => true, // First save 165 + Some(last) => &current_frontiers != last, 166 + } 167 + }; // drop last_frontiers borrow here 168 + 169 + if needs_save { 170 + document.with_mut(|doc| { 171 + doc.sync_loro_cursor(); 172 + let _ = storage::save_to_storage(doc, draft_key, None); 173 + }); 174 + 175 + // Update last saved frontiers 176 + last_saved_frontiers.set(Some(current_frontiers)); 177 + } 178 + }); 179 + interval.forget(); 180 + }); 181 + 182 + // Set up beforeinput listener for iOS/Android virtual keyboard quirks 183 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 184 + use_effect(move || { 185 + use wasm_bindgen::JsCast; 186 + use wasm_bindgen::prelude::*; 187 + 188 + let plat = platform::platform(); 189 + 190 + // Only needed on mobile 191 + if !plat.mobile { 192 + return; 193 + } 194 + 195 + let window = match web_sys::window() { 196 + Some(w) => w, 197 + None => return, 198 + }; 199 + let dom_document = match window.document() { 200 + Some(d) => d, 201 + None => return, 202 + }; 203 + let editor = match dom_document.get_element_by_id(editor_id) { 204 + Some(e) => e, 205 + None => return, 206 + }; 207 + 208 + let mut document_signal = document; 209 + let cached_paras = cached_paragraphs; 210 + 211 + let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 212 + let input_type = evt.input_type(); 213 + tracing::debug!(input_type = %input_type, "beforeinput"); 214 + 215 + let plat = platform::platform(); 216 + 217 + // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak 218 + // without proper keydown events. Handle them here. 219 + if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { 220 + tracing::debug!("iOS: intercepting {} via beforeinput", input_type); 221 + evt.prevent_default(); 222 + 223 + // Handle as Enter key 224 + document_signal.with_mut(|doc| { 225 + if let Some(sel) = doc.selection.take() { 226 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 227 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 228 + doc.cursor.offset = start; 229 + } 230 + 231 + if input_type == "insertLineBreak" { 232 + // Soft break (like Shift+Enter) 233 + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 234 + doc.cursor.offset += 3; 235 + } else { 236 + // Paragraph break 237 + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 238 + doc.cursor.offset += 2; 239 + } 240 + }); 241 + } 242 + 243 + // Android workaround: When swipe keyboard picks a suggestion, 244 + // DOM mutations fire before selection moves. We detect this pattern 245 + // and defer cursor sync. 246 + if plat.android && input_type == "insertText" { 247 + // Check if this might be a suggestion pick (has data that looks like a word) 248 + if let Some(data) = evt.data() { 249 + if data.contains(' ') || data.len() > 3 { 250 + tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 251 + // Defer cursor sync by 20ms to let selection settle 252 + let paras = cached_paras; 253 + let doc_sig = document_signal; 254 + let window = web_sys::window(); 255 + if let Some(window) = window { 256 + let closure = Closure::once(move || { 257 + let paras = paras(); 258 + sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, &paras); 259 + }); 260 + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 261 + closure.as_ref().unchecked_ref(), 262 + 20, 263 + ); 264 + closure.forget(); 265 + } 266 + } 267 + } 268 + } 269 + }) as Box<dyn FnMut(web_sys::InputEvent)>); 270 + 271 + let _ = editor 272 + .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); 273 + closure.forget(); 274 + }); 275 + 276 + // Local state for adding new tags 277 + let mut new_tag = use_signal(String::new); 278 + 279 + rsx! { 280 + Stylesheet { href: asset!("/assets/styling/editor.css") } 281 + div { class: "markdown-editor-container", 282 + // Title bar 283 + div { class: "editor-title-bar", 284 + input { 285 + r#type: "text", 286 + class: "title-input", 287 + placeholder: "Entry title...", 288 + value: "{document().title()}", 289 + oninput: move |e| { 290 + document.with_mut(|doc| doc.set_title(&e.value())); 291 + }, 292 + } 293 + } 294 + 295 + // Meta row - path, tags, publish 296 + div { class: "editor-meta-row", 297 + div { class: "meta-path", 298 + label { "Path" } 299 + input { 300 + r#type: "text", 301 + class: "path-input", 302 + placeholder: "url-slug", 303 + value: "{document().path()}", 304 + oninput: move |e| { 305 + document.with_mut(|doc| doc.set_path(&e.value())); 306 + }, 307 + } 308 + } 309 + 310 + div { class: "meta-tags", 311 + label { "Tags" } 312 + div { class: "tags-container", 313 + for tag in document().tags() { 314 + span { 315 + class: "tag-chip", 316 + "{tag}" 317 + button { 318 + class: "tag-remove", 319 + onclick: { 320 + let tag_to_remove = tag.clone(); 321 + move |_| { 322 + document.with_mut(|doc| doc.remove_tag(&tag_to_remove)); 323 + } 324 + }, 325 + "×" 326 + } 327 + } 328 + } 329 + input { 330 + r#type: "text", 331 + class: "tag-input", 332 + placeholder: "Add tag...", 333 + value: "{new_tag}", 334 + oninput: move |e| new_tag.set(e.value()), 335 + onkeydown: move |e| { 336 + use dioxus::prelude::keyboard_types::Key; 337 + if e.key() == Key::Enter && !new_tag().trim().is_empty() { 338 + e.prevent_default(); 339 + let tag = new_tag().trim().to_string(); 340 + document.with_mut(|doc| doc.add_tag(&tag)); 341 + new_tag.set(String::new()); 342 + } 343 + }, 344 + } 345 + } 346 + } 347 + 348 + PublishButton { 349 + document: document, 350 + draft_key: draft_key.to_string(), 351 + } 352 + } 353 + 354 + // Editor content 355 + div { class: "editor-content-wrapper", 356 + div { 357 + id: "{editor_id}", 358 + class: "editor-content", 359 + contenteditable: "true", 360 + 361 + onkeydown: move |evt| { 362 + use dioxus::prelude::keyboard_types::Key; 363 + use std::time::Duration; 364 + 365 + let plat = platform::platform(); 366 + let mods = evt.modifiers(); 367 + let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 368 + 369 + // During IME composition: 370 + // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 371 + // - Allow Escape to cancel composition 372 + // - Block text input (let browser handle composition preview) 373 + if document.peek().composition.is_some() { 374 + if evt.key() == Key::Escape { 375 + tracing::debug!("Escape pressed - cancelling composition"); 376 + document.with_mut(|doc| { 377 + doc.composition = None; 378 + }); 379 + return; 380 + } 381 + 382 + // Allow modifier shortcuts through during composition 383 + if !has_modifier { 384 + tracing::debug!( 385 + key = ?evt.key(), 386 + "keydown during composition - delegating to browser" 387 + ); 388 + return; 389 + } 390 + // Fall through to handle the shortcut 391 + } 392 + 393 + // Safari workaround: After Japanese IME composition ends, both 394 + // compositionend and keydown fire for Enter. Ignore keydown 395 + // within 500ms of composition end to prevent double-newline. 396 + if plat.safari && evt.key() == Key::Enter { 397 + if let Some(ended_at) = document.peek().composition_ended_at { 398 + if ended_at.elapsed() < Duration::from_millis(500) { 399 + tracing::debug!( 400 + "Safari: ignoring Enter within 500ms of compositionend" 401 + ); 402 + return; 403 + } 404 + } 405 + } 406 + 407 + // Android workaround: Chrome Android gets confused by Enter during/after 408 + // composition. Defer Enter handling to onkeypress instead. 409 + if plat.android && evt.key() == Key::Enter { 410 + tracing::debug!("Android: deferring Enter to keypress"); 411 + return; 412 + } 413 + 414 + // Only prevent default for operations that modify content 415 + // Let browser handle arrow keys, Home/End naturally 416 + if should_intercept_key(&evt) { 417 + evt.prevent_default(); 418 + handle_keydown(evt, &mut document); 419 + } 420 + }, 421 + 422 + onkeyup: move |evt| { 423 + use dioxus::prelude::keyboard_types::Key; 424 + 425 + // Navigation keys (with or without Shift for selection) 426 + let navigation = matches!( 427 + evt.key(), 428 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 429 + Key::Home | Key::End | Key::PageUp | Key::PageDown 430 + ); 431 + 432 + // Cmd/Ctrl+A for select all 433 + let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 434 + && matches!(evt.key(), Key::Character(ref c) if c == "a"); 435 + 436 + if navigation || select_all { 437 + let paras = cached_paragraphs(); 438 + sync_cursor_from_dom(&mut document, editor_id, &paras); 439 + let doc = document(); 440 + let spans = syntax_spans(); 441 + update_syntax_visibility( 442 + doc.cursor.offset, 443 + doc.selection.as_ref(), 444 + &spans, 445 + &paras, 446 + ); 447 + } 448 + }, 449 + 450 + onselect: move |_evt| { 451 + tracing::debug!("onselect fired"); 452 + let paras = cached_paragraphs(); 453 + sync_cursor_from_dom(&mut document, editor_id, &paras); 454 + let doc = document(); 455 + let spans = syntax_spans(); 456 + update_syntax_visibility( 457 + doc.cursor.offset, 458 + doc.selection.as_ref(), 459 + &spans, 460 + &paras, 461 + ); 462 + }, 463 + 464 + onselectstart: move |_evt| { 465 + tracing::debug!("onselectstart fired"); 466 + let paras = cached_paragraphs(); 467 + sync_cursor_from_dom(&mut document, editor_id, &paras); 468 + let doc = document(); 469 + let spans = syntax_spans(); 470 + update_syntax_visibility( 471 + doc.cursor.offset, 472 + doc.selection.as_ref(), 473 + &spans, 474 + &paras, 475 + ); 476 + }, 477 + 478 + onselectionchange: move |_evt| { 479 + tracing::debug!("onselectionchange fired"); 480 + let paras = cached_paragraphs(); 481 + sync_cursor_from_dom(&mut document, editor_id, &paras); 482 + let doc = document(); 483 + let spans = syntax_spans(); 484 + update_syntax_visibility( 485 + doc.cursor.offset, 486 + doc.selection.as_ref(), 487 + &spans, 488 + &paras, 489 + ); 490 + }, 491 + 492 + onclick: move |_evt| { 493 + tracing::debug!("onclick fired"); 494 + let paras = cached_paragraphs(); 495 + sync_cursor_from_dom(&mut document, editor_id, &paras); 496 + let doc = document(); 497 + let spans = syntax_spans(); 498 + update_syntax_visibility( 499 + doc.cursor.offset, 500 + doc.selection.as_ref(), 501 + &spans, 502 + &paras, 503 + ); 504 + }, 505 + 506 + // Android workaround: Handle Enter in keypress instead of keydown. 507 + // Chrome Android fires confused composition events on Enter in keydown, 508 + // but keypress fires after composition state settles. 509 + onkeypress: move |evt| { 510 + use dioxus::prelude::keyboard_types::Key; 511 + 512 + let plat = platform::platform(); 513 + if plat.android && evt.key() == Key::Enter { 514 + tracing::debug!("Android: handling Enter in keypress"); 515 + evt.prevent_default(); 516 + handle_keydown(evt, &mut document); 517 + } 518 + }, 519 + 520 + onpaste: move |evt| { 521 + handle_paste(evt, &mut document); 522 + }, 523 + 524 + oncut: move |evt| { 525 + handle_cut(evt, &mut document); 526 + }, 527 + 528 + oncopy: move |evt| { 529 + handle_copy(evt, &document); 530 + }, 531 + 532 + onblur: move |_| { 533 + // Cancel any in-progress IME composition on focus loss 534 + let had_composition = document.peek().composition.is_some(); 535 + if had_composition { 536 + tracing::debug!("onblur: clearing active composition"); 537 + } 538 + document.with_mut(|doc| { 539 + doc.composition = None; 540 + }); 541 + }, 542 + 543 + oncompositionstart: move |evt: CompositionEvent| { 544 + let data = evt.data().data(); 545 + tracing::debug!( 546 + data = %data, 547 + "compositionstart" 548 + ); 549 + document.with_mut(|doc| { 550 + // Delete selection if present (composition replaces it) 551 + if let Some(sel) = doc.selection.take() { 552 + let (start, end) = 553 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 554 + tracing::debug!( 555 + start, 556 + end, 557 + "compositionstart: deleting selection" 558 + ); 559 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 560 + doc.cursor.offset = start; 561 + } 562 + 563 + tracing::debug!( 564 + cursor = doc.cursor.offset, 565 + "compositionstart: setting composition state" 566 + ); 567 + doc.composition = Some(CompositionState { 568 + start_offset: doc.cursor.offset, 569 + text: data, 570 + }); 571 + }); 572 + }, 573 + 574 + oncompositionupdate: move |evt: CompositionEvent| { 575 + let data = evt.data().data(); 576 + tracing::debug!( 577 + data = %data, 578 + "compositionupdate" 579 + ); 580 + document.with_mut(|doc| { 581 + if let Some(ref mut comp) = doc.composition { 582 + comp.text = data; 583 + } else { 584 + tracing::debug!("compositionupdate without active composition state"); 585 + } 586 + }); 587 + }, 588 + 589 + oncompositionend: move |evt: CompositionEvent| { 590 + let final_text = evt.data().data(); 591 + tracing::debug!( 592 + data = %final_text, 593 + "compositionend" 594 + ); 595 + document.with_mut(|doc| { 596 + // Record when composition ended for Safari timing workaround 597 + doc.composition_ended_at = Some(web_time::Instant::now()); 598 + 599 + if let Some(comp) = doc.composition.take() { 600 + tracing::debug!( 601 + start_offset = comp.start_offset, 602 + final_text = %final_text, 603 + chars = final_text.chars().count(), 604 + "compositionend: inserting text" 605 + ); 606 + 607 + if !final_text.is_empty() { 608 + let mut delete_start = comp.start_offset; 609 + while delete_start > 0 { 610 + match get_char_at(doc.loro_text(), delete_start - 1) { 611 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 612 + _ => break, 613 + } 614 + } 615 + 616 + let zw_count = doc.cursor.offset - delete_start; 617 + if zw_count > 0 { 618 + // Splice: delete zero-width chars and insert new char in one op 619 + let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 620 + doc.cursor.offset = delete_start + final_text.chars().count(); 621 + } else if doc.cursor.offset == doc.len_chars() { 622 + // Fast path: append at end 623 + let _ = doc.push_tracked(&final_text); 624 + doc.cursor.offset = comp.start_offset + final_text.chars().count(); 625 + } else { 626 + let _ = doc.insert_tracked(doc.cursor.offset, &final_text); 627 + doc.cursor.offset = comp.start_offset + final_text.chars().count(); 628 + } 629 + } 630 + } else { 631 + tracing::debug!("compositionend without active composition state"); 632 + } 633 + }); 634 + }, 635 + } 636 + 637 + // Debug panel snug below editor 638 + div { class: "editor-debug", 639 + div { "Cursor: {document().cursor.offset}, Chars: {document().len_chars()}" }, 640 + ReportButton { 641 + email: "editor-bugs@weaver.sh".to_string(), 642 + editor_id: "markdown-editor".to_string(), 643 + } 644 + } 645 + } 646 + 647 + // Toolbar in grid column 2, row 3 648 + EditorToolbar { 649 + on_format: move |action| { 650 + document.with_mut(|doc| { 651 + formatting::apply_formatting(doc, action); 652 + }); 653 + } 654 + } 655 + 656 + } 657 + } 658 + }
+15 -11
crates/weaver-app/src/components/editor/cursor.rs
··· 7 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 8 //! 4. Setting cursor with web_sys Selection API 9 9 10 - use super::offset_map::{find_mapping_for_char, OffsetMapping}; 10 + use super::offset_map::{OffsetMapping, find_mapping_for_char}; 11 11 12 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 13 use wasm_bindgen::JsCast; ··· 36 36 } 37 37 38 38 // Bounds check using offset map 39 - let max_offset = offset_map.iter().map(|m| m.char_range.end).max().unwrap_or(0); 39 + let max_offset = offset_map 40 + .iter() 41 + .map(|m| m.char_range.end) 42 + .max() 43 + .unwrap_or(0); 40 44 if char_offset > max_offset { 41 - tracing::warn!("cursor offset {} > max mapping offset {}", char_offset, max_offset); 45 + tracing::warn!( 46 + "cursor offset {} > max mapping offset {}", 47 + char_offset, 48 + max_offset 49 + ); 42 50 // Don't error, just skip restoration - this can happen during edits 43 51 return Ok(()); 44 52 } ··· 70 78 .ok_or_else(|| format!("element not found: {}", mapping.node_id))?; 71 79 72 80 // Set selection using Range API 73 - let selection = window 74 - .get_selection()? 75 - .ok_or("no selection object")?; 81 + let selection = window.get_selection()?.ok_or("no selection object")?; 76 82 let range = document.create_range()?; 77 83 78 84 // Check if this is an element-based position (e.g., after <br />) ··· 84 90 let container_element = container.dyn_into::<web_sys::HtmlElement>()?; 85 91 let offset_in_range = char_offset - mapping.char_range.start; 86 92 let target_utf16_offset = mapping.char_offset_in_node + offset_in_range; 87 - let (text_node, node_offset) = find_text_node_at_offset(&container_element, target_utf16_offset)?; 93 + let (text_node, node_offset) = 94 + find_text_node_at_offset(&container_element, target_utf16_offset)?; 88 95 range.set_start(&text_node, node_offset as u32)?; 89 96 } 90 97 ··· 114 121 115 122 // Create tree walker to find text nodes 116 123 // SHOW_TEXT = 4 (from DOM spec) 117 - let walker = document.create_tree_walker_with_what_to_show( 118 - container, 119 - 4, 120 - )?; 124 + let walker = document.create_tree_walker_with_what_to_show(container, 4)?; 121 125 122 126 let mut accumulated_utf16 = 0; 123 127 let mut last_node: Option<web_sys::Node> = None;
+337 -56
crates/weaver-app/src/components/editor/document.rs
··· 1 1 //! Core data structures for the markdown editor. 2 2 //! 3 3 //! Uses Loro CRDT for text storage with built-in undo/redo support. 4 + //! Mirrors the `sh.weaver.notebook.entry` schema for AT Protocol integration. 4 5 5 6 use loro::{ 6 - ExportMode, LoroDoc, LoroResult, LoroText, UndoManager, 7 + ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager, 7 8 cursor::{Cursor, Side}, 8 9 }; 9 10 11 + use jacquard::IntoStatic; 12 + use jacquard::from_json_value; 13 + use jacquard::types::string::AtUri; 14 + use weaver_api::sh_weaver::embed::images::Image; 15 + 16 + /// Helper for working with editor images. 17 + /// Constructed from LoroMap data, NOT serialized directly. 18 + /// The Image lexicon type stores our `publishedBlobUri` in its `extra_data` field. 19 + #[derive(Clone, Debug)] 20 + pub struct EditorImage { 21 + /// The lexicon Image type (deserialized via from_json_value) 22 + pub image: Image<'static>, 23 + /// AT-URI of the PublishedBlob record (for cleanup on publish/delete) 24 + /// None for existing images that are already in an entry record. 25 + pub published_blob_uri: Option<AtUri<'static>>, 26 + } 27 + 10 28 /// Single source of truth for editor state. 11 29 /// 12 30 /// Contains the document text (backed by Loro CRDT), cursor position, 13 - /// selection, and IME composition state. 31 + /// selection, and IME composition state. Mirrors the `sh.weaver.notebook.entry` 32 + /// schema with CRDT containers for each field. 14 33 #[derive(Debug)] 15 34 pub struct EditorDocument { 16 35 /// The Loro document containing all editor state. 17 - /// Using full LoroDoc (not just LoroText) to support future 18 - /// expansion to blobs, metadata, etc. 19 36 doc: LoroDoc, 20 37 21 - /// Handle to the text container within the doc. 22 - text: LoroText, 38 + // --- Entry schema containers --- 39 + /// Markdown content (maps to entry.content) 40 + content: LoroText, 41 + 42 + /// Entry title (maps to entry.title) 43 + title: LoroText, 44 + 45 + /// URL path/slug (maps to entry.path) 46 + path: LoroText, 47 + 48 + /// ISO datetime string (maps to entry.createdAt) 49 + created_at: LoroText, 50 + 51 + /// Tags list (maps to entry.tags) 52 + tags: LoroList, 53 + 54 + /// Embeds container (maps to entry.embeds) 55 + /// Contains nested containers: images (LoroList), externals (LoroList), etc. 56 + embeds: LoroMap, 23 57 58 + // --- Editor state --- 24 59 /// Undo manager for the document. 25 60 undo_mgr: UndoManager, 26 61 ··· 111 146 return true; 112 147 } 113 148 114 - let content = self.text.to_string(); 149 + let content_str = self.content.to_string(); 115 150 let mut last_newline_pos: Option<usize> = None; 116 151 117 - for (i, c) in content.chars().take(pos).enumerate() { 152 + for (i, c) in content_str.chars().take(pos).enumerate() { 118 153 if c == '\n' { 119 154 last_newline_pos = Some(i); 120 155 } ··· 129 164 } 130 165 131 166 /// Create a new editor document with the given content. 132 - pub fn new(content: String) -> Self { 167 + /// Sets `created_at` to current time. 168 + pub fn new(initial_content: String) -> Self { 133 169 let doc = LoroDoc::new(); 134 - let text = doc.get_text("content"); 170 + 171 + // Get all containers 172 + let content = doc.get_text("content"); 173 + let title = doc.get_text("title"); 174 + let path = doc.get_text("path"); 175 + let created_at = doc.get_text("created_at"); 176 + let tags = doc.get_list("tags"); 177 + let embeds = doc.get_map("embeds"); 135 178 136 179 // Insert initial content if any 137 - if !content.is_empty() { 138 - text.insert(0, &content) 180 + if !initial_content.is_empty() { 181 + content 182 + .insert(0, &initial_content) 139 183 .expect("failed to insert initial content"); 140 184 } 141 185 186 + // Set created_at to current time (ISO 8601) 187 + let now = Self::current_datetime_string(); 188 + created_at 189 + .insert(0, &now) 190 + .expect("failed to set created_at"); 191 + 142 192 // Set up undo manager with merge interval for batching keystrokes 143 193 let mut undo_mgr = UndoManager::new(&doc); 144 194 undo_mgr.set_merge_interval(300); // 300ms merge window 145 195 undo_mgr.set_max_undo_steps(100); 146 196 147 197 // Create initial Loro cursor at position 0 148 - let loro_cursor = text.get_cursor(0, Side::default()); 198 + let loro_cursor = content.get_cursor(0, Side::default()); 149 199 150 200 Self { 151 201 doc, 152 - text, 202 + content, 203 + title, 204 + path, 205 + created_at, 206 + tags, 207 + embeds, 153 208 undo_mgr, 154 209 cursor: CursorState { 155 210 offset: 0, ··· 163 218 } 164 219 } 165 220 166 - /// Get the underlying LoroText for read operations. 221 + /// Generate current datetime as ISO 8601 string. 222 + #[cfg(target_family = "wasm")] 223 + fn current_datetime_string() -> String { 224 + js_sys::Date::new_0() 225 + .to_iso_string() 226 + .as_string() 227 + .unwrap_or_default() 228 + } 229 + 230 + #[cfg(not(target_family = "wasm"))] 231 + fn current_datetime_string() -> String { 232 + // Fallback for non-wasm (tests, etc.) 233 + chrono::Utc::now().to_rfc3339() 234 + } 235 + 236 + /// Get the underlying LoroText for read operations on content. 167 237 pub fn loro_text(&self) -> &LoroText { 168 - &self.text 238 + &self.content 239 + } 240 + 241 + // --- Content accessors --- 242 + 243 + /// Get the markdown content as a string. 244 + pub fn content(&self) -> String { 245 + self.content.to_string() 169 246 } 170 247 171 - /// Convert the document to a string. 248 + /// Convert the document content to a string (alias for content()). 172 249 pub fn to_string(&self) -> String { 173 - self.text.to_string() 250 + self.content.to_string() 174 251 } 175 252 176 - /// Get the length of the document in characters. 253 + /// Get the length of the content in characters. 177 254 pub fn len_chars(&self) -> usize { 178 - self.text.len_unicode() 255 + self.content.len_unicode() 179 256 } 180 257 181 - /// Get the length of the document in UTF-8 bytes. 258 + /// Get the length of the content in UTF-8 bytes. 182 259 pub fn len_bytes(&self) -> usize { 183 - self.text.len_utf8() 260 + self.content.len_utf8() 184 261 } 185 262 186 - /// Get the length of the document in UTF-16 code units. 263 + /// Get the length of the content in UTF-16 code units. 187 264 pub fn len_utf16(&self) -> usize { 188 - self.text.len_utf16() 265 + self.content.len_utf16() 189 266 } 190 267 191 - /// Check if the document is empty. 268 + /// Check if the content is empty. 192 269 pub fn is_empty(&self) -> bool { 193 - self.text.len_unicode() == 0 270 + self.content.len_unicode() == 0 271 + } 272 + 273 + // --- Entry metadata accessors --- 274 + 275 + /// Get the entry title. 276 + pub fn title(&self) -> String { 277 + self.title.to_string() 278 + } 279 + 280 + /// Set the entry title (replaces existing). 281 + pub fn set_title(&mut self, new_title: &str) { 282 + let current_len = self.title.len_unicode(); 283 + if current_len > 0 { 284 + self.title.delete(0, current_len).ok(); 285 + } 286 + self.title.insert(0, new_title).ok(); 287 + } 288 + 289 + /// Get the URL path/slug. 290 + pub fn path(&self) -> String { 291 + self.path.to_string() 292 + } 293 + 294 + /// Set the URL path/slug (replaces existing). 295 + pub fn set_path(&mut self, new_path: &str) { 296 + let current_len = self.path.len_unicode(); 297 + if current_len > 0 { 298 + self.path.delete(0, current_len).ok(); 299 + } 300 + self.path.insert(0, new_path).ok(); 301 + } 302 + 303 + /// Get the created_at timestamp (ISO 8601 string). 304 + pub fn created_at(&self) -> String { 305 + self.created_at.to_string() 306 + } 307 + 308 + /// Set the created_at timestamp (usually only called once on creation or when loading). 309 + pub fn set_created_at(&mut self, datetime: &str) { 310 + let current_len = self.created_at.len_unicode(); 311 + if current_len > 0 { 312 + self.created_at.delete(0, current_len).ok(); 313 + } 314 + self.created_at.insert(0, datetime).ok(); 315 + } 316 + 317 + // --- Tags accessors --- 318 + 319 + /// Get all tags as a vector of strings. 320 + pub fn tags(&self) -> Vec<String> { 321 + let len = self.tags.len(); 322 + (0..len) 323 + .filter_map(|i| match self.tags.get(i)? { 324 + loro::ValueOrContainer::Value(LoroValue::String(s)) => Some(s.to_string()), 325 + _ => None, 326 + }) 327 + .collect() 328 + } 329 + 330 + /// Add a tag (if not already present). 331 + pub fn add_tag(&mut self, tag: &str) { 332 + let existing = self.tags(); 333 + if !existing.iter().any(|t| t == tag) { 334 + self.tags.push(LoroValue::String(tag.into())).ok(); 335 + } 336 + } 337 + 338 + /// Remove a tag by value. 339 + pub fn remove_tag(&mut self, tag: &str) { 340 + let len = self.tags.len(); 341 + for i in (0..len).rev() { 342 + if let Some(loro::ValueOrContainer::Value(LoroValue::String(s))) = self.tags.get(i) { 343 + if s.as_str() == tag { 344 + self.tags.delete(i, 1).ok(); 345 + break; 346 + } 347 + } 348 + } 349 + } 350 + 351 + /// Clear all tags. 352 + pub fn clear_tags(&mut self) { 353 + let len = self.tags.len(); 354 + if len > 0 { 355 + self.tags.delete(0, len).ok(); 356 + } 357 + } 358 + 359 + // --- Images accessors --- 360 + 361 + /// Get the images LoroList from embeds, creating it if needed. 362 + fn get_images_list(&self) -> LoroList { 363 + self.embeds 364 + .get_or_create_container("images", LoroList::new()) 365 + .unwrap() 194 366 } 195 367 196 - /// Insert text and record edit info for incremental rendering. 368 + /// Get all images as a Vec. 369 + pub fn images(&self) -> Vec<EditorImage> { 370 + let images_list = self.get_images_list(); 371 + let mut result = Vec::new(); 372 + 373 + for i in 0..images_list.len() { 374 + if let Some(editor_image) = self.loro_value_to_editor_image(&images_list, i) { 375 + result.push(editor_image); 376 + } 377 + } 378 + 379 + result 380 + } 381 + 382 + /// Convert a LoroValue at the given index to an EditorImage. 383 + fn loro_value_to_editor_image(&self, list: &LoroList, index: usize) -> Option<EditorImage> { 384 + let value = list.get(index)?; 385 + 386 + // Extract LoroValue from ValueOrContainer 387 + let loro_value = value.as_value()?; 388 + 389 + // Convert LoroValue to serde_json::Value 390 + let json = loro_value.to_json_value(); 391 + 392 + // Deserialize using Jacquard's from_json_value - publishedBlobUri ends up in extra_data 393 + let image: Image<'static> = from_json_value::<Image>(json).ok()?; 394 + 395 + // Extract our tracking field from extra_data 396 + let published_blob_uri = image 397 + .extra_data 398 + .as_ref() 399 + .and_then(|m| m.get("publishedBlobUri")) 400 + .and_then(|d| d.as_str()) 401 + .and_then(|s| AtUri::new(s).ok()) 402 + .map(|uri| uri.into_static()); 403 + 404 + Some(EditorImage { 405 + image, 406 + published_blob_uri, 407 + }) 408 + } 409 + 410 + /// Add an image to the embeds. 411 + /// The Image is serialized to JSON with our publishedBlobUri added. 412 + pub fn add_image(&mut self, image: &Image<'_>, published_blob_uri: Option<&AtUri<'_>>) { 413 + // Serialize the Image to serde_json::Value 414 + let mut json = serde_json::to_value(image).expect("Image serializes"); 415 + 416 + // Add our tracking field (not part of lexicon, stored in extra_data on deserialize) 417 + if let Some(uri) = published_blob_uri { 418 + json.as_object_mut() 419 + .unwrap() 420 + .insert("publishedBlobUri".into(), uri.as_str().into()); 421 + } 422 + 423 + // Insert into the images list 424 + let images_list = self.get_images_list(); 425 + images_list.push(json).ok(); 426 + } 427 + 428 + /// Remove an image by index. 429 + pub fn remove_image(&mut self, index: usize) { 430 + let images_list = self.get_images_list(); 431 + if index < images_list.len() { 432 + images_list.delete(index, 1).ok(); 433 + } 434 + } 435 + 436 + /// Get a single image by index. 437 + pub fn get_image(&self, index: usize) -> Option<EditorImage> { 438 + let images_list = self.get_images_list(); 439 + self.loro_value_to_editor_image(&images_list, index) 440 + } 441 + 442 + /// Get the number of images. 443 + pub fn images_len(&self) -> usize { 444 + self.get_images_list().len() 445 + } 446 + 447 + /// Update the alt text of an image at the given index. 448 + pub fn update_image_alt(&mut self, index: usize, alt: &str) { 449 + let images_list = self.get_images_list(); 450 + if let Some(value) = images_list.get(index) { 451 + if let Some(loro_value) = value.as_value() { 452 + let mut json = loro_value.to_json_value(); 453 + if let Some(obj) = json.as_object_mut() { 454 + obj.insert("alt".into(), alt.into()); 455 + // Replace the entire value at this index 456 + images_list.delete(index, 1).ok(); 457 + images_list.insert(index, json).ok(); 458 + } 459 + } 460 + } 461 + } 462 + 463 + /// Insert text into content and record edit info for incremental rendering. 197 464 pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { 198 465 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 199 - let len_before = self.text.len_unicode(); 200 - let result = self.text.insert(pos, text); 201 - let len_after = self.text.len_unicode(); 466 + let len_before = self.content.len_unicode(); 467 + let result = self.content.insert(pos, text); 468 + let len_after = self.content.len_unicode(); 202 469 self.last_edit = Some(EditInfo { 203 470 edit_char_pos: pos, 204 471 inserted_len: len_after.saturating_sub(len_before), ··· 210 477 result 211 478 } 212 479 213 - /// Push text to end of document. Faster than insert for appending. 480 + /// Push text to end of content. Faster than insert for appending. 214 481 pub fn push_tracked(&mut self, text: &str) -> LoroResult<()> { 215 - let pos = self.text.len_unicode(); 482 + let pos = self.content.len_unicode(); 216 483 let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); 217 - let result = self.text.push_str(text); 218 - let len_after = self.text.len_unicode(); 484 + let result = self.content.push_str(text); 485 + let len_after = self.content.len_unicode(); 219 486 self.last_edit = Some(EditInfo { 220 487 edit_char_pos: pos, 221 488 inserted_len: text.chars().count(), ··· 227 494 result 228 495 } 229 496 230 - /// Remove text range and record edit info for incremental rendering. 497 + /// Remove text range from content and record edit info for incremental rendering. 231 498 pub fn remove_tracked(&mut self, start: usize, len: usize) -> LoroResult<()> { 232 - let content = self.text.to_string(); 233 - let contains_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); 499 + let content_str = self.content.to_string(); 500 + let contains_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n'); 234 501 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 235 502 236 - let result = self.text.delete(start, len); 503 + let result = self.content.delete(start, len); 237 504 self.last_edit = Some(EditInfo { 238 505 edit_char_pos: start, 239 506 inserted_len: 0, 240 507 deleted_len: len, 241 508 contains_newline, 242 509 in_block_syntax_zone, 243 - doc_len_after: self.text.len_unicode(), 510 + doc_len_after: self.content.len_unicode(), 244 511 }); 245 512 result 246 513 } 247 514 248 - /// Replace text (delete then insert) and record combined edit info. 515 + /// Replace text in content (delete then insert) and record combined edit info. 249 516 pub fn replace_tracked(&mut self, start: usize, len: usize, text: &str) -> LoroResult<()> { 250 - let content = self.text.to_string(); 251 - let delete_has_newline = content.chars().skip(start).take(len).any(|c| c == '\n'); 517 + let content_str = self.content.to_string(); 518 + let delete_has_newline = content_str.chars().skip(start).take(len).any(|c| c == '\n'); 252 519 let in_block_syntax_zone = self.is_in_block_syntax_zone(start); 253 520 254 - let len_before = self.text.len_unicode(); 521 + let len_before = self.content.len_unicode(); 255 522 // Use splice for atomic replace 256 - self.text.splice(start, len, text)?; 257 - let len_after = self.text.len_unicode(); 523 + self.content.splice(start, len, text)?; 524 + let len_after = self.content.len_unicode(); 258 525 259 526 // inserted_len = (len_after - len_before) + deleted_len 260 527 // because: len_after = len_before - deleted + inserted ··· 312 579 self.undo_mgr.can_redo() 313 580 } 314 581 315 - /// Get a slice of the document text. 582 + /// Get a slice of the content text. 316 583 /// Returns None if the range is invalid. 317 584 pub fn slice(&self, start: usize, end: usize) -> Option<String> { 318 - self.text.slice(start, end).ok() 585 + self.content.slice(start, end).ok() 319 586 } 320 587 321 588 /// Sync the Loro cursor to the current cursor.offset position. 322 589 /// Call this after OUR edits where we know the new cursor position. 323 590 pub fn sync_loro_cursor(&mut self) { 324 - self.loro_cursor = self.text.get_cursor(self.cursor.offset, Side::default()); 591 + self.loro_cursor = self.content.get_cursor(self.cursor.offset, Side::default()); 325 592 } 326 593 327 594 /// Update cursor.offset from the Loro cursor's tracked position. ··· 383 650 } 384 651 } 385 652 386 - let text = doc.get_text("content"); 653 + // Get all containers (they will contain data from the snapshot if import succeeded) 654 + let content = doc.get_text("content"); 655 + let title = doc.get_text("title"); 656 + let path = doc.get_text("path"); 657 + let created_at = doc.get_text("created_at"); 658 + let tags = doc.get_list("tags"); 659 + let embeds = doc.get_map("embeds"); 387 660 388 661 // Set up undo manager - tracks operations from this point forward only 389 662 let mut undo_mgr = UndoManager::new(&doc); ··· 391 664 undo_mgr.set_max_undo_steps(100); 392 665 393 666 // Try to restore cursor from Loro cursor, fall back to offset 394 - let max_offset = text.len_unicode(); 667 + let max_offset = content.len_unicode(); 395 668 let cursor_offset = if let Some(ref lc) = loro_cursor { 396 669 doc.get_cursor_pos(lc) 397 670 .map(|r| r.current.pos) ··· 406 679 }; 407 680 408 681 // If no Loro cursor provided, create one at the restored position 409 - let loro_cursor = loro_cursor.or_else(|| text.get_cursor(cursor.offset, Side::default())); 682 + let loro_cursor = 683 + loro_cursor.or_else(|| content.get_cursor(cursor.offset, Side::default())); 410 684 411 685 Self { 412 686 doc, 413 - text, 687 + content, 688 + title, 689 + path, 690 + created_at, 691 + tags, 692 + embeds, 414 693 undo_mgr, 415 694 cursor, 416 695 loro_cursor, ··· 427 706 428 707 impl Clone for EditorDocument { 429 708 fn clone(&self) -> Self { 430 - // Create a new document with the same content 431 - let content = self.to_string(); 432 - let mut new_doc = Self::new(content); 709 + // Use snapshot export/import for a complete clone including all containers 710 + let snapshot = self.export_snapshot(); 711 + let mut new_doc = 712 + Self::from_snapshot(&snapshot, self.loro_cursor.clone(), self.cursor.offset); 713 + 714 + // Copy non-CRDT state 433 715 new_doc.cursor = self.cursor; 434 - // Recreate Loro cursor at the same position in the new doc 435 716 new_doc.sync_loro_cursor(); 436 717 new_doc.selection = self.selection; 437 718 new_doc.composition = self.composition.clone();
+286
crates/weaver-app/src/components/editor/dom_sync.rs
··· 1 + //! DOM synchronization for the markdown editor. 2 + //! 3 + //! Handles syncing cursor/selection state between the browser DOM and our 4 + //! internal document model, and updating paragraph DOM elements. 5 + 6 + use dioxus::prelude::*; 7 + 8 + use super::document::{EditorDocument, Selection}; 9 + use super::paragraph::ParagraphRender; 10 + 11 + /// Sync internal cursor and selection state from browser DOM selection. 12 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 13 + pub fn sync_cursor_from_dom( 14 + document: &mut Signal<EditorDocument>, 15 + editor_id: &str, 16 + paragraphs: &[ParagraphRender], 17 + ) { 18 + use wasm_bindgen::JsCast; 19 + 20 + // Early return if paragraphs not yet populated (first render edge case) 21 + if paragraphs.is_empty() { 22 + return; 23 + } 24 + 25 + let window = match web_sys::window() { 26 + Some(w) => w, 27 + None => return, 28 + }; 29 + 30 + let dom_document = match window.document() { 31 + Some(d) => d, 32 + None => return, 33 + }; 34 + 35 + let editor_element = match dom_document.get_element_by_id(editor_id) { 36 + Some(e) => e, 37 + None => return, 38 + }; 39 + 40 + let selection = match window.get_selection() { 41 + Ok(Some(sel)) => sel, 42 + _ => return, 43 + }; 44 + 45 + // Get both anchor (selection start) and focus (selection end) positions 46 + let anchor_node = match selection.anchor_node() { 47 + Some(node) => node, 48 + None => return, 49 + }; 50 + let focus_node = match selection.focus_node() { 51 + Some(node) => node, 52 + None => return, 53 + }; 54 + let anchor_offset = selection.anchor_offset() as usize; 55 + let focus_offset = selection.focus_offset() as usize; 56 + 57 + // Convert both DOM positions to rope offsets using cached paragraphs 58 + let anchor_rope = dom_position_to_rope_offset( 59 + &dom_document, 60 + &editor_element, 61 + &anchor_node, 62 + anchor_offset, 63 + paragraphs, 64 + ); 65 + let focus_rope = dom_position_to_rope_offset( 66 + &dom_document, 67 + &editor_element, 68 + &focus_node, 69 + focus_offset, 70 + paragraphs, 71 + ); 72 + 73 + document.with_mut(|doc| { 74 + match (anchor_rope, focus_rope) { 75 + (Some(anchor), Some(focus)) => { 76 + doc.cursor.offset = focus; 77 + if anchor != focus { 78 + // There's an actual selection 79 + doc.selection = Some(Selection { 80 + anchor, 81 + head: focus, 82 + }); 83 + } else { 84 + // Collapsed selection (just cursor) 85 + doc.selection = None; 86 + } 87 + } 88 + _ => { 89 + tracing::warn!("Could not map DOM selection to rope offsets"); 90 + } 91 + } 92 + }); 93 + } 94 + 95 + /// Convert a DOM position (node + offset) to a rope char offset using offset maps. 96 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 97 + fn dom_position_to_rope_offset( 98 + dom_document: &web_sys::Document, 99 + editor_element: &web_sys::Element, 100 + node: &web_sys::Node, 101 + offset_in_text_node: usize, 102 + paragraphs: &[ParagraphRender], 103 + ) -> Option<usize> { 104 + use wasm_bindgen::JsCast; 105 + 106 + // Find the containing element with a node ID (walk up from text node) 107 + let mut current_node = node.clone(); 108 + let node_id = loop { 109 + if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 110 + if element == editor_element { 111 + // Selection is on the editor container itself (e.g., Cmd+A select all) 112 + // Return boundary position based on offset: 113 + // offset 0 = start of editor, offset == child count = end of editor 114 + let child_count = editor_element.child_element_count() as usize; 115 + if offset_in_text_node == 0 { 116 + return Some(0); // Start of document 117 + } else if offset_in_text_node >= child_count { 118 + // End of document - find last paragraph's end 119 + return paragraphs.last().map(|p| p.char_range.end); 120 + } 121 + break None; 122 + } 123 + 124 + let id = element 125 + .get_attribute("id") 126 + .or_else(|| element.get_attribute("data-node-id")); 127 + 128 + if let Some(id) = id { 129 + if id.starts_with('n') && id[1..].parse::<usize>().is_ok() { 130 + break Some(id); 131 + } 132 + } 133 + } 134 + 135 + current_node = current_node.parent_node()?; 136 + }; 137 + 138 + let node_id = node_id?; 139 + 140 + // Get the container element 141 + let container = dom_document.get_element_by_id(&node_id).or_else(|| { 142 + let selector = format!("[data-node-id='{}']", node_id); 143 + dom_document.query_selector(&selector).ok().flatten() 144 + })?; 145 + 146 + // Calculate UTF-16 offset from start of container to the position 147 + let mut utf16_offset_in_container = 0; 148 + 149 + if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { 150 + while let Ok(Some(text_node)) = walker.next_node() { 151 + if &text_node == node { 152 + utf16_offset_in_container += offset_in_text_node; 153 + break; 154 + } 155 + 156 + if let Some(text) = text_node.text_content() { 157 + utf16_offset_in_container += text.encode_utf16().count(); 158 + } 159 + } 160 + } 161 + 162 + // Look up in offset maps 163 + for para in paragraphs { 164 + for mapping in &para.offset_map { 165 + if mapping.node_id == node_id { 166 + let mapping_start = mapping.char_offset_in_node; 167 + let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; 168 + 169 + if utf16_offset_in_container >= mapping_start 170 + && utf16_offset_in_container <= mapping_end 171 + { 172 + let offset_in_mapping = utf16_offset_in_container - mapping_start; 173 + return Some(mapping.char_range.start + offset_in_mapping); 174 + } 175 + } 176 + } 177 + } 178 + 179 + None 180 + } 181 + 182 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 183 + pub fn sync_cursor_from_dom( 184 + _document: &mut Signal<EditorDocument>, 185 + _editor_id: &str, 186 + _paragraphs: &[ParagraphRender], 187 + ) { 188 + // No-op on non-wasm 189 + } 190 + 191 + /// Update paragraph DOM elements incrementally. 192 + /// 193 + /// Only modifies paragraphs that changed (by comparing source_hash). 194 + /// Browser preserves cursor naturally in unchanged paragraphs. 195 + /// 196 + /// Returns true if the paragraph containing the cursor was updated. 197 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 198 + pub fn update_paragraph_dom( 199 + editor_id: &str, 200 + old_paragraphs: &[ParagraphRender], 201 + new_paragraphs: &[ParagraphRender], 202 + cursor_offset: usize, 203 + ) -> bool { 204 + use wasm_bindgen::JsCast; 205 + 206 + let window = match web_sys::window() { 207 + Some(w) => w, 208 + None => return false, 209 + }; 210 + 211 + let document = match window.document() { 212 + Some(d) => d, 213 + None => return false, 214 + }; 215 + 216 + let editor = match document.get_element_by_id(editor_id) { 217 + Some(e) => e, 218 + None => return false, 219 + }; 220 + 221 + // Find which paragraph contains cursor 222 + // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) 223 + // This handles typing at end of paragraph, which is the common case 224 + // The empty paragraph at document end catches any trailing cursor positions 225 + let cursor_para_idx = new_paragraphs 226 + .iter() 227 + .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); 228 + 229 + let mut cursor_para_updated = false; 230 + 231 + // Update or create paragraphs 232 + for (idx, new_para) in new_paragraphs.iter().enumerate() { 233 + let para_id = format!("para-{}", idx); 234 + 235 + if let Some(old_para) = old_paragraphs.get(idx) { 236 + // Paragraph exists - check if changed 237 + if new_para.source_hash != old_para.source_hash { 238 + // Changed - clear and update innerHTML 239 + // We clear first to ensure any browser-added content (from IME composition, 240 + // contenteditable quirks, etc.) is fully removed before setting new content 241 + if let Some(elem) = document.get_element_by_id(&para_id) { 242 + elem.set_text_content(None); // Clear completely 243 + elem.set_inner_html(&new_para.html); 244 + } 245 + 246 + // Track if we updated the cursor's paragraph 247 + if Some(idx) == cursor_para_idx { 248 + cursor_para_updated = true; 249 + } 250 + } 251 + // Unchanged - do nothing, browser preserves cursor 252 + } else { 253 + // New paragraph - create div 254 + if let Ok(div) = document.create_element("div") { 255 + div.set_id(&para_id); 256 + div.set_inner_html(&new_para.html); 257 + let _ = editor.append_child(&div); 258 + } 259 + 260 + // Track if we created the cursor's paragraph 261 + if Some(idx) == cursor_para_idx { 262 + cursor_para_updated = true; 263 + } 264 + } 265 + } 266 + 267 + // Remove extra paragraphs if document got shorter 268 + for idx in new_paragraphs.len()..old_paragraphs.len() { 269 + let para_id = format!("para-{}", idx); 270 + if let Some(elem) = document.get_element_by_id(&para_id) { 271 + let _ = elem.remove(); 272 + } 273 + } 274 + 275 + cursor_para_updated 276 + } 277 + 278 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 279 + pub fn update_paragraph_dom( 280 + _editor_id: &str, 281 + _old_paragraphs: &[ParagraphRender], 282 + _new_paragraphs: &[ParagraphRender], 283 + _cursor_offset: usize, 284 + ) -> bool { 285 + false 286 + }
+1 -1
crates/weaver-app/src/components/editor/formatting.rs
··· 1 1 //! Formatting actions and utilities for applying markdown formatting. 2 2 3 - use crate::components::editor::{ListContext, detect_list_context, find_line_end}; 3 + use super::input::{ListContext, detect_list_context, find_line_end}; 4 4 5 5 use super::document::EditorDocument; 6 6
+639
crates/weaver-app/src/components/editor/input.rs
··· 1 + //! Input handling for the markdown editor. 2 + //! 3 + //! Keyboard events, clipboard operations, and text manipulation. 4 + 5 + use dioxus::prelude::*; 6 + 7 + use super::document::EditorDocument; 8 + use super::formatting::{self, FormatAction}; 9 + 10 + /// Check if we need to intercept this key event. 11 + /// Returns true for content-modifying operations, false for navigation. 12 + pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 13 + use dioxus::prelude::keyboard_types::Key; 14 + 15 + let key = evt.key(); 16 + let mods = evt.modifiers(); 17 + 18 + // Handle Ctrl/Cmd shortcuts 19 + if mods.ctrl() || mods.meta() { 20 + if let Key::Character(ch) = &key { 21 + // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) 22 + match ch.as_str() { 23 + "b" | "i" | "z" | "y" => return true, 24 + "e" => return true, // Ctrl+E for HTML export/copy 25 + _ => {} 26 + } 27 + } 28 + // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 29 + return false; 30 + } 31 + 32 + // Intercept content modifications 33 + matches!( 34 + key, 35 + Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab 36 + ) 37 + } 38 + 39 + /// Handle keyboard events and update document state. 40 + pub fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 41 + use dioxus::prelude::keyboard_types::Key; 42 + 43 + let key = evt.key(); 44 + let mods = evt.modifiers(); 45 + 46 + document.with_mut(|doc| { 47 + match key { 48 + Key::Character(ch) => { 49 + // Keyboard shortcuts first 50 + if mods.ctrl() { 51 + match ch.as_str() { 52 + "b" => { 53 + formatting::apply_formatting(doc, FormatAction::Bold); 54 + return; 55 + } 56 + "i" => { 57 + formatting::apply_formatting(doc, FormatAction::Italic); 58 + return; 59 + } 60 + "z" => { 61 + if mods.shift() { 62 + // Ctrl+Shift+Z = redo 63 + if let Ok(true) = doc.redo() { 64 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 65 + } 66 + } else { 67 + // Ctrl+Z = undo 68 + if let Ok(true) = doc.undo() { 69 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 70 + } 71 + } 72 + doc.selection = None; 73 + return; 74 + } 75 + "y" => { 76 + // Ctrl+Y = redo (alternative) 77 + if let Ok(true) = doc.redo() { 78 + doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 79 + } 80 + doc.selection = None; 81 + return; 82 + } 83 + "e" => { 84 + // Ctrl+E = copy as HTML (export) 85 + if let Some(sel) = doc.selection { 86 + let (start, end) = 87 + (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 88 + if start != end { 89 + if let Some(markdown) = doc.slice(start, end) { 90 + let clean_md = markdown 91 + .replace('\u{200C}', "") 92 + .replace('\u{200B}', ""); 93 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 94 + wasm_bindgen_futures::spawn_local(async move { 95 + if let Err(e) = copy_as_html(&clean_md).await { 96 + tracing::warn!("[COPY HTML] Failed: {:?}", e); 97 + } 98 + }); 99 + } 100 + } 101 + } 102 + return; 103 + } 104 + _ => {} 105 + } 106 + } 107 + 108 + // Insert character at cursor (replacing selection if any) 109 + if let Some(sel) = doc.selection.take() { 110 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 111 + let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 112 + doc.cursor.offset = start + ch.chars().count(); 113 + } else { 114 + // Clean up any preceding zero-width chars (gap scaffolding) 115 + let mut delete_start = doc.cursor.offset; 116 + while delete_start > 0 { 117 + match get_char_at(doc.loro_text(), delete_start - 1) { 118 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 119 + _ => break, 120 + } 121 + } 122 + 123 + let zw_count = doc.cursor.offset - delete_start; 124 + if zw_count > 0 { 125 + // Splice: delete zero-width chars and insert new char in one op 126 + let _ = doc.replace_tracked(delete_start, zw_count, &ch); 127 + doc.cursor.offset = delete_start + ch.chars().count(); 128 + } else if doc.cursor.offset == doc.len_chars() { 129 + // Fast path: append at end 130 + let _ = doc.push_tracked(&ch); 131 + doc.cursor.offset += ch.chars().count(); 132 + } else { 133 + let _ = doc.insert_tracked(doc.cursor.offset, &ch); 134 + doc.cursor.offset += ch.chars().count(); 135 + } 136 + } 137 + } 138 + 139 + Key::Backspace => { 140 + if let Some(sel) = doc.selection { 141 + // Delete selection 142 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 143 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 144 + doc.cursor.offset = start; 145 + doc.selection = None; 146 + } else if doc.cursor.offset > 0 { 147 + // Check if we're about to delete a newline 148 + let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); 149 + 150 + if prev_char == Some('\n') { 151 + let newline_pos = doc.cursor.offset - 1; 152 + let mut delete_start = newline_pos; 153 + let mut delete_end = doc.cursor.offset; 154 + 155 + // Check if there's another newline before this one (empty paragraph) 156 + // If so, delete both newlines to merge paragraphs 157 + if newline_pos > 0 { 158 + let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 159 + if prev_prev_char == Some('\n') { 160 + // Empty paragraph case: delete both newlines 161 + delete_start = newline_pos - 1; 162 + } 163 + } 164 + 165 + // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 166 + if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 167 + if ch == '\u{200C}' || ch == '\u{200B}' { 168 + delete_end += 1; 169 + } 170 + } 171 + 172 + // Scan backwards through whitespace before the newline(s) 173 + while delete_start > 0 { 174 + let ch = get_char_at(doc.loro_text(), delete_start - 1); 175 + match ch { 176 + Some('\u{200C}') | Some('\u{200B}') => { 177 + delete_start -= 1; 178 + } 179 + Some('\n') => break, // stop at another newline 180 + _ => break, // stop at actual content 181 + } 182 + } 183 + 184 + // Delete from where we stopped to end (including any trailing zero-width) 185 + let _ = doc 186 + .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 187 + doc.cursor.offset = delete_start; 188 + } else { 189 + // Normal backspace - delete one char 190 + let prev = doc.cursor.offset - 1; 191 + let _ = doc.remove_tracked(prev, 1); 192 + doc.cursor.offset = prev; 193 + } 194 + } 195 + } 196 + 197 + Key::Delete => { 198 + if let Some(sel) = doc.selection.take() { 199 + // Delete selection 200 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 201 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 202 + doc.cursor.offset = start; 203 + } else if doc.cursor.offset < doc.len_chars() { 204 + // Delete next char 205 + let _ = doc.remove_tracked(doc.cursor.offset, 1); 206 + } 207 + } 208 + 209 + // Arrow keys handled by browser, synced in onkeyup 210 + Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 211 + // Browser handles these naturally 212 + } 213 + 214 + Key::Enter => { 215 + if let Some(sel) = doc.selection.take() { 216 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 217 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 218 + doc.cursor.offset = start; 219 + } 220 + 221 + if mods.shift() { 222 + // Shift+Enter: hard line break (soft break) 223 + let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 224 + doc.cursor.offset += 3; 225 + } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 226 + // We're in a list item 227 + if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { 228 + // Empty item - exit list by removing marker and inserting paragraph break 229 + let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 230 + let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); 231 + 232 + // Delete the empty list item line INCLUDING its trailing newline 233 + // line_end points to the newline, so +1 to include it 234 + let delete_end = (line_end + 1).min(doc.len_chars()); 235 + 236 + // Use replace_tracked to atomically delete line and insert paragraph break 237 + let _ = doc.replace_tracked( 238 + line_start, 239 + delete_end.saturating_sub(line_start), 240 + "\n\n\u{200C}\n", 241 + ); 242 + doc.cursor.offset = line_start + 2; 243 + } else { 244 + // Non-empty item - continue list 245 + let continuation = match ctx { 246 + ListContext::Unordered { indent, marker } => { 247 + format!("\n{}{} ", indent, marker) 248 + } 249 + ListContext::Ordered { indent, number } => { 250 + format!("\n{}{}. ", indent, number + 1) 251 + } 252 + }; 253 + let len = continuation.chars().count(); 254 + let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 255 + doc.cursor.offset += len; 256 + } 257 + } else { 258 + // Not in a list - normal paragraph break 259 + let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 260 + doc.cursor.offset += 2; 261 + } 262 + } 263 + 264 + // Home/End handled by browser, synced in onkeyup 265 + Key::Home | Key::End => { 266 + // Browser handles these naturally 267 + } 268 + 269 + _ => {} 270 + } 271 + 272 + // Sync Loro cursor when edits affect paragraph boundaries 273 + // This ensures cursor position is tracked correctly through structural changes 274 + if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { 275 + doc.sync_loro_cursor(); 276 + } 277 + }); 278 + } 279 + 280 + /// Handle paste events and insert text at cursor. 281 + pub fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 282 + evt.prevent_default(); 283 + 284 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 285 + { 286 + use dioxus::web::WebEventExt; 287 + use wasm_bindgen::JsCast; 288 + 289 + let base_evt = evt.as_web_event(); 290 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 291 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 292 + // Try our custom type first (internal paste), fall back to text/plain 293 + let text = data_transfer 294 + .get_data("text/x-weaver-md") 295 + .ok() 296 + .filter(|s| !s.is_empty()) 297 + .or_else(|| data_transfer.get_data("text/plain").ok()); 298 + 299 + if let Some(text) = text { 300 + document.with_mut(|doc| { 301 + // Delete selection if present 302 + if let Some(sel) = doc.selection { 303 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 304 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 305 + doc.cursor.offset = start; 306 + doc.selection = None; 307 + } 308 + 309 + // Insert pasted text 310 + let _ = doc.insert_tracked(doc.cursor.offset, &text); 311 + doc.cursor.offset += text.chars().count(); 312 + }); 313 + } 314 + } 315 + } else { 316 + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 317 + } 318 + } 319 + } 320 + 321 + /// Handle cut events - extract text, write to clipboard, then delete. 322 + pub fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 323 + evt.prevent_default(); 324 + 325 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 326 + { 327 + use dioxus::web::WebEventExt; 328 + use wasm_bindgen::JsCast; 329 + 330 + let base_evt = evt.as_web_event(); 331 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 332 + let cut_text = document.with_mut(|doc| { 333 + if let Some(sel) = doc.selection { 334 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 335 + if start != end { 336 + // Extract text and strip zero-width chars 337 + let selected_text = doc.slice(start, end).unwrap_or_default(); 338 + let clean_text = selected_text 339 + .replace('\u{200C}', "") 340 + .replace('\u{200B}', ""); 341 + 342 + // Write to clipboard BEFORE deleting (sync fallback) 343 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 344 + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 345 + tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 346 + } 347 + } 348 + 349 + // Now delete 350 + let _ = doc.remove_tracked(start, end.saturating_sub(start)); 351 + doc.cursor.offset = start; 352 + doc.selection = None; 353 + 354 + return Some(clean_text); 355 + } 356 + } 357 + None 358 + }); 359 + 360 + // Async: also write custom MIME type for internal paste detection 361 + if let Some(text) = cut_text { 362 + wasm_bindgen_futures::spawn_local(async move { 363 + if let Err(e) = write_clipboard_with_custom_type(&text).await { 364 + tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); 365 + } 366 + }); 367 + } 368 + } 369 + } 370 + 371 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 372 + { 373 + let _ = evt; // suppress unused warning 374 + } 375 + } 376 + 377 + /// Handle copy events - extract text, clean it up, write to clipboard. 378 + pub fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 379 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 380 + { 381 + use dioxus::web::WebEventExt; 382 + use wasm_bindgen::JsCast; 383 + 384 + let base_evt = evt.as_web_event(); 385 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 386 + let doc = document.read(); 387 + if let Some(sel) = doc.selection { 388 + let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 389 + if start != end { 390 + // Extract text 391 + let selected_text = doc.slice(start, end).unwrap_or_default(); 392 + 393 + // Strip zero-width chars used for gap handling 394 + let clean_text = selected_text 395 + .replace('\u{200C}', "") 396 + .replace('\u{200B}', ""); 397 + 398 + // Sync fallback: write text/plain via DataTransfer 399 + if let Some(data_transfer) = clipboard_evt.clipboard_data() { 400 + if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 401 + tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 402 + } 403 + } 404 + 405 + // Async: also write custom MIME type for internal paste detection 406 + let text_for_async = clean_text.clone(); 407 + wasm_bindgen_futures::spawn_local(async move { 408 + if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { 409 + tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); 410 + } 411 + }); 412 + 413 + // Prevent browser's default copy (which would copy rendered HTML) 414 + evt.prevent_default(); 415 + } 416 + } 417 + } 418 + } 419 + 420 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 421 + { 422 + let _ = (evt, document); // suppress unused warnings 423 + } 424 + } 425 + 426 + /// Copy markdown as rendered HTML to clipboard. 427 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 428 + pub async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { 429 + use js_sys::Array; 430 + use wasm_bindgen::JsValue; 431 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 432 + 433 + // Render markdown to HTML using ClientWriter 434 + let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); 435 + let mut html = String::new(); 436 + weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( 437 + parser.map(|(evt, _range)| evt), 438 + &mut html, 439 + ) 440 + .run() 441 + .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 442 + 443 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 444 + let clipboard = window.navigator().clipboard(); 445 + 446 + // Create blobs for both HTML and plain text (raw HTML for inspection) 447 + let parts = Array::new(); 448 + parts.push(&JsValue::from_str(&html)); 449 + 450 + let mut html_opts = BlobPropertyBag::new(); 451 + html_opts.type_("text/html"); 452 + let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 453 + 454 + let mut text_opts = BlobPropertyBag::new(); 455 + text_opts.type_("text/plain"); 456 + let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 457 + 458 + // Create ClipboardItem with both types 459 + let item_data = js_sys::Object::new(); 460 + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 461 + js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 462 + 463 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 464 + let items = Array::new(); 465 + items.push(&clipboard_item); 466 + 467 + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 468 + tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 469 + Ok(()) 470 + } 471 + 472 + /// Write text to clipboard with both text/plain and custom MIME type. 473 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 474 + pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { 475 + use js_sys::{Array, Object, Reflect}; 476 + use wasm_bindgen::JsValue; 477 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 478 + 479 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 480 + let navigator = window.navigator(); 481 + let clipboard = navigator.clipboard(); 482 + 483 + // Create blobs for each MIME type 484 + let text_parts = Array::new(); 485 + text_parts.push(&JsValue::from_str(text)); 486 + 487 + let mut text_opts = BlobPropertyBag::new(); 488 + text_opts.type_("text/plain"); 489 + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 490 + 491 + let mut custom_opts = BlobPropertyBag::new(); 492 + custom_opts.type_("text/x-weaver-md"); 493 + let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 494 + 495 + // Create ClipboardItem with both types 496 + let item_data = Object::new(); 497 + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 498 + Reflect::set( 499 + &item_data, 500 + &JsValue::from_str("text/x-weaver-md"), 501 + &custom_blob, 502 + )?; 503 + 504 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 505 + let items = Array::new(); 506 + items.push(&clipboard_item); 507 + 508 + let promise = clipboard.write(&items); 509 + wasm_bindgen_futures::JsFuture::from(promise).await?; 510 + 511 + Ok(()) 512 + } 513 + 514 + /// Describes what kind of list item the cursor is in, if any. 515 + #[derive(Debug, Clone)] 516 + pub enum ListContext { 517 + /// Unordered list with the given marker char ('-' or '*') and indentation. 518 + Unordered { indent: String, marker: char }, 519 + /// Ordered list with the current number and indentation. 520 + Ordered { indent: String, number: usize }, 521 + } 522 + 523 + /// Detect if cursor is in a list item and return context for continuation. 524 + /// 525 + /// Scans backwards to find start of current line, then checks for list marker. 526 + pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> { 527 + // Find start of current line 528 + let line_start = find_line_start(text, cursor_offset); 529 + 530 + // Get the line content from start to cursor 531 + let line_end = find_line_end(text, cursor_offset); 532 + if line_start >= line_end { 533 + return None; 534 + } 535 + 536 + // Extract line text 537 + let line = text.slice(line_start, line_end).ok()?; 538 + 539 + // Parse indentation 540 + let indent: String = line 541 + .chars() 542 + .take_while(|c| *c == ' ' || *c == '\t') 543 + .collect(); 544 + let trimmed = &line[indent.len()..]; 545 + 546 + // Check for unordered list marker: "- " or "* " 547 + if trimmed.starts_with("- ") { 548 + return Some(ListContext::Unordered { 549 + indent, 550 + marker: '-', 551 + }); 552 + } 553 + if trimmed.starts_with("* ") { 554 + return Some(ListContext::Unordered { 555 + indent, 556 + marker: '*', 557 + }); 558 + } 559 + 560 + // Check for ordered list marker: "1. ", "2. ", "123. ", etc. 561 + if let Some(dot_pos) = trimmed.find(". ") { 562 + let num_part = &trimmed[..dot_pos]; 563 + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { 564 + if let Ok(number) = num_part.parse::<usize>() { 565 + return Some(ListContext::Ordered { indent, number }); 566 + } 567 + } 568 + } 569 + 570 + None 571 + } 572 + 573 + /// Check if the current list item is empty (just the marker, no content after cursor). 574 + /// 575 + /// Used to determine whether Enter should continue the list or exit it. 576 + pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { 577 + let line_start = find_line_start(text, cursor_offset); 578 + let line_end = find_line_end(text, cursor_offset); 579 + 580 + // Get line content 581 + let line = match text.slice(line_start, line_end) { 582 + Ok(s) => s, 583 + Err(_) => return false, 584 + }; 585 + 586 + // Calculate expected marker length 587 + let marker_len = match ctx { 588 + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " 589 + ListContext::Ordered { indent, number } => { 590 + indent.len() + number.to_string().len() + 2 // "1. " 591 + } 592 + }; 593 + 594 + // Item is empty if line length equals marker length (nothing after marker) 595 + line.len() <= marker_len 596 + } 597 + 598 + /// Get character at the given offset in LoroText. 599 + pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> { 600 + text.char_at(offset).ok() 601 + } 602 + 603 + /// Find start of line containing offset. 604 + pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 605 + if offset == 0 { 606 + return 0; 607 + } 608 + // Only slice the portion before cursor 609 + let prefix = match text.slice(0, offset) { 610 + Ok(s) => s, 611 + Err(_) => return 0, 612 + }; 613 + prefix 614 + .chars() 615 + .enumerate() 616 + .filter(|(_, c)| *c == '\n') 617 + .last() 618 + .map(|(pos, _)| pos + 1) 619 + .unwrap_or(0) 620 + } 621 + 622 + /// Find end of line containing offset. 623 + pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { 624 + let char_len = text.len_unicode(); 625 + if offset >= char_len { 626 + return char_len; 627 + } 628 + // Only slice from cursor to end 629 + let suffix = match text.slice(offset, char_len) { 630 + Ok(s) => s, 631 + Err(_) => return char_len, 632 + }; 633 + suffix 634 + .chars() 635 + .enumerate() 636 + .find(|(_, c)| *c == '\n') 637 + .map(|(i, _)| offset + i) 638 + .unwrap_or(char_len) 639 + }
+4 -2
crates/weaver-app/src/components/editor/log_buffer.rs
··· 9 9 10 10 use tracing::field::{Field, Visit}; 11 11 use tracing::{Event, Level, Subscriber}; 12 - use tracing_subscriber::layer::Context; 13 12 use tracing_subscriber::Layer; 13 + use tracing_subscriber::layer::Context; 14 14 15 15 /// Maximum number of log entries to keep. 16 16 const MAX_ENTRIES: usize = 100; ··· 36 36 let target = metadata.target(); 37 37 38 38 // Only buffer debug+ logs from our modules 39 - let is_our_module = CAPTURED_PREFIXES.iter().any(|prefix| target.starts_with(prefix)); 39 + let is_our_module = CAPTURED_PREFIXES 40 + .iter() 41 + .any(|prefix| target.starts_with(prefix)); 40 42 if !is_our_module || *level > BUFFER_MIN_LEVEL { 41 43 return; 42 44 }
+35 -1554
crates/weaver-app/src/components/editor/mod.rs
··· 4 4 //! characters are hidden contextually based on cursor position, while still 5 5 //! editing plain markdown text under the hood. 6 6 7 + mod component; 7 8 mod cursor; 8 9 mod document; 10 + mod dom_sync; 9 11 mod formatting; 12 + mod input; 10 13 mod log_buffer; 11 14 mod offset_map; 12 15 mod paragraph; 13 16 mod platform; 17 + mod publish; 14 18 mod render; 15 19 mod report; 16 20 mod storage; ··· 21 25 #[cfg(test)] 22 26 mod tests; 23 27 28 + // Main component 29 + pub use component::MarkdownEditor; 30 + 31 + // Document types 32 + #[allow(unused_imports)] 24 33 pub use document::{Affinity, CompositionState, CursorState, EditorDocument, Selection}; 34 + 35 + // Formatting 36 + #[allow(unused_imports)] 25 37 pub use formatting::{FormatAction, apply_formatting, find_word_boundaries}; 38 + 39 + // Rendering 40 + #[allow(unused_imports)] 26 41 pub use offset_map::{OffsetMapping, RenderResult, find_mapping_for_byte}; 42 + #[allow(unused_imports)] 27 43 pub use paragraph::ParagraphRender; 44 + #[allow(unused_imports)] 28 45 pub use render::{RenderCache, render_paragraphs_incremental}; 29 - pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_storage}; 30 - pub use toolbar::EditorToolbar; 31 - pub use visibility::VisibilityState; 46 + #[allow(unused_imports)] 32 47 pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; 33 - pub use report::ReportButton; 34 - pub use log_buffer::LogCaptureLayer; 35 - 36 - use dioxus::prelude::*; 37 48 38 - use crate::components::record_view::CodeView; 39 - 40 - /// Main markdown editor component. 41 - /// 42 - /// # Props 43 - /// - `initial_content`: Optional initial markdown content 44 - /// 45 - /// # Features 46 - /// - Loro CRDT-based text storage with undo/redo support 47 - /// - Event interception for full control over editing operations 48 - /// - Toolbar formatting buttons 49 - /// - LocalStorage auto-save with debouncing 50 - /// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic) 51 - /// 52 - /// # Phase 1 Limitations (mostly resolved) 53 - /// - Cursor jumps to end after each keystroke (acceptable for MVP) 54 - /// - All formatting characters visible (no hiding based on cursor position) - RESOLVED 55 - /// - No proper grapheme cluster handling 56 - /// - No undo/redo - RESOLVED (Loro UndoManager) 57 - /// - No selection with Shift+Arrow 58 - /// - No mouse selection - RESOLVED 59 - #[component] 60 - pub fn MarkdownEditor(initial_content: Option<String>) -> Element { 61 - // Try to restore from localStorage (includes CRDT state for undo history) 62 - let mut document = use_signal(move || { 63 - storage::load_from_storage() 64 - .unwrap_or_else(|| EditorDocument::new(initial_content.clone().unwrap_or_default())) 65 - }); 66 - let editor_id = "markdown-editor"; 67 - 68 - // Cache for incremental paragraph rendering 69 - let mut render_cache = use_signal(|| render::RenderCache::default()); 70 - 71 - // Render paragraphs with incremental caching 72 - let paragraphs = use_memo(move || { 73 - let doc = document(); 74 - let cache = render_cache.peek(); 75 - let edit = doc.last_edit.as_ref(); 76 - 77 - let (paras, new_cache) = 78 - render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit); 79 - 80 - // Update cache for next render (write-only via spawn to avoid reactive loop) 81 - dioxus::prelude::spawn(async move { 82 - render_cache.set(new_cache); 83 - }); 84 - 85 - paras 86 - }); 87 - 88 - // Flatten offset maps from all paragraphs 89 - let offset_map = use_memo(move || { 90 - paragraphs() 91 - .iter() 92 - .flat_map(|p| p.offset_map.iter().cloned()) 93 - .collect::<Vec<_>>() 94 - }); 95 - 96 - // Flatten syntax spans from all paragraphs 97 - let syntax_spans = use_memo(move || { 98 - paragraphs() 99 - .iter() 100 - .flat_map(|p| p.syntax_spans.iter().cloned()) 101 - .collect::<Vec<_>>() 102 - }); 103 - 104 - // Cache paragraphs for change detection AND for event handlers to access 105 - let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 106 - 107 - // Update DOM when paragraphs change (incremental rendering) 108 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 109 - use_effect(move || { 110 - tracing::debug!("DOM update effect triggered"); 111 - 112 - // Read document once to avoid multiple borrows 113 - let doc = document(); 114 - 115 - tracing::debug!( 116 - composition_active = doc.composition.is_some(), 117 - cursor = doc.cursor.offset, 118 - "DOM update: checking state" 119 - ); 120 - 121 - // Skip DOM updates during IME composition - browser controls the preview 122 - if doc.composition.is_some() { 123 - tracing::debug!("skipping DOM update during composition"); 124 - return; 125 - } 126 - 127 - tracing::debug!( 128 - cursor = doc.cursor.offset, 129 - len = doc.len_chars(), 130 - "DOM update proceeding (not in composition)" 131 - ); 132 - 133 - let cursor_offset = doc.cursor.offset; 134 - let selection = doc.selection; 135 - drop(doc); // Release borrow before other operations 136 - 137 - let new_paras = paragraphs(); 138 - let map = offset_map(); 139 - let spans = syntax_spans(); 140 - 141 - // Use peek() to avoid creating reactive dependency on cached_paragraphs 142 - let prev = cached_paragraphs.peek().clone(); 143 - 144 - let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset); 145 - 146 - // Only restore cursor if we actually re-rendered the paragraph it's in 147 - if cursor_para_updated { 148 - use wasm_bindgen::JsCast; 149 - use wasm_bindgen::prelude::*; 150 - 151 - // Use requestAnimationFrame to wait for browser paint 152 - if let Some(window) = web_sys::window() { 153 - let closure = Closure::once(move || { 154 - if let Err(e) = cursor::restore_cursor_position(cursor_offset, &map, editor_id) 155 - { 156 - tracing::warn!("Cursor restoration failed: {:?}", e); 157 - } 158 - }); 159 - 160 - let _ = window.request_animation_frame(closure.as_ref().unchecked_ref()); 161 - closure.forget(); 162 - } 163 - } 164 - 165 - // Store for next comparison AND for event handlers (write-only, no reactive read) 166 - cached_paragraphs.set(new_paras.clone()); 167 - 168 - // Update syntax visibility after DOM changes 169 - update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &new_paras); 170 - }); 171 - 172 - // Track last saved frontiers to detect changes (peek-only, no subscriptions) 173 - let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 174 - 175 - // Auto-save with periodic check (no reactive dependency to avoid loops) 176 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 177 - use_effect(move || { 178 - // Check every 500ms if there are unsaved changes 179 - let interval = gloo_timers::callback::Interval::new(500, move || { 180 - // Peek both signals without creating reactive dependencies 181 - let current_frontiers = document.peek().state_frontiers(); 182 - 183 - // Only save if frontiers changed (document was edited) 184 - let needs_save = { 185 - let last_frontiers = last_saved_frontiers.peek(); 186 - match &*last_frontiers { 187 - None => true, // First save 188 - Some(last) => &current_frontiers != last, 189 - } 190 - }; // drop last_frontiers borrow here 191 - 192 - if needs_save { 193 - // Sync cursor and extract data for save 194 - let (content, cursor_offset, loro_cursor, snapshot_bytes) = 195 - document.with_mut(|doc| { 196 - doc.sync_loro_cursor(); 197 - ( 198 - doc.to_string(), 199 - doc.cursor.offset, 200 - doc.loro_cursor().cloned(), 201 - doc.export_snapshot(), 202 - ) 203 - }); 204 - 205 - use gloo_storage::Storage as _; // bring trait into scope for LocalStorage::set 206 - let snapshot_b64 = if snapshot_bytes.is_empty() { 207 - None 208 - } else { 209 - Some(base64::Engine::encode( 210 - &base64::engine::general_purpose::STANDARD, 211 - &snapshot_bytes, 212 - )) 213 - }; 214 - let snapshot = storage::EditorSnapshot { 215 - content, 216 - snapshot: snapshot_b64, 217 - cursor: loro_cursor, 218 - cursor_offset, 219 - }; 220 - let _ = gloo_storage::LocalStorage::set("weaver_editor_draft", &snapshot); 221 - 222 - // Update last saved frontiers 223 - last_saved_frontiers.set(Some(current_frontiers)); 224 - } 225 - }); 226 - interval.forget(); 227 - }); 228 - 229 - // Set up beforeinput listener for iOS/Android virtual keyboard quirks 230 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 231 - use_effect(move || { 232 - use wasm_bindgen::JsCast; 233 - use wasm_bindgen::prelude::*; 234 - 235 - let plat = platform::platform(); 236 - 237 - // Only needed on mobile 238 - if !plat.mobile { 239 - return; 240 - } 241 - 242 - let window = match web_sys::window() { 243 - Some(w) => w, 244 - None => return, 245 - }; 246 - let dom_document = match window.document() { 247 - Some(d) => d, 248 - None => return, 249 - }; 250 - let editor = match dom_document.get_element_by_id(editor_id) { 251 - Some(e) => e, 252 - None => return, 253 - }; 254 - 255 - let mut document_signal = document; 256 - let cached_paras = cached_paragraphs; 257 - 258 - let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 259 - let input_type = evt.input_type(); 260 - tracing::debug!(input_type = %input_type, "beforeinput"); 261 - 262 - let plat = platform::platform(); 49 + // Storage 50 + #[allow(unused_imports)] 51 + pub use storage::{ 52 + DRAFT_KEY_PREFIX, EditorSnapshot, clear_all_drafts, delete_draft, list_drafts, 53 + load_from_storage, save_to_storage, 54 + }; 263 55 264 - // iOS workaround: Virtual keyboard sends insertParagraph/insertLineBreak 265 - // without proper keydown events. Handle them here. 266 - if plat.ios && (input_type == "insertParagraph" || input_type == "insertLineBreak") { 267 - tracing::debug!("iOS: intercepting {} via beforeinput", input_type); 268 - evt.prevent_default(); 269 - 270 - // Handle as Enter key 271 - document_signal.with_mut(|doc| { 272 - if let Some(sel) = doc.selection.take() { 273 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 274 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 275 - doc.cursor.offset = start; 276 - } 56 + // UI components 57 + pub use publish::PublishButton; 58 + pub use report::ReportButton; 59 + #[allow(unused_imports)] 60 + pub use toolbar::EditorToolbar; 277 61 278 - if input_type == "insertLineBreak" { 279 - // Soft break (like Shift+Enter) 280 - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 281 - doc.cursor.offset += 3; 282 - } else { 283 - // Paragraph break 284 - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 285 - doc.cursor.offset += 2; 286 - } 287 - }); 288 - } 62 + // Visibility 63 + #[allow(unused_imports)] 64 + pub use visibility::VisibilityState; 289 65 290 - // Android workaround: When swipe keyboard picks a suggestion, 291 - // DOM mutations fire before selection moves. We detect this pattern 292 - // and defer cursor sync. 293 - if plat.android && input_type == "insertText" { 294 - // Check if this might be a suggestion pick (has data that looks like a word) 295 - if let Some(data) = evt.data() { 296 - if data.contains(' ') || data.len() > 3 { 297 - tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 298 - // Defer cursor sync by 20ms to let selection settle 299 - let paras = cached_paras; 300 - let doc_sig = document_signal; 301 - let window = web_sys::window(); 302 - if let Some(window) = window { 303 - let closure = Closure::once(move || { 304 - let paras = paras(); 305 - sync_cursor_from_dom(&mut doc_sig.clone(), editor_id, &paras); 306 - }); 307 - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 308 - closure.as_ref().unchecked_ref(), 309 - 20, 310 - ); 311 - closure.forget(); 312 - } 313 - } 314 - } 315 - } 316 - }) as Box<dyn FnMut(web_sys::InputEvent)>); 317 - 318 - let _ = editor 319 - .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); 320 - closure.forget(); 321 - }); 322 - 323 - rsx! { 324 - Stylesheet { href: asset!("/assets/styling/editor.css") } 325 - div { class: "markdown-editor-container", 326 - div { class: "editor-content-wrapper", 327 - // Debug panel 328 - div { class: "editor-debug", 329 - "Cursor: {document().cursor.offset}, " 330 - "Chars: {document().len_chars()}" 331 - } 332 - div { 333 - id: "{editor_id}", 334 - class: "editor-content", 335 - contenteditable: "true", 336 - // DOM populated via web-sys in use_effect for incremental updates 337 - 338 - onkeydown: move |evt| { 339 - use dioxus::prelude::keyboard_types::Key; 340 - use std::time::Duration; 341 - 342 - let plat = platform::platform(); 343 - let mods = evt.modifiers(); 344 - let has_modifier = mods.ctrl() || mods.meta() || mods.alt(); 345 - 346 - // During IME composition: 347 - // - Allow modifier shortcuts (Ctrl+B, Ctrl+Z, etc.) 348 - // - Allow Escape to cancel composition 349 - // - Block text input (let browser handle composition preview) 350 - if document.peek().composition.is_some() { 351 - if evt.key() == Key::Escape { 352 - tracing::debug!("Escape pressed - cancelling composition"); 353 - document.with_mut(|doc| { 354 - doc.composition = None; 355 - }); 356 - return; 357 - } 358 - 359 - // Allow modifier shortcuts through during composition 360 - if !has_modifier { 361 - tracing::debug!( 362 - key = ?evt.key(), 363 - "keydown during composition - delegating to browser" 364 - ); 365 - return; 366 - } 367 - // Fall through to handle the shortcut 368 - } 369 - 370 - // Safari workaround: After Japanese IME composition ends, both 371 - // compositionend and keydown fire for Enter. Ignore keydown 372 - // within 500ms of composition end to prevent double-newline. 373 - if plat.safari && evt.key() == Key::Enter { 374 - if let Some(ended_at) = document.peek().composition_ended_at { 375 - if ended_at.elapsed() < Duration::from_millis(500) { 376 - tracing::debug!( 377 - "Safari: ignoring Enter within 500ms of compositionend" 378 - ); 379 - return; 380 - } 381 - } 382 - } 383 - 384 - // Android workaround: Chrome Android gets confused by Enter during/after 385 - // composition. Defer Enter handling to onkeypress instead. 386 - if plat.android && evt.key() == Key::Enter { 387 - tracing::debug!("Android: deferring Enter to keypress"); 388 - return; 389 - } 390 - 391 - // Only prevent default for operations that modify content 392 - // Let browser handle arrow keys, Home/End naturally 393 - if should_intercept_key(&evt) { 394 - evt.prevent_default(); 395 - handle_keydown(evt, &mut document); 396 - } 397 - }, 398 - 399 - onkeyup: move |evt| { 400 - use dioxus::prelude::keyboard_types::Key; 401 - 402 - // Navigation keys (with or without Shift for selection) 403 - let navigation = matches!( 404 - evt.key(), 405 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | 406 - Key::Home | Key::End | Key::PageUp | Key::PageDown 407 - ); 408 - 409 - // Cmd/Ctrl+A for select all 410 - let select_all = (evt.modifiers().meta() || evt.modifiers().ctrl()) 411 - && matches!(evt.key(), Key::Character(ref c) if c == "a"); 412 - 413 - if navigation || select_all { 414 - let paras = cached_paragraphs(); 415 - sync_cursor_from_dom(&mut document, editor_id, &paras); 416 - let doc = document(); 417 - let spans = syntax_spans(); 418 - update_syntax_visibility( 419 - doc.cursor.offset, 420 - doc.selection.as_ref(), 421 - &spans, 422 - &paras, 423 - ); 424 - } 425 - }, 426 - 427 - onselect: move |_evt| { 428 - tracing::debug!("onselect fired"); 429 - let paras = cached_paragraphs(); 430 - sync_cursor_from_dom(&mut document, editor_id, &paras); 431 - let doc = document(); 432 - let spans = syntax_spans(); 433 - update_syntax_visibility( 434 - doc.cursor.offset, 435 - doc.selection.as_ref(), 436 - &spans, 437 - &paras, 438 - ); 439 - }, 440 - 441 - onselectstart: move |_evt| { 442 - tracing::debug!("onselectstart fired"); 443 - let paras = cached_paragraphs(); 444 - sync_cursor_from_dom(&mut document, editor_id, &paras); 445 - let doc = document(); 446 - let spans = syntax_spans(); 447 - update_syntax_visibility( 448 - doc.cursor.offset, 449 - doc.selection.as_ref(), 450 - &spans, 451 - &paras, 452 - ); 453 - }, 454 - 455 - onselectionchange: move |_evt| { 456 - tracing::debug!("onselectionchange fired"); 457 - let paras = cached_paragraphs(); 458 - sync_cursor_from_dom(&mut document, editor_id, &paras); 459 - let doc = document(); 460 - let spans = syntax_spans(); 461 - update_syntax_visibility( 462 - doc.cursor.offset, 463 - doc.selection.as_ref(), 464 - &spans, 465 - &paras, 466 - ); 467 - }, 468 - 469 - onclick: move |_evt| { 470 - tracing::debug!("onclick fired"); 471 - let paras = cached_paragraphs(); 472 - sync_cursor_from_dom(&mut document, editor_id, &paras); 473 - let doc = document(); 474 - let spans = syntax_spans(); 475 - update_syntax_visibility( 476 - doc.cursor.offset, 477 - doc.selection.as_ref(), 478 - &spans, 479 - &paras, 480 - ); 481 - }, 482 - 483 - // Android workaround: Handle Enter in keypress instead of keydown. 484 - // Chrome Android fires confused composition events on Enter in keydown, 485 - // but keypress fires after composition state settles. 486 - onkeypress: move |evt| { 487 - use dioxus::prelude::keyboard_types::Key; 488 - 489 - let plat = platform::platform(); 490 - if plat.android && evt.key() == Key::Enter { 491 - tracing::debug!("Android: handling Enter in keypress"); 492 - evt.prevent_default(); 493 - handle_keydown(evt, &mut document); 494 - } 495 - }, 496 - 497 - onpaste: move |evt| { 498 - handle_paste(evt, &mut document); 499 - }, 500 - 501 - oncut: move |evt| { 502 - handle_cut(evt, &mut document); 503 - }, 504 - 505 - oncopy: move |evt| { 506 - handle_copy(evt, &document); 507 - }, 508 - 509 - onblur: move |_| { 510 - // Cancel any in-progress IME composition on focus loss 511 - let had_composition = document.peek().composition.is_some(); 512 - if had_composition { 513 - tracing::debug!("onblur: clearing active composition"); 514 - } 515 - document.with_mut(|doc| { 516 - doc.composition = None; 517 - }); 518 - }, 519 - 520 - oncompositionstart: move |evt: CompositionEvent| { 521 - let data = evt.data().data(); 522 - tracing::debug!( 523 - data = %data, 524 - "compositionstart" 525 - ); 526 - document.with_mut(|doc| { 527 - // Delete selection if present (composition replaces it) 528 - if let Some(sel) = doc.selection.take() { 529 - let (start, end) = 530 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 531 - tracing::debug!( 532 - start, 533 - end, 534 - "compositionstart: deleting selection" 535 - ); 536 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 537 - doc.cursor.offset = start; 538 - } 539 - 540 - tracing::debug!( 541 - cursor = doc.cursor.offset, 542 - "compositionstart: setting composition state" 543 - ); 544 - doc.composition = Some(CompositionState { 545 - start_offset: doc.cursor.offset, 546 - text: data, 547 - }); 548 - }); 549 - }, 550 - 551 - oncompositionupdate: move |evt: CompositionEvent| { 552 - let data = evt.data().data(); 553 - tracing::debug!( 554 - data = %data, 555 - "compositionupdate" 556 - ); 557 - document.with_mut(|doc| { 558 - if let Some(ref mut comp) = doc.composition { 559 - comp.text = data; 560 - } else { 561 - tracing::debug!("compositionupdate without active composition state"); 562 - } 563 - }); 564 - }, 565 - 566 - oncompositionend: move |evt: CompositionEvent| { 567 - let final_text = evt.data().data(); 568 - tracing::debug!( 569 - data = %final_text, 570 - "compositionend" 571 - ); 572 - document.with_mut(|doc| { 573 - // Record when composition ended for Safari timing workaround 574 - doc.composition_ended_at = Some(web_time::Instant::now()); 575 - 576 - if let Some(comp) = doc.composition.take() { 577 - tracing::debug!( 578 - start_offset = comp.start_offset, 579 - final_text = %final_text, 580 - chars = final_text.chars().count(), 581 - "compositionend: inserting text" 582 - ); 583 - 584 - if !final_text.is_empty() { 585 - let mut delete_start = comp.start_offset; 586 - while delete_start > 0 { 587 - match get_char_at(doc.loro_text(), delete_start - 1) { 588 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 589 - _ => break, 590 - } 591 - } 592 - 593 - let zw_count = doc.cursor.offset - delete_start; 594 - if zw_count > 0 { 595 - // Splice: delete zero-width chars and insert new char in one op 596 - let _ = doc.replace_tracked(delete_start, zw_count, &final_text); 597 - doc.cursor.offset = delete_start + final_text.chars().count(); 598 - } else if doc.cursor.offset == doc.len_chars() { 599 - // Fast path: append at end 600 - let _ = doc.push_tracked(&final_text); 601 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 602 - } else { 603 - let _ = doc.insert_tracked(doc.cursor.offset, &final_text); 604 - doc.cursor.offset = comp.start_offset + final_text.chars().count(); 605 - } 606 - } 607 - } else { 608 - tracing::debug!("compositionend without active composition state"); 609 - } 610 - }); 611 - }, 612 - } 613 - } 614 - 615 - EditorToolbar { 616 - on_format: move |action| { 617 - document.with_mut(|doc| { 618 - formatting::apply_formatting(doc, action); 619 - }); 620 - } 621 - } 622 - } 623 - 624 - } 625 - } 626 - 627 - /// Check if we need to intercept this key event 628 - /// Returns true for content-modifying operations, false for navigation 629 - fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 630 - use dioxus::prelude::keyboard_types::Key; 631 - 632 - let key = evt.key(); 633 - let mods = evt.modifiers(); 634 - 635 - // Handle Ctrl/Cmd shortcuts 636 - if mods.ctrl() || mods.meta() { 637 - if let Key::Character(ch) = &key { 638 - // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) 639 - match ch.as_str() { 640 - "b" | "i" | "z" | "y" => return true, 641 - "e" => return true, // Ctrl+E for HTML export/copy 642 - _ => {} 643 - } 644 - } 645 - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 646 - return false; 647 - } 648 - 649 - // Intercept content modifications 650 - matches!( 651 - key, 652 - Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab 653 - ) 654 - } 655 - 656 - /// Sync internal cursor and selection state from browser DOM selection 657 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 658 - fn sync_cursor_from_dom( 659 - document: &mut Signal<EditorDocument>, 660 - editor_id: &str, 661 - paragraphs: &[ParagraphRender], 662 - ) { 663 - use wasm_bindgen::JsCast; 664 - 665 - // Early return if paragraphs not yet populated (first render edge case) 666 - if paragraphs.is_empty() { 667 - return; 668 - } 669 - 670 - let window = match web_sys::window() { 671 - Some(w) => w, 672 - None => return, 673 - }; 674 - 675 - let dom_document = match window.document() { 676 - Some(d) => d, 677 - None => return, 678 - }; 679 - 680 - let editor_element = match dom_document.get_element_by_id(editor_id) { 681 - Some(e) => e, 682 - None => return, 683 - }; 684 - 685 - let selection = match window.get_selection() { 686 - Ok(Some(sel)) => sel, 687 - _ => return, 688 - }; 689 - 690 - // Get both anchor (selection start) and focus (selection end) positions 691 - let anchor_node = match selection.anchor_node() { 692 - Some(node) => node, 693 - None => return, 694 - }; 695 - let focus_node = match selection.focus_node() { 696 - Some(node) => node, 697 - None => return, 698 - }; 699 - let anchor_offset = selection.anchor_offset() as usize; 700 - let focus_offset = selection.focus_offset() as usize; 701 - 702 - // Convert both DOM positions to rope offsets using cached paragraphs 703 - let anchor_rope = dom_position_to_rope_offset( 704 - &dom_document, 705 - &editor_element, 706 - &anchor_node, 707 - anchor_offset, 708 - paragraphs, 709 - ); 710 - let focus_rope = dom_position_to_rope_offset( 711 - &dom_document, 712 - &editor_element, 713 - &focus_node, 714 - focus_offset, 715 - paragraphs, 716 - ); 717 - 718 - document.with_mut(|doc| { 719 - match (anchor_rope, focus_rope) { 720 - (Some(anchor), Some(focus)) => { 721 - doc.cursor.offset = focus; 722 - if anchor != focus { 723 - // There's an actual selection 724 - doc.selection = Some(Selection { 725 - anchor, 726 - head: focus, 727 - }); 728 - } else { 729 - // Collapsed selection (just cursor) 730 - doc.selection = None; 731 - } 732 - } 733 - _ => { 734 - tracing::warn!("Could not map DOM selection to rope offsets"); 735 - } 736 - } 737 - }); 738 - } 739 - 740 - /// Convert a DOM position (node + offset) to a rope char offset using offset maps 741 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 742 - fn dom_position_to_rope_offset( 743 - dom_document: &web_sys::Document, 744 - editor_element: &web_sys::Element, 745 - node: &web_sys::Node, 746 - offset_in_text_node: usize, 747 - paragraphs: &[ParagraphRender], 748 - ) -> Option<usize> { 749 - use wasm_bindgen::JsCast; 750 - 751 - // Find the containing element with a node ID (walk up from text node) 752 - let mut current_node = node.clone(); 753 - let node_id = loop { 754 - if let Some(element) = current_node.dyn_ref::<web_sys::Element>() { 755 - if element == editor_element { 756 - // Selection is on the editor container itself (e.g., Cmd+A select all) 757 - // Return boundary position based on offset: 758 - // offset 0 = start of editor, offset == child count = end of editor 759 - let child_count = editor_element.child_element_count() as usize; 760 - if offset_in_text_node == 0 { 761 - return Some(0); // Start of document 762 - } else if offset_in_text_node >= child_count { 763 - // End of document - find last paragraph's end 764 - return paragraphs.last().map(|p| p.char_range.end); 765 - } 766 - break None; 767 - } 768 - 769 - let id = element 770 - .get_attribute("id") 771 - .or_else(|| element.get_attribute("data-node-id")); 772 - 773 - if let Some(id) = id { 774 - if id.starts_with('n') && id[1..].parse::<usize>().is_ok() { 775 - break Some(id); 776 - } 777 - } 778 - } 779 - 780 - current_node = current_node.parent_node()?; 781 - }; 782 - 783 - let node_id = node_id?; 784 - 785 - // Get the container element 786 - let container = dom_document.get_element_by_id(&node_id).or_else(|| { 787 - let selector = format!("[data-node-id='{}']", node_id); 788 - dom_document.query_selector(&selector).ok().flatten() 789 - })?; 790 - 791 - // Calculate UTF-16 offset from start of container to the position 792 - let mut utf16_offset_in_container = 0; 793 - 794 - if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) { 795 - while let Ok(Some(text_node)) = walker.next_node() { 796 - if &text_node == node { 797 - utf16_offset_in_container += offset_in_text_node; 798 - break; 799 - } 800 - 801 - if let Some(text) = text_node.text_content() { 802 - utf16_offset_in_container += text.encode_utf16().count(); 803 - } 804 - } 805 - } 806 - 807 - // Look up in offset maps 808 - for para in paragraphs { 809 - for mapping in &para.offset_map { 810 - if mapping.node_id == node_id { 811 - let mapping_start = mapping.char_offset_in_node; 812 - let mapping_end = mapping.char_offset_in_node + mapping.utf16_len; 813 - 814 - if utf16_offset_in_container >= mapping_start 815 - && utf16_offset_in_container <= mapping_end 816 - { 817 - let offset_in_mapping = utf16_offset_in_container - mapping_start; 818 - return Some(mapping.char_range.start + offset_in_mapping); 819 - } 820 - } 821 - } 822 - } 823 - 824 - None 825 - } 826 - 827 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 828 - fn sync_cursor_from_dom( 829 - _document: &mut Signal<EditorDocument>, 830 - _editor_id: &str, 831 - _paragraphs: &[ParagraphRender], 832 - ) { 833 - // No-op on non-wasm 834 - } 835 - 836 - /// Update syntax span visibility in the DOM based on cursor position. 837 - /// 838 - /// Toggles the "hidden" class on syntax spans based on calculated visibility. 839 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 840 - fn update_syntax_visibility( 841 - cursor_offset: usize, 842 - selection: Option<&Selection>, 843 - syntax_spans: &[SyntaxSpanInfo], 844 - paragraphs: &[ParagraphRender], 845 - ) { 846 - let visibility = 847 - visibility::VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 848 - 849 - let Some(window) = web_sys::window() else { 850 - return; 851 - }; 852 - let Some(document) = window.document() else { 853 - return; 854 - }; 855 - 856 - // Update each syntax span's visibility 857 - for span in syntax_spans { 858 - let selector = format!("[data-syn-id='{}']", span.syn_id); 859 - if let Ok(Some(element)) = document.query_selector(&selector) { 860 - let class_list = element.class_list(); 861 - if visibility.is_visible(&span.syn_id) { 862 - let _ = class_list.remove_1("hidden"); 863 - } else { 864 - let _ = class_list.add_1("hidden"); 865 - } 866 - } 867 - } 868 - } 869 - 870 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 871 - fn update_syntax_visibility( 872 - _cursor_offset: usize, 873 - _selection: Option<&Selection>, 874 - _syntax_spans: &[SyntaxSpanInfo], 875 - _paragraphs: &[ParagraphRender], 876 - ) { 877 - // No-op on non-wasm 878 - } 879 - 880 - /// Handle paste events and insert text at cursor 881 - fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 882 - evt.prevent_default(); 883 - 884 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 885 - { 886 - use dioxus::web::WebEventExt; 887 - use wasm_bindgen::JsCast; 888 - 889 - let base_evt = evt.as_web_event(); 890 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 891 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 892 - // Try our custom type first (internal paste), fall back to text/plain 893 - let text = data_transfer 894 - .get_data("text/x-weaver-md") 895 - .ok() 896 - .filter(|s| !s.is_empty()) 897 - .or_else(|| data_transfer.get_data("text/plain").ok()); 898 - 899 - if let Some(text) = text { 900 - document.with_mut(|doc| { 901 - // Delete selection if present 902 - if let Some(sel) = doc.selection { 903 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 904 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 905 - doc.cursor.offset = start; 906 - doc.selection = None; 907 - } 908 - 909 - // Insert pasted text 910 - let _ = doc.insert_tracked(doc.cursor.offset, &text); 911 - doc.cursor.offset += text.chars().count(); 912 - }); 913 - } 914 - } 915 - } else { 916 - tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 917 - } 918 - } 919 - } 920 - 921 - /// Handle cut events - extract text, write to clipboard, then delete 922 - fn handle_cut(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) { 923 - evt.prevent_default(); 924 - 925 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 926 - { 927 - use dioxus::web::WebEventExt; 928 - use wasm_bindgen::JsCast; 929 - 930 - let base_evt = evt.as_web_event(); 931 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 932 - let cut_text = document.with_mut(|doc| { 933 - if let Some(sel) = doc.selection { 934 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 935 - if start != end { 936 - // Extract text and strip zero-width chars 937 - let selected_text = doc.slice(start, end).unwrap_or_default(); 938 - let clean_text = selected_text 939 - .replace('\u{200C}', "") 940 - .replace('\u{200B}', ""); 941 - 942 - // Write to clipboard BEFORE deleting (sync fallback) 943 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 944 - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 945 - tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 946 - } 947 - } 948 - 949 - // Now delete 950 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 951 - doc.cursor.offset = start; 952 - doc.selection = None; 953 - 954 - return Some(clean_text); 955 - } 956 - } 957 - None 958 - }); 959 - 960 - // Async: also write custom MIME type for internal paste detection 961 - if let Some(text) = cut_text { 962 - wasm_bindgen_futures::spawn_local(async move { 963 - if let Err(e) = write_clipboard_with_custom_type(&text).await { 964 - tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); 965 - } 966 - }); 967 - } 968 - } 969 - } 970 - 971 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 972 - { 973 - let _ = evt; // suppress unused warning 974 - } 975 - } 976 - 977 - /// Handle copy events - extract text, clean it up, write to clipboard 978 - fn handle_copy(evt: Event<ClipboardData>, document: &Signal<EditorDocument>) { 979 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 980 - { 981 - use dioxus::web::WebEventExt; 982 - use wasm_bindgen::JsCast; 983 - 984 - let base_evt = evt.as_web_event(); 985 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 986 - let doc = document.read(); 987 - if let Some(sel) = doc.selection { 988 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 989 - if start != end { 990 - // Extract text 991 - let selected_text = doc.slice(start, end).unwrap_or_default(); 992 - 993 - // Strip zero-width chars used for gap handling 994 - let clean_text = selected_text 995 - .replace('\u{200C}', "") 996 - .replace('\u{200B}', ""); 997 - 998 - // Sync fallback: write text/plain via DataTransfer 999 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 1000 - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 1001 - tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 1002 - } 1003 - } 1004 - 1005 - // Async: also write custom MIME type for internal paste detection 1006 - let text_for_async = clean_text.clone(); 1007 - wasm_bindgen_futures::spawn_local(async move { 1008 - if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { 1009 - tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); 1010 - } 1011 - }); 1012 - 1013 - // Prevent browser's default copy (which would copy rendered HTML) 1014 - evt.prevent_default(); 1015 - } 1016 - } 1017 - } 1018 - } 1019 - 1020 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1021 - { 1022 - let _ = (evt, document); // suppress unused warnings 1023 - } 1024 - } 1025 - 1026 - /// Copy markdown as rendered HTML to clipboard 1027 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1028 - async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { 1029 - use js_sys::Array; 1030 - use wasm_bindgen::JsValue; 1031 - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 1032 - 1033 - // Render markdown to HTML using ClientWriter 1034 - let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); 1035 - let mut html = String::new(); 1036 - weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( 1037 - parser.map(|(evt, _range)| evt), 1038 - &mut html, 1039 - ) 1040 - .run() 1041 - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 1042 - 1043 - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 1044 - let clipboard = window.navigator().clipboard(); 1045 - 1046 - // Create blobs for both HTML and plain text (raw HTML for inspection) 1047 - let parts = Array::new(); 1048 - parts.push(&JsValue::from_str(&html)); 1049 - 1050 - let mut html_opts = BlobPropertyBag::new(); 1051 - html_opts.type_("text/html"); 1052 - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 1053 - 1054 - let mut text_opts = BlobPropertyBag::new(); 1055 - text_opts.type_("text/plain"); 1056 - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 1057 - 1058 - // Create ClipboardItem with both types 1059 - let item_data = js_sys::Object::new(); 1060 - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 1061 - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 1062 - 1063 - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 1064 - let items = Array::new(); 1065 - items.push(&clipboard_item); 1066 - 1067 - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 1068 - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 1069 - Ok(()) 1070 - } 1071 - 1072 - /// Write text to clipboard with both text/plain and custom MIME type 1073 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1074 - async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { 1075 - use js_sys::{Array, Object, Reflect}; 1076 - use wasm_bindgen::JsValue; 1077 - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 1078 - 1079 - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 1080 - let navigator = window.navigator(); 1081 - let clipboard = navigator.clipboard(); 1082 - 1083 - // Create blobs for each MIME type 1084 - let text_parts = Array::new(); 1085 - text_parts.push(&JsValue::from_str(text)); 1086 - 1087 - let mut text_opts = BlobPropertyBag::new(); 1088 - text_opts.type_("text/plain"); 1089 - let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 1090 - 1091 - let mut custom_opts = BlobPropertyBag::new(); 1092 - custom_opts.type_("text/x-weaver-md"); 1093 - let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 1094 - 1095 - // Create ClipboardItem with both types 1096 - let item_data = Object::new(); 1097 - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 1098 - Reflect::set( 1099 - &item_data, 1100 - &JsValue::from_str("text/x-weaver-md"), 1101 - &custom_blob, 1102 - )?; 1103 - 1104 - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 1105 - let items = Array::new(); 1106 - items.push(&clipboard_item); 1107 - 1108 - let promise = clipboard.write(&items); 1109 - wasm_bindgen_futures::JsFuture::from(promise).await?; 1110 - 1111 - Ok(()) 1112 - } 1113 - 1114 - /// Extract a slice of text from a string by char indices 1115 - fn extract_text_slice(text: &str, start: usize, end: usize) -> String { 1116 - text.chars() 1117 - .skip(start) 1118 - .take(end.saturating_sub(start)) 1119 - .collect() 1120 - } 1121 - 1122 - /// Handle keyboard events and update document state 1123 - fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) { 1124 - use dioxus::prelude::keyboard_types::Key; 1125 - 1126 - let key = evt.key(); 1127 - let mods = evt.modifiers(); 1128 - 1129 - document.with_mut(|doc| { 1130 - match key { 1131 - Key::Character(ch) => { 1132 - // Keyboard shortcuts first 1133 - if mods.ctrl() { 1134 - match ch.as_str() { 1135 - "b" => { 1136 - formatting::apply_formatting(doc, FormatAction::Bold); 1137 - return; 1138 - } 1139 - "i" => { 1140 - formatting::apply_formatting(doc, FormatAction::Italic); 1141 - return; 1142 - } 1143 - "z" => { 1144 - if mods.shift() { 1145 - // Ctrl+Shift+Z = redo 1146 - if let Ok(true) = doc.redo() { 1147 - // Cursor position should be handled by the undo manager 1148 - // but we may need to clamp it 1149 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 1150 - } 1151 - } else { 1152 - // Ctrl+Z = undo 1153 - if let Ok(true) = doc.undo() { 1154 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 1155 - } 1156 - } 1157 - doc.selection = None; 1158 - return; 1159 - } 1160 - "y" => { 1161 - // Ctrl+Y = redo (alternative) 1162 - if let Ok(true) = doc.redo() { 1163 - doc.cursor.offset = doc.cursor.offset.min(doc.len_chars()); 1164 - } 1165 - doc.selection = None; 1166 - return; 1167 - } 1168 - "e" => { 1169 - // Ctrl+E = copy as HTML (export) 1170 - if let Some(sel) = doc.selection { 1171 - let (start, end) = 1172 - (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1173 - if start != end { 1174 - if let Some(markdown) = doc.slice(start, end) { 1175 - let clean_md = markdown 1176 - .replace('\u{200C}', "") 1177 - .replace('\u{200B}', ""); 1178 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1179 - wasm_bindgen_futures::spawn_local(async move { 1180 - if let Err(e) = copy_as_html(&clean_md).await { 1181 - tracing::warn!("[COPY HTML] Failed: {:?}", e); 1182 - } 1183 - }); 1184 - } 1185 - } 1186 - } 1187 - return; 1188 - } 1189 - _ => {} 1190 - } 1191 - } 1192 - 1193 - // Insert character at cursor (replacing selection if any) 1194 - if let Some(sel) = doc.selection.take() { 1195 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1196 - let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 1197 - doc.cursor.offset = start + ch.chars().count(); 1198 - } else { 1199 - // Clean up any preceding zero-width chars (gap scaffolding) 1200 - let mut delete_start = doc.cursor.offset; 1201 - while delete_start > 0 { 1202 - match get_char_at(doc.loro_text(), delete_start - 1) { 1203 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 1204 - _ => break, 1205 - } 1206 - } 1207 - 1208 - let zw_count = doc.cursor.offset - delete_start; 1209 - if zw_count > 0 { 1210 - // Splice: delete zero-width chars and insert new char in one op 1211 - let _ = doc.replace_tracked(delete_start, zw_count, &ch); 1212 - doc.cursor.offset = delete_start + ch.chars().count(); 1213 - } else if doc.cursor.offset == doc.len_chars() { 1214 - // Fast path: append at end 1215 - let _ = doc.push_tracked(&ch); 1216 - doc.cursor.offset += ch.chars().count(); 1217 - } else { 1218 - let _ = doc.insert_tracked(doc.cursor.offset, &ch); 1219 - doc.cursor.offset += ch.chars().count(); 1220 - } 1221 - } 1222 - } 1223 - 1224 - Key::Backspace => { 1225 - if let Some(sel) = doc.selection { 1226 - // Delete selection 1227 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1228 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1229 - doc.cursor.offset = start; 1230 - doc.selection = None; 1231 - } else if doc.cursor.offset > 0 { 1232 - // Check if we're about to delete a newline 1233 - let prev_char = get_char_at(doc.loro_text(), doc.cursor.offset - 1); 1234 - 1235 - if prev_char == Some('\n') { 1236 - let newline_pos = doc.cursor.offset - 1; 1237 - let mut delete_start = newline_pos; 1238 - let mut delete_end = doc.cursor.offset; 1239 - 1240 - // Check if there's another newline before this one (empty paragraph) 1241 - // If so, delete both newlines to merge paragraphs 1242 - if newline_pos > 0 { 1243 - let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 1244 - if prev_prev_char == Some('\n') { 1245 - // Empty paragraph case: delete both newlines 1246 - delete_start = newline_pos - 1; 1247 - } 1248 - } 1249 - 1250 - // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 1251 - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 1252 - if ch == '\u{200C}' || ch == '\u{200B}' { 1253 - delete_end += 1; 1254 - } 1255 - } 1256 - 1257 - // Scan backwards through whitespace before the newline(s) 1258 - while delete_start > 0 { 1259 - let ch = get_char_at(doc.loro_text(), delete_start - 1); 1260 - match ch { 1261 - Some('\u{200C}') | Some('\u{200B}') => { 1262 - delete_start -= 1; 1263 - } 1264 - Some('\n') => break, // stop at another newline 1265 - _ => break, // stop at actual content 1266 - } 1267 - } 1268 - 1269 - // Delete from where we stopped to end (including any trailing zero-width) 1270 - let _ = doc 1271 - .remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 1272 - doc.cursor.offset = delete_start; 1273 - } else { 1274 - // Normal backspace - delete one char 1275 - let prev = doc.cursor.offset - 1; 1276 - let _ = doc.remove_tracked(prev, 1); 1277 - doc.cursor.offset = prev; 1278 - } 1279 - } 1280 - } 1281 - 1282 - Key::Delete => { 1283 - if let Some(sel) = doc.selection.take() { 1284 - // Delete selection 1285 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1286 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1287 - doc.cursor.offset = start; 1288 - } else if doc.cursor.offset < doc.len_chars() { 1289 - // Delete next char 1290 - let _ = doc.remove_tracked(doc.cursor.offset, 1); 1291 - } 1292 - } 1293 - 1294 - // Arrow keys handled by browser, synced in onkeyup 1295 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 1296 - // Browser handles these naturally 1297 - } 1298 - 1299 - Key::Enter => { 1300 - if let Some(sel) = doc.selection.take() { 1301 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 1302 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 1303 - doc.cursor.offset = start; 1304 - } 1305 - 1306 - if mods.shift() { 1307 - // Shift+Enter: hard line break (soft break) 1308 - let _ = doc.insert_tracked(doc.cursor.offset, " \n\u{200C}"); 1309 - doc.cursor.offset += 3; 1310 - } else if let Some(ctx) = detect_list_context(doc.loro_text(), doc.cursor.offset) { 1311 - // We're in a list item 1312 - if is_list_item_empty(doc.loro_text(), doc.cursor.offset, &ctx) { 1313 - // Empty item - exit list by removing marker and inserting paragraph break 1314 - let line_start = find_line_start(doc.loro_text(), doc.cursor.offset); 1315 - let line_end = find_line_end(doc.loro_text(), doc.cursor.offset); 1316 - 1317 - // Delete the empty list item line INCLUDING its trailing newline 1318 - // line_end points to the newline, so +1 to include it 1319 - let delete_end = (line_end + 1).min(doc.len_chars()); 1320 - 1321 - // Use replace_tracked to atomically delete line and insert paragraph break 1322 - let _ = doc.replace_tracked( 1323 - line_start, 1324 - delete_end.saturating_sub(line_start), 1325 - "\n\n\u{200C}\n", 1326 - ); 1327 - doc.cursor.offset = line_start + 2; 1328 - } else { 1329 - // Non-empty item - continue list 1330 - let continuation = match ctx { 1331 - ListContext::Unordered { indent, marker } => { 1332 - format!("\n{}{} ", indent, marker) 1333 - } 1334 - ListContext::Ordered { indent, number } => { 1335 - format!("\n{}{}. ", indent, number + 1) 1336 - } 1337 - }; 1338 - let len = continuation.chars().count(); 1339 - let _ = doc.insert_tracked(doc.cursor.offset, &continuation); 1340 - doc.cursor.offset += len; 1341 - } 1342 - } else { 1343 - // Not in a list - normal paragraph break 1344 - let _ = doc.insert_tracked(doc.cursor.offset, "\n\n"); 1345 - doc.cursor.offset += 2; 1346 - } 1347 - } 1348 - 1349 - // Home/End handled by browser, synced in onkeyup 1350 - Key::Home | Key::End => { 1351 - // Browser handles these naturally 1352 - } 1353 - 1354 - _ => {} 1355 - } 1356 - 1357 - // Sync Loro cursor when edits affect paragraph boundaries 1358 - // This ensures cursor position is tracked correctly through structural changes 1359 - if doc.last_edit.as_ref().is_some_and(|e| e.contains_newline) { 1360 - doc.sync_loro_cursor(); 1361 - } 1362 - }); 1363 - } 1364 - 1365 - /// Describes what kind of list item the cursor is in, if any 1366 - #[derive(Debug, Clone)] 1367 - enum ListContext { 1368 - /// Unordered list with the given marker char ('-' or '*') and indentation 1369 - Unordered { indent: String, marker: char }, 1370 - /// Ordered list with the current number and indentation 1371 - Ordered { indent: String, number: usize }, 1372 - } 1373 - 1374 - /// Detect if cursor is in a list item and return context for continuation. 1375 - /// 1376 - /// Scans backwards to find start of current line, then checks for list marker. 1377 - fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> { 1378 - // Find start of current line 1379 - let line_start = find_line_start(text, cursor_offset); 1380 - 1381 - // Get the line content from start to cursor 1382 - let line_end = find_line_end(text, cursor_offset); 1383 - if line_start >= line_end { 1384 - return None; 1385 - } 1386 - 1387 - // Extract line text 1388 - let line = text.slice(line_start, line_end).ok()?; 1389 - 1390 - // Parse indentation 1391 - let indent: String = line 1392 - .chars() 1393 - .take_while(|c| *c == ' ' || *c == '\t') 1394 - .collect(); 1395 - let trimmed = &line[indent.len()..]; 1396 - 1397 - // Check for unordered list marker: "- " or "* " 1398 - if trimmed.starts_with("- ") { 1399 - return Some(ListContext::Unordered { 1400 - indent, 1401 - marker: '-', 1402 - }); 1403 - } 1404 - if trimmed.starts_with("* ") { 1405 - return Some(ListContext::Unordered { 1406 - indent, 1407 - marker: '*', 1408 - }); 1409 - } 1410 - 1411 - // Check for ordered list marker: "1. ", "2. ", "123. ", etc. 1412 - if let Some(dot_pos) = trimmed.find(". ") { 1413 - let num_part = &trimmed[..dot_pos]; 1414 - if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { 1415 - if let Ok(number) = num_part.parse::<usize>() { 1416 - return Some(ListContext::Ordered { indent, number }); 1417 - } 1418 - } 1419 - } 1420 - 1421 - None 1422 - } 1423 - 1424 - /// Check if the current list item is empty (just the marker, no content after cursor). 1425 - /// 1426 - /// Used to determine whether Enter should continue the list or exit it. 1427 - fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { 1428 - let line_start = find_line_start(text, cursor_offset); 1429 - let line_end = find_line_end(text, cursor_offset); 1430 - 1431 - // Get line content 1432 - let line = match text.slice(line_start, line_end) { 1433 - Ok(s) => s, 1434 - Err(_) => return false, 1435 - }; 1436 - 1437 - // Calculate expected marker length 1438 - let marker_len = match ctx { 1439 - ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " 1440 - ListContext::Ordered { indent, number } => { 1441 - indent.len() + number.to_string().len() + 2 // "1. " 1442 - } 1443 - }; 1444 - 1445 - // Item is empty if line length equals marker length (nothing after marker) 1446 - line.len() <= marker_len 1447 - } 1448 - 1449 - /// Get character at the given offset in LoroText 1450 - fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> { 1451 - text.char_at(offset).ok() 1452 - } 1453 - 1454 - /// Find start of line containing offset 1455 - fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 1456 - if offset == 0 { 1457 - return 0; 1458 - } 1459 - // Only slice the portion before cursor 1460 - let prefix = match text.slice(0, offset) { 1461 - Ok(s) => s, 1462 - Err(_) => return 0, 1463 - }; 1464 - prefix 1465 - .chars() 1466 - .enumerate() 1467 - .filter(|(_, c)| *c == '\n') 1468 - .last() 1469 - .map(|(pos, _)| pos + 1) 1470 - .unwrap_or(0) 1471 - } 1472 - 1473 - /// Find end of line containing offset 1474 - fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { 1475 - let char_len = text.len_unicode(); 1476 - if offset >= char_len { 1477 - return char_len; 1478 - } 1479 - // Only slice from cursor to end 1480 - let suffix = match text.slice(offset, char_len) { 1481 - Ok(s) => s, 1482 - Err(_) => return char_len, 1483 - }; 1484 - suffix 1485 - .chars() 1486 - .enumerate() 1487 - .find(|(_, c)| *c == '\n') 1488 - .map(|(i, _)| offset + i) 1489 - .unwrap_or(char_len) 1490 - } 1491 - 1492 - /// Update paragraph DOM elements incrementally. 1493 - /// 1494 - /// Only modifies paragraphs that changed (by comparing source_hash). 1495 - /// Browser preserves cursor naturally in unchanged paragraphs. 1496 - /// 1497 - /// Returns true if the paragraph containing the cursor was updated. 1498 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1499 - fn update_paragraph_dom( 1500 - editor_id: &str, 1501 - old_paragraphs: &[ParagraphRender], 1502 - new_paragraphs: &[ParagraphRender], 1503 - cursor_offset: usize, 1504 - ) -> bool { 1505 - use wasm_bindgen::JsCast; 1506 - 1507 - let window = match web_sys::window() { 1508 - Some(w) => w, 1509 - None => return false, 1510 - }; 1511 - 1512 - let document = match window.document() { 1513 - Some(d) => d, 1514 - None => return false, 1515 - }; 1516 - 1517 - let editor = match document.get_element_by_id(editor_id) { 1518 - Some(e) => e, 1519 - None => return false, 1520 - }; 1521 - 1522 - // Find which paragraph contains cursor 1523 - // Use end-inclusive matching: cursor at position N belongs to paragraph (0..N) 1524 - // This handles typing at end of paragraph, which is the common case 1525 - // The empty paragraph at document end catches any trailing cursor positions 1526 - let cursor_para_idx = new_paragraphs 1527 - .iter() 1528 - .position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end); 1529 - 1530 - let mut cursor_para_updated = false; 1531 - 1532 - // Update or create paragraphs 1533 - for (idx, new_para) in new_paragraphs.iter().enumerate() { 1534 - let para_id = format!("para-{}", idx); 1535 - 1536 - if let Some(old_para) = old_paragraphs.get(idx) { 1537 - // Paragraph exists - check if changed 1538 - if new_para.source_hash != old_para.source_hash { 1539 - // Changed - clear and update innerHTML 1540 - // We clear first to ensure any browser-added content (from IME composition, 1541 - // contenteditable quirks, etc.) is fully removed before setting new content 1542 - if let Some(elem) = document.get_element_by_id(&para_id) { 1543 - elem.set_text_content(None); // Clear completely 1544 - elem.set_inner_html(&new_para.html); 1545 - } 1546 - 1547 - // Track if we updated the cursor's paragraph 1548 - if Some(idx) == cursor_para_idx { 1549 - cursor_para_updated = true; 1550 - } 1551 - } 1552 - // Unchanged - do nothing, browser preserves cursor 1553 - } else { 1554 - // New paragraph - create div 1555 - if let Ok(div) = document.create_element("div") { 1556 - div.set_id(&para_id); 1557 - div.set_inner_html(&new_para.html); 1558 - let _ = editor.append_child(&div); 1559 - } 1560 - 1561 - // Track if we created the cursor's paragraph 1562 - if Some(idx) == cursor_para_idx { 1563 - cursor_para_updated = true; 1564 - } 1565 - } 1566 - } 1567 - 1568 - // Remove extra paragraphs if document got shorter 1569 - for idx in new_paragraphs.len()..old_paragraphs.len() { 1570 - let para_id = format!("para-{}", idx); 1571 - if let Some(elem) = document.get_element_by_id(&para_id) { 1572 - let _ = elem.remove(); 1573 - } 1574 - } 1575 - 1576 - cursor_para_updated 1577 - } 1578 - 1579 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1580 - fn update_paragraph_dom( 1581 - _editor_id: &str, 1582 - _old_paragraphs: &[ParagraphRender], 1583 - _new_paragraphs: &[ParagraphRender], 1584 - _cursor_offset: usize, 1585 - ) -> bool { 1586 - false 1587 - } 66 + // Logging 67 + #[allow(unused_imports)] 68 + pub use log_buffer::LogCaptureLayer;
-1
crates/weaver-app/src/components/editor/paragraph.rs
··· 44 44 pub fn text_slice_to_string(text: &LoroText, range: Range<usize>) -> String { 45 45 text.slice(range.start, range.end).unwrap_or_default() 46 46 } 47 -
+1 -4
crates/weaver-app/src/components/editor/platform.rs
··· 80 80 let chrome_version = extract_version(&user_agent, "chrome/"); 81 81 82 82 // Mobile detection 83 - let mobile = ios 84 - || android 85 - || user_agent.contains("mobile") 86 - || user_agent.contains("iemobile"); 83 + let mobile = ios || android || user_agent.contains("mobile") || user_agent.contains("iemobile"); 87 84 88 85 Platform { 89 86 ios,
+305
crates/weaver-app/src/components/editor/publish.rs
··· 1 + //! Entry publishing functionality for the markdown editor. 2 + //! 3 + //! Handles creating/updating AT Protocol notebook entries from editor state. 4 + 5 + use dioxus::prelude::*; 6 + use jacquard::types::string::{AtUri, Datetime}; 7 + use weaver_api::sh_weaver::embed::images::Images; 8 + use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds}; 9 + use weaver_common::{WeaverError, WeaverExt}; 10 + 11 + use crate::auth::AuthState; 12 + use crate::fetch::Fetcher; 13 + 14 + use super::document::EditorDocument; 15 + use super::storage::delete_draft; 16 + 17 + /// Result of a publish operation. 18 + #[derive(Clone, Debug)] 19 + pub enum PublishResult { 20 + /// Entry was created (new) 21 + Created(AtUri<'static>), 22 + /// Entry was updated (existing) 23 + Updated(AtUri<'static>), 24 + } 25 + 26 + impl PublishResult { 27 + pub fn uri(&self) -> &AtUri<'static> { 28 + match self { 29 + PublishResult::Created(uri) | PublishResult::Updated(uri) => uri, 30 + } 31 + } 32 + } 33 + 34 + /// Publish an entry to the AT Protocol. 35 + /// 36 + /// # Arguments 37 + /// * `fetcher` - The authenticated fetcher/client 38 + /// * `doc` - The editor document containing entry data 39 + /// * `notebook_title` - Title of the notebook to publish to 40 + /// * `draft_key` - Storage key for the draft (for cleanup) 41 + /// 42 + /// # Returns 43 + /// The AT-URI of the created/updated entry, or an error. 44 + pub async fn publish_entry( 45 + fetcher: &Fetcher, 46 + doc: &EditorDocument, 47 + notebook_title: &str, 48 + draft_key: &str, 49 + ) -> Result<PublishResult, WeaverError> { 50 + // Get images from the document 51 + let editor_images = doc.images(); 52 + 53 + // Build embeds if we have images 54 + let entry_embeds = if editor_images.is_empty() { 55 + None 56 + } else { 57 + // Extract Image types from EditorImage wrappers 58 + let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect(); 59 + 60 + Some(EntryEmbeds { 61 + images: Some(Images { 62 + images, 63 + extra_data: None, 64 + }), 65 + ..Default::default() 66 + }) 67 + }; 68 + 69 + // Build tags (convert Vec<String> to the expected type) 70 + let tags = { 71 + let tag_strings = doc.tags(); 72 + if tag_strings.is_empty() { 73 + None 74 + } else { 75 + Some(tag_strings.into_iter().map(Into::into).collect()) 76 + } 77 + }; 78 + 79 + // Determine path - use doc path if set, otherwise slugify title 80 + let path = { 81 + let doc_path = doc.path(); 82 + if doc_path.is_empty() { 83 + slugify(&doc.title()) 84 + } else { 85 + doc_path 86 + } 87 + }; 88 + 89 + // Build the entry 90 + let entry = Entry::new() 91 + .content(doc.content()) 92 + .title(doc.title()) 93 + .path(path) 94 + .created_at(Datetime::now()) 95 + .maybe_tags(tags) 96 + .maybe_embeds(entry_embeds) 97 + .build(); 98 + 99 + // Publish via upsert_entry 100 + let client = fetcher.get_client(); 101 + let (uri, was_created) = client 102 + .upsert_entry(notebook_title, &doc.title(), entry) 103 + .await?; 104 + 105 + // Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive) 106 + // TODO: Implement when image upload is added 107 + // for img in &editor_images { 108 + // if let Some(ref published_uri) = img.published_blob_uri { 109 + // let _ = delete_published_blob(fetcher, published_uri).await; 110 + // } 111 + // } 112 + 113 + // Clear local draft 114 + delete_draft(draft_key); 115 + 116 + if was_created { 117 + Ok(PublishResult::Created(uri)) 118 + } else { 119 + Ok(PublishResult::Updated(uri)) 120 + } 121 + } 122 + 123 + /// Simple slug generation from title. 124 + fn slugify(title: &str) -> String { 125 + title 126 + .to_lowercase() 127 + .chars() 128 + .map(|c| { 129 + if c.is_ascii_alphanumeric() { 130 + c 131 + } else if c.is_whitespace() || c == '-' || c == '_' { 132 + '-' 133 + } else { 134 + // Skip other characters 135 + '\0' 136 + } 137 + }) 138 + .filter(|&c| c != '\0') 139 + .collect::<String>() 140 + // Collapse multiple dashes 141 + .split('-') 142 + .filter(|s| !s.is_empty()) 143 + .collect::<Vec<_>>() 144 + .join("-") 145 + } 146 + 147 + /// Props for the publish button component. 148 + #[derive(Props, Clone, PartialEq)] 149 + pub struct PublishButtonProps { 150 + /// The editor document signal 151 + pub document: Signal<EditorDocument>, 152 + /// Storage key for the draft 153 + pub draft_key: String, 154 + } 155 + 156 + /// Publish button component with notebook selection. 157 + #[component] 158 + pub fn PublishButton(props: PublishButtonProps) -> Element { 159 + let fetcher = use_context::<Fetcher>(); 160 + let auth_state = use_context::<Signal<AuthState>>(); 161 + 162 + let mut show_dialog = use_signal(|| false); 163 + let mut notebook_title = use_signal(|| String::from("Default")); 164 + let mut is_publishing = use_signal(|| false); 165 + let mut error_message: Signal<Option<String>> = use_signal(|| None); 166 + let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None); 167 + 168 + let is_authenticated = auth_state.read().is_authenticated(); 169 + let doc = props.document; 170 + let draft_key = props.draft_key.clone(); 171 + 172 + // Validate that we have required fields 173 + let can_publish = { 174 + let d = doc(); 175 + !d.title().trim().is_empty() && !d.content().trim().is_empty() 176 + }; 177 + 178 + let open_dialog = move |_| { 179 + error_message.set(None); 180 + success_uri.set(None); 181 + show_dialog.set(true); 182 + }; 183 + 184 + let close_dialog = move |_| { 185 + show_dialog.set(false); 186 + }; 187 + 188 + let draft_key_clone = draft_key.clone(); 189 + let do_publish = move |_| { 190 + let fetcher = fetcher.clone(); 191 + let draft_key = draft_key_clone.clone(); 192 + let notebook = notebook_title(); 193 + 194 + spawn(async move { 195 + is_publishing.set(true); 196 + error_message.set(None); 197 + 198 + // Get document snapshot for publishing 199 + let doc_snapshot = doc(); 200 + 201 + match publish_entry(&fetcher, &doc_snapshot, &notebook, &draft_key).await { 202 + Ok(result) => { 203 + success_uri.set(Some(result.uri().clone())); 204 + } 205 + Err(e) => { 206 + error_message.set(Some(format!("{}", e))); 207 + } 208 + } 209 + 210 + is_publishing.set(false); 211 + }); 212 + }; 213 + 214 + rsx! { 215 + button { 216 + class: "publish-button", 217 + disabled: !is_authenticated || !can_publish, 218 + onclick: open_dialog, 219 + title: if !is_authenticated { 220 + "Log in to publish" 221 + } else if !can_publish { 222 + "Title and content required" 223 + } else { 224 + "Publish entry" 225 + }, 226 + "Publish" 227 + } 228 + 229 + if show_dialog() { 230 + div { 231 + class: "publish-dialog-overlay", 232 + onclick: close_dialog, 233 + 234 + div { 235 + class: "publish-dialog", 236 + onclick: move |e| e.stop_propagation(), 237 + 238 + h2 { "Publish Entry" } 239 + 240 + if let Some(uri) = success_uri() { 241 + div { class: "publish-success", 242 + p { "Entry published successfully!" } 243 + a { 244 + href: "{uri}", 245 + target: "_blank", 246 + "View entry →" 247 + } 248 + button { 249 + class: "publish-done", 250 + onclick: close_dialog, 251 + "Done" 252 + } 253 + } 254 + } else { 255 + div { class: "publish-form", 256 + div { class: "publish-field", 257 + label { "Notebook" } 258 + input { 259 + r#type: "text", 260 + class: "publish-input", 261 + placeholder: "Notebook title...", 262 + value: "{notebook_title}", 263 + oninput: move |e| notebook_title.set(e.value()), 264 + } 265 + } 266 + 267 + div { class: "publish-preview", 268 + p { "Title: {doc().title()}" } 269 + p { "Path: {doc().path()}" } 270 + if !doc().tags().is_empty() { 271 + p { "Tags: {doc().tags().join(\", \")}" } 272 + } 273 + } 274 + 275 + if let Some(err) = error_message() { 276 + div { class: "publish-error", 277 + "{err}" 278 + } 279 + } 280 + 281 + div { class: "publish-actions", 282 + button { 283 + class: "publish-cancel", 284 + onclick: close_dialog, 285 + disabled: is_publishing(), 286 + "Cancel" 287 + } 288 + button { 289 + class: "publish-submit", 290 + onclick: do_publish, 291 + disabled: is_publishing() || notebook_title().trim().is_empty(), 292 + if is_publishing() { 293 + "Publishing..." 294 + } else { 295 + "Publish" 296 + } 297 + } 298 + } 299 + } 300 + } 301 + } 302 + } 303 + } 304 + } 305 + }
+5 -1
crates/weaver-app/src/components/editor/render.rs
··· 181 181 182 182 // Compute delta from actual length difference, not edit info 183 183 // This handles stale edits gracefully (delta = 0 if lengths match) 184 - let cached_len = cache.paragraphs.last().map(|p| p.char_range.end).unwrap_or(0); 184 + let cached_len = cache 185 + .paragraphs 186 + .last() 187 + .map(|p| p.char_range.end) 188 + .unwrap_or(0); 185 189 let char_delta = current_len as isize - cached_len as isize; 186 190 187 191 // Adjust each cached paragraph's range
+2 -2
crates/weaver-app/src/components/editor/report.rs
··· 27 27 .map(|e| e.outer_html()) 28 28 .unwrap_or_default(); 29 29 30 - let editor_text = load_from_storage() 31 - .map(|snapshot| snapshot.to_string()) 30 + let editor_text = load_from_storage("current") 31 + .map(|doc| doc.content()) 32 32 .unwrap_or_default(); 33 33 34 34 let platform_info = {
+109 -16
crates/weaver-app/src/components/editor/storage.rs
··· 2 2 //! 3 3 //! Stores both human-readable content (for debugging) and the full CRDT 4 4 //! snapshot (for undo history preservation across sessions). 5 + //! 6 + //! Storage key strategy: 7 + //! - New entries: `"draft:new:{uuid}"` 8 + //! - Editing existing: `"draft:{at-uri}"` 5 9 6 10 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 7 11 use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; ··· 12 16 13 17 use super::document::EditorDocument; 14 18 19 + /// Prefix for all draft storage keys. 20 + pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; 21 + 15 22 /// Editor snapshot for persistence. 16 23 /// 17 24 /// Stores both human-readable content and CRDT snapshot for best of both worlds: 18 25 /// - `content`: Human-readable text for debugging 19 - /// - `snapshot`: Base64-encoded CRDT state for document history 26 + /// - `title`: Entry title for debugging/display in drafts list 27 + /// - `snapshot`: Base64-encoded CRDT state for document history (includes all embeds) 20 28 /// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position 21 29 /// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored 30 + /// - `editing_uri`: AT-URI if editing an existing entry 22 31 /// 23 32 /// Note: Undo/redo is session-only (UndoManager state is ephemeral). 24 33 /// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. ··· 26 35 pub struct EditorSnapshot { 27 36 /// Human-readable document content (for debugging/fallback) 28 37 pub content: String, 29 - /// Base64-encoded CRDT snapshot 38 + 39 + /// Entry title (for debugging/display in drafts list) 40 + #[serde(default)] 41 + pub title: String, 42 + 43 + /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) 44 + #[serde(default, skip_serializing_if = "Option::is_none")] 30 45 pub snapshot: Option<String>, 46 + 31 47 /// Loro Cursor for stable cursor position tracking 48 + #[serde(default, skip_serializing_if = "Option::is_none")] 32 49 pub cursor: Option<Cursor>, 50 + 33 51 /// Fallback cursor offset (used if Loro cursor can't be restored) 52 + #[serde(default)] 34 53 pub cursor_offset: usize, 54 + 55 + /// AT-URI if editing an existing entry (None for new entries) 56 + #[serde(default, skip_serializing_if = "Option::is_none")] 57 + pub editing_uri: Option<String>, 35 58 } 36 59 37 - #[cfg(all(target_family = "wasm", target_os = "unknown"))] 38 - const STORAGE_KEY: &str = "weaver_editor_draft"; 60 + /// Build the full storage key from a draft key. 61 + fn storage_key(key: &str) -> String { 62 + format!("{}{}", DRAFT_KEY_PREFIX, key) 63 + } 39 64 40 65 /// Save editor state to LocalStorage (WASM only). 66 + /// 67 + /// # Arguments 68 + /// * `doc` - The editor document to save 69 + /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 70 + /// * `editing_uri` - AT-URI if editing an existing entry 41 71 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 42 - pub fn save_to_storage(doc: &EditorDocument) -> Result<(), gloo_storage::errors::StorageError> { 72 + pub fn save_to_storage( 73 + doc: &EditorDocument, 74 + key: &str, 75 + editing_uri: Option<&str>, 76 + ) -> Result<(), gloo_storage::errors::StorageError> { 43 77 let snapshot_bytes = doc.export_snapshot(); 44 78 let snapshot_b64 = if snapshot_bytes.is_empty() { 45 79 None ··· 48 82 }; 49 83 50 84 let snapshot = EditorSnapshot { 51 - content: doc.to_string(), 85 + content: doc.content(), 86 + title: doc.title(), 52 87 snapshot: snapshot_b64, 53 88 cursor: doc.loro_cursor().cloned(), 54 89 cursor_offset: doc.cursor.offset, 90 + editing_uri: editing_uri.map(String::from), 55 91 }; 56 - LocalStorage::set(STORAGE_KEY, &snapshot) 92 + LocalStorage::set(storage_key(key), &snapshot) 57 93 } 58 94 59 95 /// Load editor state from LocalStorage (WASM only). 96 + /// 60 97 /// Returns an EditorDocument restored from CRDT snapshot if available, 61 98 /// otherwise falls back to just the text content. 99 + /// 100 + /// # Arguments 101 + /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 62 102 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 63 - pub fn load_from_storage() -> Option<EditorDocument> { 64 - let snapshot: EditorSnapshot = LocalStorage::get(STORAGE_KEY).ok()?; 103 + pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 104 + let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 65 105 66 106 // Try to restore from CRDT snapshot first 67 107 if let Some(ref snapshot_b64) = snapshot.snapshot { ··· 72 112 snapshot.cursor_offset, 73 113 ); 74 114 // Verify the content matches (sanity check) 75 - if doc.to_string() == snapshot.content { 115 + if doc.content() == snapshot.content { 76 116 return Some(doc); 77 117 } 78 118 tracing::warn!("Snapshot content mismatch, falling back to text content"); ··· 86 126 Some(doc) 87 127 } 88 128 89 - /// Clear editor state from LocalStorage (WASM only). 129 + /// Delete a draft from LocalStorage (WASM only). 130 + /// 131 + /// # Arguments 132 + /// * `key` - Storage key to delete 133 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 134 + pub fn delete_draft(key: &str) { 135 + LocalStorage::delete(storage_key(key)); 136 + } 137 + 138 + /// List all draft keys from LocalStorage (WASM only). 139 + /// 140 + /// Returns a list of (key, title, editing_uri) tuples for all saved drafts. 141 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 142 + pub fn list_drafts() -> Vec<(String, String, Option<String>)> { 143 + let mut drafts = Vec::new(); 144 + 145 + // gloo_storage doesn't have a direct way to iterate keys, 146 + // so we use web_sys directly 147 + if let Some(storage) = web_sys::window() 148 + .and_then(|w| w.local_storage().ok()) 149 + .flatten() 150 + { 151 + let len = storage.length().unwrap_or(0); 152 + for i in 0..len { 153 + if let Ok(Some(key)) = storage.key(i) { 154 + if key.starts_with(DRAFT_KEY_PREFIX) { 155 + // Try to load just the metadata 156 + if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) { 157 + let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); 158 + drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri)); 159 + } 160 + } 161 + } 162 + } 163 + } 164 + 165 + drafts 166 + } 167 + 168 + /// Clear all editor drafts from LocalStorage (WASM only). 90 169 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 91 170 #[allow(dead_code)] 92 - pub fn clear_storage() { 93 - LocalStorage::delete(STORAGE_KEY); 171 + pub fn clear_all_drafts() { 172 + for (key, _, _) in list_drafts() { 173 + delete_draft(&key); 174 + } 94 175 } 95 176 96 177 // Stub implementations for non-WASM targets 97 178 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 98 - pub fn save_to_storage(_doc: &EditorDocument) -> Result<(), String> { 179 + pub fn save_to_storage( 180 + _doc: &EditorDocument, 181 + _key: &str, 182 + _editing_uri: Option<&str>, 183 + ) -> Result<(), String> { 99 184 Ok(()) 100 185 } 101 186 102 187 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 103 - pub fn load_from_storage() -> Option<EditorDocument> { 188 + pub fn load_from_storage(_key: &str) -> Option<EditorDocument> { 104 189 None 105 190 } 106 191 107 192 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 193 + pub fn delete_draft(_key: &str) {} 194 + 195 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 196 + pub fn list_drafts() -> Vec<(String, String, Option<String>)> { 197 + Vec::new() 198 + } 199 + 200 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 108 201 #[allow(dead_code)] 109 - pub fn clear_storage() {} 202 + pub fn clear_all_drafts() {}
+48 -15
crates/weaver-app/src/components/editor/tests.rs
··· 418 418 // Test with extra whitespace (4 newlines = heading eats 1, leaves 3, gap = 3 > 2) 419 419 let result = render_test("# Title\n\n\n\nContent"); // 4 newlines 420 420 assert_eq!(result.len(), 3, "Expected 3 elements with extra whitespace"); 421 - assert!(result[1].html.contains("gap-"), "Middle element should be a gap"); 421 + assert!( 422 + result[1].html.contains("gap-"), 423 + "Middle element should be a gap" 424 + ); 422 425 423 426 // Test standard break (3 newlines = heading eats 1, leaves 2, gap = 2 = MIN, no gap element) 424 427 let result2 = render_test("# Title\n\n\nContent"); // 3 newlines 425 - assert_eq!(result2.len(), 2, "Expected 2 elements with standard break equivalent"); 428 + assert_eq!( 429 + result2.len(), 430 + 2, 431 + "Expected 2 elements with standard break equivalent" 432 + ); 426 433 } 427 434 428 435 // ============================================================================= ··· 630 637 fn test_heading_to_non_heading_transition() { 631 638 // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading) 632 639 // This tests that the syntax spans are correctly updated on content change. 633 - use loro::LoroDoc; 634 640 use super::render::render_paragraphs_incremental; 641 + use loro::LoroDoc; 635 642 636 643 let doc = LoroDoc::new(); 637 644 let text = doc.get_text("content"); ··· 680 687 let result = render_test(">"); 681 688 eprintln!("Paragraphs for '>': {:?}", result.len()); 682 689 for (i, p) in result.iter().enumerate() { 683 - eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range); 690 + eprintln!( 691 + " Para {}: html={}, char_range={:?}", 692 + i, p.html, p.char_range 693 + ); 684 694 } 685 695 686 696 // Empty blockquote should still produce at least one paragraph ··· 759 769 760 770 // With standard \n\n break, we expect 2 paragraphs (no gap element) 761 771 // Paragraph ranges include some trailing whitespace from markdown parsing 762 - assert_eq!(paragraphs.len(), 2, "Expected 2 paragraphs for standard break"); 772 + assert_eq!( 773 + paragraphs.len(), 774 + 2, 775 + "Expected 2 paragraphs for standard break" 776 + ); 763 777 764 778 // First paragraph ends before second starts, with gap for \n\n 765 779 let gap_start = paragraphs[0].char_range.end; 766 780 let gap_end = paragraphs[1].char_range.start; 767 781 let gap_size = gap_end - gap_start; 768 - assert!(gap_size <= 2, "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", gap_size); 782 + assert!( 783 + gap_size <= 2, 784 + "Gap should be at most MIN_PARAGRAPH_BREAK (2), got {}", 785 + gap_size 786 + ); 769 787 } 770 788 771 789 #[test] ··· 779 797 let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None); 780 798 781 799 // With extra newlines, we expect 3 elements: para, gap, para 782 - assert_eq!(paragraphs.len(), 3, "Expected 3 elements with extra whitespace"); 800 + assert_eq!( 801 + paragraphs.len(), 802 + 3, 803 + "Expected 3 elements with extra whitespace" 804 + ); 783 805 784 806 // Gap element should exist and cover whitespace zone 785 807 let gap = &paragraphs[1]; 786 808 assert!(gap.html.contains("gap-"), "Second element should be a gap"); 787 809 788 810 // Gap should cover ALL whitespace (not just extra) 789 - assert_eq!(gap.char_range.start, paragraphs[0].char_range.end, 790 - "Gap should start where first paragraph ends"); 791 - assert_eq!(gap.char_range.end, paragraphs[2].char_range.start, 792 - "Gap should end where second paragraph starts"); 811 + assert_eq!( 812 + gap.char_range.start, paragraphs[0].char_range.end, 813 + "Gap should start where first paragraph ends" 814 + ); 815 + assert_eq!( 816 + gap.char_range.end, paragraphs[2].char_range.start, 817 + "Gap should end where second paragraph starts" 818 + ); 793 819 } 794 820 795 821 #[test] ··· 999 1025 // UTF-16: 0 1 2 3 4 5 6,7 8 9 10 1000 1026 1001 1027 assert_eq!(char_to_utf16(&text, 0), 0); 1002 - assert_eq!(char_to_utf16(&text, 6), 6); // before emoji 1003 - assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) 1028 + assert_eq!(char_to_utf16(&text, 6), 6); // before emoji 1029 + assert_eq!(char_to_utf16(&text, 7), 8); // after emoji (emoji is 2 UTF-16 units) 1004 1030 assert_eq!(char_to_utf16(&text, 10), 11); // end 1005 1031 } 1006 1032 ··· 1026 1052 if text.len_unicode() == text.len_utf16() { 1027 1053 return char_pos; // fast path 1028 1054 } 1029 - text.slice(0, char_pos).map(|s| s.encode_utf16().count()).unwrap_or(0) 1055 + text.slice(0, char_pos) 1056 + .map(|s| s.encode_utf16().count()) 1057 + .unwrap_or(0) 1030 1058 } 1031 1059 1032 1060 // All positions should be identity for ASCII 1033 1061 for i in 0..=text.len_unicode() { 1034 - assert_eq!(char_to_utf16(&text, i), i, "ASCII fast path failed at pos {}", i); 1062 + assert_eq!( 1063 + char_to_utf16(&text, i), 1064 + i, 1065 + "ASCII fast path failed at pos {}", 1066 + i 1067 + ); 1035 1068 } 1036 1069 }
+116 -34
crates/weaver-app/src/components/editor/visibility.rs
··· 188 188 pos.saturating_add(amount).min(max_pos) 189 189 } 190 190 191 + /// Update syntax span visibility in the DOM based on cursor position. 192 + /// 193 + /// Toggles the "hidden" class on syntax spans based on calculated visibility. 194 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 195 + pub fn update_syntax_visibility( 196 + cursor_offset: usize, 197 + selection: Option<&Selection>, 198 + syntax_spans: &[SyntaxSpanInfo], 199 + paragraphs: &[ParagraphRender], 200 + ) { 201 + let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 202 + 203 + let Some(window) = web_sys::window() else { 204 + return; 205 + }; 206 + let Some(document) = window.document() else { 207 + return; 208 + }; 209 + 210 + // Update each syntax span's visibility 211 + for span in syntax_spans { 212 + let selector = format!("[data-syn-id='{}']", span.syn_id); 213 + if let Ok(Some(element)) = document.query_selector(&selector) { 214 + let class_list = element.class_list(); 215 + if visibility.is_visible(&span.syn_id) { 216 + let _ = class_list.remove_1("hidden"); 217 + } else { 218 + let _ = class_list.add_1("hidden"); 219 + } 220 + } 221 + } 222 + } 223 + 224 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 225 + pub fn update_syntax_visibility( 226 + _cursor_offset: usize, 227 + _selection: Option<&Selection>, 228 + _syntax_spans: &[SyntaxSpanInfo], 229 + _paragraphs: &[ParagraphRender], 230 + ) { 231 + // No-op on non-wasm 232 + } 233 + 191 234 #[cfg(test)] 192 235 mod tests { 193 236 use super::*; 194 237 195 - fn make_span(syn_id: &str, start: usize, end: usize, syntax_type: SyntaxType) -> SyntaxSpanInfo { 238 + fn make_span( 239 + syn_id: &str, 240 + start: usize, 241 + end: usize, 242 + syntax_type: SyntaxType, 243 + ) -> SyntaxSpanInfo { 196 244 SyntaxSpanInfo { 197 245 syn_id: syn_id.to_string(), 198 246 char_range: start..end, ··· 240 288 241 289 // Cursor at position 4 (middle of "bold", inside formatted region) 242 290 let vis = VisibilityState::calculate(4, None, &spans, &paras); 243 - assert!(vis.is_visible("s0"), "opening ** should be visible when cursor inside formatted region"); 244 - assert!(vis.is_visible("s1"), "closing ** should be visible when cursor inside formatted region"); 291 + assert!( 292 + vis.is_visible("s0"), 293 + "opening ** should be visible when cursor inside formatted region" 294 + ); 295 + assert!( 296 + vis.is_visible("s1"), 297 + "closing ** should be visible when cursor inside formatted region" 298 + ); 245 299 246 300 // Cursor at position 2 (adjacent to opening **, start of "bold") 247 301 let vis = VisibilityState::calculate(2, None, &spans, &paras); 248 - assert!(vis.is_visible("s0"), "opening ** should be visible when cursor adjacent at start of bold"); 302 + assert!( 303 + vis.is_visible("s0"), 304 + "opening ** should be visible when cursor adjacent at start of bold" 305 + ); 249 306 250 307 // Cursor at position 5 (adjacent to closing **, end of "bold") 251 308 let vis = VisibilityState::calculate(5, None, &spans, &paras); 252 - assert!(vis.is_visible("s1"), "closing ** should be visible when cursor adjacent at end of bold"); 309 + assert!( 310 + vis.is_visible("s1"), 311 + "closing ** should be visible when cursor adjacent at end of bold" 312 + ); 253 313 } 254 314 255 315 #[test] ··· 263 323 264 324 // Cursor at position 4 (middle of "bold", not adjacent to either marker) 265 325 let vis = VisibilityState::calculate(4, None, &spans, &paras); 266 - assert!(!vis.is_visible("s0"), "opening ** should be hidden when no formatted_range and cursor not adjacent"); 267 - assert!(!vis.is_visible("s1"), "closing ** should be hidden when no formatted_range and cursor not adjacent"); 326 + assert!( 327 + !vis.is_visible("s0"), 328 + "opening ** should be hidden when no formatted_range and cursor not adjacent" 329 + ); 330 + assert!( 331 + !vis.is_visible("s1"), 332 + "closing ** should be hidden when no formatted_range and cursor not adjacent" 333 + ); 268 334 } 269 335 270 336 #[test] ··· 278 344 279 345 // Cursor at position 4 (one before ** which starts at 5) 280 346 let vis = VisibilityState::calculate(4, None, &spans, &paras); 281 - assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent"); 347 + assert!( 348 + vis.is_visible("s0"), 349 + "** should be visible when cursor adjacent" 350 + ); 282 351 283 352 // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) 284 353 let vis = VisibilityState::calculate(7, None, &spans, &paras); 285 - assert!(vis.is_visible("s0"), "** should be visible when cursor adjacent after span"); 354 + assert!( 355 + vis.is_visible("s0"), 356 + "** should be visible when cursor adjacent after span" 357 + ); 286 358 } 287 359 288 360 #[test] 289 361 fn test_inline_visibility_cursor_far() { 290 - let spans = vec![ 291 - make_span("s0", 10, 12, SyntaxType::Inline), 292 - ]; 362 + let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)]; 293 363 let paras = vec![make_para(0, 33, spans.clone())]; 294 364 295 365 // Cursor at position 0 (far from **) 296 366 let vis = VisibilityState::calculate(0, None, &spans, &paras); 297 - assert!(!vis.is_visible("s0"), "** should be hidden when cursor far away"); 367 + assert!( 368 + !vis.is_visible("s0"), 369 + "** should be hidden when cursor far away" 370 + ); 298 371 } 299 372 300 373 #[test] ··· 310 383 311 384 // Cursor at position 5 (inside heading) 312 385 let vis = VisibilityState::calculate(5, None, &spans, &paras); 313 - assert!(vis.is_visible("s0"), "# should be visible when cursor in same paragraph"); 386 + assert!( 387 + vis.is_visible("s0"), 388 + "# should be visible when cursor in same paragraph" 389 + ); 314 390 } 315 391 316 392 #[test] 317 393 fn test_block_visibility_different_paragraph() { 318 - let spans = vec![ 319 - make_span("s0", 0, 2, SyntaxType::Block), 320 - ]; 321 - let paras = vec![ 322 - make_para(0, 10, spans.clone()), 323 - make_para(12, 30, vec![]), 324 - ]; 394 + let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)]; 395 + let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])]; 325 396 326 397 // Cursor at position 20 (in second paragraph) 327 398 let vis = VisibilityState::calculate(20, None, &spans, &paras); 328 - assert!(!vis.is_visible("s0"), "# should be hidden when cursor in different paragraph"); 399 + assert!( 400 + !vis.is_visible("s0"), 401 + "# should be hidden when cursor in different paragraph" 402 + ); 329 403 } 330 404 331 405 #[test] 332 406 fn test_selection_reveals_syntax() { 333 - let spans = vec![ 334 - make_span("s0", 5, 7, SyntaxType::Inline), 335 - ]; 407 + let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)]; 336 408 let paras = vec![make_para(0, 24, spans.clone())]; 337 409 338 410 // Selection overlaps the syntax span 339 - let selection = Selection { anchor: 3, head: 10 }; 411 + let selection = Selection { 412 + anchor: 3, 413 + head: 10, 414 + }; 340 415 let vis = VisibilityState::calculate(10, Some(&selection), &spans, &paras); 341 - assert!(vis.is_visible("s0"), "** should be visible when selection overlaps"); 416 + assert!( 417 + vis.is_visible("s0"), 418 + "** should be visible when selection overlaps" 419 + ); 342 420 } 343 421 344 422 #[test] ··· 351 429 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** 352 430 ]; 353 431 let paras = vec![ 354 - make_para(0, 8, spans.clone()), // "**bold**" 355 - make_para(9, 13, vec![]), // "text" (after newline) 432 + make_para(0, 8, spans.clone()), // "**bold**" 433 + make_para(9, 13, vec![]), // "text" (after newline) 356 434 ]; 357 435 358 436 // Cursor at position 9 (start of second paragraph) 359 437 // Should NOT reveal the closing ** because para bounds clamp extension 360 438 let vis = VisibilityState::calculate(9, None, &spans, &paras); 361 - assert!(!vis.is_visible("s1"), "closing ** should NOT be visible when cursor is in next paragraph"); 439 + assert!( 440 + !vis.is_visible("s1"), 441 + "closing ** should NOT be visible when cursor is in next paragraph" 442 + ); 362 443 } 363 444 364 445 #[test] 365 446 fn test_extension_clamps_to_paragraph() { 366 447 // Syntax at very start of paragraph - extension left should stop at para start 367 - let spans = vec![ 368 - make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), 369 - ]; 448 + let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)]; 370 449 let paras = vec![make_para(0, 8, spans.clone())]; 371 450 372 451 // Cursor at position 0 - should still see the opening ** 373 452 let vis = VisibilityState::calculate(0, None, &spans, &paras); 374 - assert!(vis.is_visible("s0"), "** at start should be visible when cursor at position 0"); 453 + assert!( 454 + vis.is_visible("s0"), 455 + "** at start should be visible when cursor at position 0" 456 + ); 375 457 } 376 458 }
+1 -1
crates/weaver-app/src/components/input/mod.rs
··· 1 1 mod component; 2 - pub use component::*; 2 + pub use component::*;
+1 -1
crates/weaver-app/src/components/mod.rs
··· 127 127 pub mod accordion; 128 128 pub mod button; 129 129 pub mod dialog; 130 - pub mod input; 131 130 pub mod editor; 131 + pub mod input;
+1 -1
crates/weaver-app/src/main.rs
··· 106 106 { 107 107 use tracing::Level; 108 108 use tracing::subscriber::set_global_default; 109 - use tracing_subscriber::layer::SubscriberExt; 110 109 use tracing_subscriber::Registry; 110 + use tracing_subscriber::layer::SubscriberExt; 111 111 112 112 let console_level = if cfg!(debug_assertions) { 113 113 Level::DEBUG
+1 -9
crates/weaver-app/src/views/editor.rs
··· 1 1 //! Editor view - wraps the MarkdownEditor component for the /editor route. 2 2 3 - use crate::components::editor::{MarkdownEditor, ReportButton}; 3 + use crate::components::editor::MarkdownEditor; 4 4 use dioxus::prelude::*; 5 5 6 6 /// Editor page view. ··· 12 12 rsx! { 13 13 EditorCss {} 14 14 div { class: "editor-page", 15 - div { class: "editor-header", 16 - h1 { "Markdown Editor Test" } 17 - ReportButton { 18 - email: "editor-bugs@weaver.sh".to_string(), 19 - editor_id: "markdown-editor".to_string(), 20 - } 21 - } 22 15 MarkdownEditor { initial_content: None } 23 16 } 24 17 } ··· 46 39 _ => rsx! {}, 47 40 } 48 41 } 49 -