all reasonably generic editor stuff extracted

Orual 8cc4614a 6f726641

+921 -1495
+3
Cargo.lock
··· 12207 12207 name = "weaver-editor-browser" 12208 12208 version = "0.1.0" 12209 12209 dependencies = [ 12210 + "dioxus-core 0.7.2", 12211 + "dioxus-html 0.7.2", 12210 12212 "dioxus-signals 0.7.2", 12213 + "dioxus-web 0.7.2", 12211 12214 "gloo-events", 12212 12215 "gloo-utils", 12213 12216 "js-sys",
+1 -1
crates/weaver-app/Cargo.toml
··· 49 49 dioxus = { version = "0.7.1", features = ["router"] } 50 50 weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } 51 51 weaver-editor-core = { path = "../weaver-editor-core" } 52 - weaver-editor-browser = { path = "../weaver-editor-browser" } 52 + weaver-editor-browser = { path = "../weaver-editor-browser", features = ["dioxus"] } 53 53 weaver-editor-crdt = { path = "../weaver-editor-crdt" } 54 54 jacquard = { workspace = true}#, features = ["streaming"] } 55 55 jacquard-lexicon = { workspace = true }
+28 -543
crates/weaver-app/src/components/editor/actions.rs
··· 1 1 //! Editor actions and keybinding system. 2 2 //! 3 3 //! This module re-exports core types and provides Dioxus-specific conversions. 4 - //! Action execution delegates to `weaver_editor_core::execute_action`. 4 + //! Action execution delegates entirely to `weaver_editor_core`. 5 5 6 - use dioxus::prelude::*; 6 + use dioxus::prelude::ModifiersInteraction; 7 7 8 8 use super::document::SignalEditorDocument; 9 9 use weaver_editor_browser::Platform; 10 - use weaver_editor_core::SnapDirection; 11 10 12 11 // Re-export core types. 13 12 pub use weaver_editor_core::{ 14 - EditorAction, FormatAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 15 - apply_formatting, 13 + EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 16 14 }; 17 - 18 - /// Determine the cursor snap direction hint for an action. 19 - fn snap_direction_for_action(action: &EditorAction) -> Option<SnapDirection> { 20 - match action { 21 - // Forward: cursor should snap toward new/remaining content. 22 - EditorAction::InsertLineBreak { .. } 23 - | EditorAction::InsertParagraph { .. } 24 - | EditorAction::DeleteForward { .. } 25 - | EditorAction::DeleteWordForward { .. } 26 - | EditorAction::DeleteToLineEnd { .. } 27 - | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), 28 - 29 - // Backward: cursor should snap toward content before edit. 30 - EditorAction::DeleteBackward { .. } 31 - | EditorAction::DeleteWordBackward { .. } 32 - | EditorAction::DeleteToLineStart { .. } 33 - | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), 34 - 35 - _ => None, 36 - } 37 - } 38 15 39 16 // === Dioxus conversion helpers === 40 17 ··· 152 129 KeybindingConfig::default_for_platform(platform.mac) 153 130 } 154 131 155 - /// Execute an editor action on a document. 156 - /// 157 - /// This is the central dispatch point for all editor operations. 158 - /// Returns true if the action was handled and the document was modified. 132 + /// Execute an editor action on a document with browser clipboard support. 133 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 159 134 pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { 160 - use super::input::{ 161 - detect_list_context, find_line_end, find_line_start, get_char_at, is_list_item_empty, 162 - }; 163 - use weaver_editor_core::SnapDirection; 164 - 165 - match action { 166 - EditorAction::Insert { text, range } => { 167 - let range = range.normalize(); 168 - if range.is_caret() { 169 - // Simple insert 170 - let offset = range.start; 171 - 172 - // Clean up any preceding zero-width chars 173 - let mut delete_start = offset; 174 - while delete_start > 0 { 175 - match get_char_at(doc.loro_text(), delete_start - 1) { 176 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 177 - _ => break, 178 - } 179 - } 180 - 181 - let zw_count = offset - delete_start; 182 - if zw_count > 0 { 183 - let _ = doc.replace_tracked(delete_start, zw_count, text); 184 - doc.cursor.write().offset = delete_start + text.chars().count(); 185 - } else if offset == doc.len_chars() { 186 - let _ = doc.push_tracked(text); 187 - doc.cursor.write().offset = offset + text.chars().count(); 188 - } else { 189 - let _ = doc.insert_tracked(offset, text); 190 - doc.cursor.write().offset = offset + text.chars().count(); 191 - } 192 - } else { 193 - // Replace range 194 - let _ = doc.replace_tracked(range.start, range.len(), text); 195 - doc.cursor.write().offset = range.start + text.chars().count(); 196 - } 197 - doc.selection.set(None); 198 - true 199 - } 200 - 201 - EditorAction::InsertLineBreak { range } => { 202 - let range = range.normalize(); 203 - doc.pending_snap.set(Some(SnapDirection::Forward)); 204 - 205 - let offset = range.start; 206 - if !range.is_caret() { 207 - let _ = doc.remove_tracked(offset, range.len()); 208 - } 209 - 210 - // Check if we're right after a soft break (newline + zero-width char). 211 - // If so, convert to paragraph break by replacing the zero-width char 212 - // with a newline. 213 - let is_double_enter = if offset >= 2 { 214 - let prev_char = get_char_at(doc.loro_text(), offset - 1); 215 - let prev_prev_char = get_char_at(doc.loro_text(), offset - 2); 216 - if prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') { 217 - true 218 - } else { 219 - false 220 - } 221 - } else { 222 - false 223 - }; 224 - 225 - if !is_double_enter { 226 - // Check for list context 227 - if let Some(ctx) = detect_list_context(doc.loro_text(), offset) { 228 - tracing::debug!("List context detected: {:?}", ctx); 229 - if is_list_item_empty(doc.loro_text(), offset, &ctx) { 230 - // Empty item - exit list 231 - let line_start = find_line_start(doc.loro_text(), offset); 232 - let line_end = find_line_end(doc.loro_text(), offset); 233 - let delete_end = (line_end + 1).min(doc.len_chars()); 234 - 235 - let _ = doc.replace_tracked( 236 - line_start, 237 - delete_end.saturating_sub(line_start), 238 - "\n\n\u{200C}\n", 239 - ); 240 - doc.cursor.write().offset = line_start + 2; 241 - tracing::debug!("empty list"); 242 - } else { 243 - // Continue list 244 - let continuation = match ctx { 245 - super::input::ListContext::Unordered { indent, marker } => { 246 - format!("\n{}{} ", indent, marker) 247 - } 248 - super::input::ListContext::Ordered { indent, number } => { 249 - format!("\n{}{}. ", indent, number + 1) 250 - } 251 - }; 252 - let len = continuation.chars().count(); 253 - let _ = doc.insert_tracked(offset, &continuation); 254 - doc.cursor.write().offset = offset + len; 255 - tracing::debug!("continuation {}", continuation); 256 - } 257 - } else { 258 - // Normal soft break: insert newline + zero-width char for cursor positioning. 259 - let _ = doc.insert_tracked(offset, "\n\u{200C}"); 260 - doc.cursor.write().offset = offset + 2; 261 - } 262 - } else { 263 - // Replace zero-width char with newline 264 - let _ = doc.replace_tracked(offset - 1, 1, "\n"); 265 - doc.cursor.write().offset = offset; 266 - } 267 - 268 - doc.selection.set(None); 269 - true 270 - } 271 - 272 - EditorAction::InsertParagraph { range } => { 273 - let range = range.normalize(); 274 - doc.pending_snap.set(Some(SnapDirection::Forward)); 275 - 276 - let cursor_offset = range.start; 277 - if !range.is_caret() { 278 - let _ = doc.remove_tracked(cursor_offset, range.len()); 279 - } 280 - 281 - // Check for list context 282 - if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 283 - if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { 284 - // Empty item - exit list 285 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 286 - let line_end = find_line_end(doc.loro_text(), cursor_offset); 287 - let delete_end = (line_end + 1).min(doc.len_chars()); 288 - 289 - let _ = doc.replace_tracked( 290 - line_start, 291 - delete_end.saturating_sub(line_start), 292 - "\n\n\u{200C}\n", 293 - ); 294 - doc.cursor.write().offset = line_start + 2; 295 - } else { 296 - // Continue list 297 - let continuation = match ctx { 298 - super::input::ListContext::Unordered { indent, marker } => { 299 - format!("\n{}{} ", indent, marker) 300 - } 301 - super::input::ListContext::Ordered { indent, number } => { 302 - format!("\n{}{}. ", indent, number + 1) 303 - } 304 - }; 305 - let len = continuation.chars().count(); 306 - let _ = doc.insert_tracked(cursor_offset, &continuation); 307 - doc.cursor.write().offset = cursor_offset + len; 308 - } 309 - } else { 310 - // Normal paragraph break 311 - let _ = doc.insert_tracked(cursor_offset, "\n\n"); 312 - doc.cursor.write().offset = cursor_offset + 2; 313 - } 314 - 315 - doc.selection.set(None); 316 - true 317 - } 318 - 319 - EditorAction::DeleteBackward { range } => { 320 - let range = range.normalize(); 321 - doc.pending_snap.set(Some(SnapDirection::Backward)); 322 - 323 - if !range.is_caret() { 324 - // Delete selection 325 - let _ = doc.remove_tracked(range.start, range.len()); 326 - doc.cursor.write().offset = range.start; 327 - } else if range.start > 0 { 328 - let cursor_offset = range.start; 329 - let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); 330 - 331 - if prev_char == Some('\n') { 332 - // Deleting a newline - handle paragraph merging 333 - let newline_pos = cursor_offset - 1; 334 - let mut delete_start = newline_pos; 335 - let mut delete_end = cursor_offset; 135 + use weaver_editor_browser::BrowserClipboard; 136 + use weaver_editor_core::execute_action_with_clipboard; 336 137 337 - // Check for empty paragraph (double newline) 338 - if newline_pos > 0 { 339 - if get_char_at(doc.loro_text(), newline_pos - 1) == Some('\n') { 340 - delete_start = newline_pos - 1; 341 - } 342 - } 343 - 344 - // Check for trailing zero-width char 345 - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 346 - if ch == '\u{200C}' || ch == '\u{200B}' { 347 - delete_end += 1; 348 - } 349 - } 350 - 351 - // Scan backwards through zero-width chars 352 - while delete_start > 0 { 353 - match get_char_at(doc.loro_text(), delete_start - 1) { 354 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 355 - Some('\n') | _ => break, 356 - } 357 - } 358 - 359 - let _ = 360 - doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 361 - doc.cursor.write().offset = delete_start; 362 - } else { 363 - // Normal single char delete 364 - let _ = doc.remove_tracked(cursor_offset - 1, 1); 365 - doc.cursor.write().offset = cursor_offset - 1; 366 - } 367 - } 368 - 369 - doc.selection.set(None); 370 - true 371 - } 372 - 373 - EditorAction::DeleteForward { range } => { 374 - let range = range.normalize(); 375 - doc.pending_snap.set(Some(SnapDirection::Forward)); 376 - 377 - if !range.is_caret() { 378 - let _ = doc.remove_tracked(range.start, range.len()); 379 - doc.cursor.write().offset = range.start; 380 - } else if range.start < doc.len_chars() { 381 - let _ = doc.remove_tracked(range.start, 1); 382 - // Cursor stays at same position 383 - } 384 - 385 - doc.selection.set(None); 386 - true 387 - } 388 - 389 - EditorAction::DeleteWordBackward { range } => { 390 - let range = range.normalize(); 391 - doc.pending_snap.set(Some(SnapDirection::Backward)); 392 - 393 - if !range.is_caret() { 394 - let _ = doc.remove_tracked(range.start, range.len()); 395 - doc.cursor.write().offset = range.start; 396 - } else { 397 - // Find word boundary backwards 398 - let cursor = range.start; 399 - let word_start = find_word_boundary_backward(doc, cursor); 400 - if word_start < cursor { 401 - let _ = doc.remove_tracked(word_start, cursor - word_start); 402 - doc.cursor.write().offset = word_start; 403 - } 404 - } 405 - 406 - doc.selection.set(None); 407 - true 408 - } 409 - 410 - EditorAction::DeleteWordForward { range } => { 411 - let range = range.normalize(); 412 - doc.pending_snap.set(Some(SnapDirection::Forward)); 413 - 414 - if !range.is_caret() { 415 - let _ = doc.remove_tracked(range.start, range.len()); 416 - doc.cursor.write().offset = range.start; 417 - } else { 418 - // Find word boundary forward 419 - let cursor = range.start; 420 - let word_end = find_word_boundary_forward(doc, cursor); 421 - if word_end > cursor { 422 - let _ = doc.remove_tracked(cursor, word_end - cursor); 423 - } 424 - } 425 - 426 - doc.selection.set(None); 427 - true 428 - } 429 - 430 - EditorAction::DeleteToLineStart { range } => { 431 - let range = range.normalize(); 432 - doc.pending_snap.set(Some(SnapDirection::Backward)); 433 - 434 - let cursor = if range.is_caret() { 435 - range.start 436 - } else { 437 - range.start 438 - }; 439 - let line_start = find_line_start(doc.loro_text(), cursor); 440 - 441 - if line_start < cursor { 442 - let _ = doc.remove_tracked(line_start, cursor - line_start); 443 - doc.cursor.write().offset = line_start; 444 - } 445 - 446 - doc.selection.set(None); 447 - true 448 - } 449 - 450 - EditorAction::DeleteToLineEnd { range } => { 451 - let range = range.normalize(); 452 - doc.pending_snap.set(Some(SnapDirection::Forward)); 453 - 454 - let cursor = if range.is_caret() { 455 - range.start 456 - } else { 457 - range.end 458 - }; 459 - let line_end = find_line_end(doc.loro_text(), cursor); 460 - 461 - if cursor < line_end { 462 - let _ = doc.remove_tracked(cursor, line_end - cursor); 463 - } 464 - 465 - doc.selection.set(None); 466 - true 467 - } 468 - 469 - EditorAction::DeleteSoftLineBackward { range } => { 470 - // For now, treat same as DeleteToLineStart 471 - // TODO: Handle visual line wrapping if needed 472 - execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) 473 - } 474 - 475 - EditorAction::DeleteSoftLineForward { range } => { 476 - // For now, treat same as DeleteToLineEnd 477 - execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) 478 - } 479 - 480 - EditorAction::Undo => { 481 - if let Ok(true) = doc.undo() { 482 - let max = doc.len_chars(); 483 - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 484 - doc.selection.set(None); 485 - true 486 - } else { 487 - false 488 - } 489 - } 490 - 491 - EditorAction::Redo => { 492 - if let Ok(true) = doc.redo() { 493 - let max = doc.len_chars(); 494 - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 495 - doc.selection.set(None); 496 - true 497 - } else { 498 - false 499 - } 500 - } 501 - 502 - EditorAction::ToggleBold => { 503 - apply_formatting(doc, FormatAction::Bold); 504 - true 505 - } 506 - 507 - EditorAction::ToggleItalic => { 508 - apply_formatting(doc, FormatAction::Italic); 509 - true 510 - } 511 - 512 - EditorAction::ToggleCode => { 513 - apply_formatting(doc, FormatAction::Code); 514 - true 515 - } 516 - 517 - EditorAction::ToggleStrikethrough => { 518 - apply_formatting(doc, FormatAction::Strikethrough); 519 - true 520 - } 521 - 522 - EditorAction::InsertLink => { 523 - apply_formatting(doc, FormatAction::Link); 524 - true 525 - } 526 - 527 - EditorAction::Cut => { 528 - // Handled separately via clipboard events 529 - false 530 - } 531 - 532 - EditorAction::Copy => { 533 - // Handled separately via clipboard events 534 - false 535 - } 536 - 537 - EditorAction::Paste { range: _ } => { 538 - // Handled separately via clipboard events (needs async clipboard access) 539 - false 540 - } 541 - 542 - EditorAction::CopyAsHtml => { 543 - // Handled in component with async clipboard access 544 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 545 - { 546 - if let Some(sel) = *doc.selection.read() { 547 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 548 - if start != end { 549 - if let Some(markdown) = doc.slice(start, end) { 550 - let clean_md = markdown.replace('\u{200C}', "").replace('\u{200B}', ""); 551 - wasm_bindgen_futures::spawn_local(async move { 552 - if let Err(e) = super::input::copy_as_html(&clean_md).await { 553 - tracing::warn!("[COPY HTML] Failed: {:?}", e); 554 - } 555 - }); 556 - return true; 557 - } 558 - } 559 - } 560 - } 561 - false 562 - } 563 - 564 - EditorAction::SelectAll => { 565 - let len = doc.len_chars(); 566 - doc.selection.set(Some(super::document::Selection { 567 - anchor: 0, 568 - head: len, 569 - })); 570 - doc.cursor.write().offset = len; 571 - true 572 - } 573 - 574 - EditorAction::MoveCursor { offset } => { 575 - let offset = (*offset).min(doc.len_chars()); 576 - doc.cursor.write().offset = offset; 577 - doc.selection.set(None); 578 - true 579 - } 580 - 581 - EditorAction::ExtendSelection { offset } => { 582 - let offset = (*offset).min(doc.len_chars()); 583 - let current_sel = *doc.selection.read(); 584 - let anchor = current_sel 585 - .map(|s| s.anchor) 586 - .unwrap_or(doc.cursor.read().offset); 587 - doc.selection.set(Some(super::document::Selection { 588 - anchor, 589 - head: offset, 590 - })); 591 - doc.cursor.write().offset = offset; 592 - true 593 - } 594 - } 138 + let clipboard = BrowserClipboard::empty(); 139 + execute_action_with_clipboard(doc, action, &clipboard) 595 140 } 596 141 597 - /// Find word boundary backward from cursor. 598 - fn find_word_boundary_backward(doc: &SignalEditorDocument, cursor: usize) -> usize { 599 - use super::input::get_char_at; 600 - 601 - if cursor == 0 { 602 - return 0; 603 - } 604 - 605 - let mut pos = cursor; 606 - 607 - // Skip any whitespace/punctuation immediately before cursor 608 - while pos > 0 { 609 - match get_char_at(doc.loro_text(), pos - 1) { 610 - Some(c) if c.is_alphanumeric() || c == '_' => break, 611 - Some(_) => pos -= 1, 612 - None => break, 613 - } 614 - } 615 - 616 - // Skip the word characters 617 - while pos > 0 { 618 - match get_char_at(doc.loro_text(), pos - 1) { 619 - Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1, 620 - _ => break, 621 - } 622 - } 623 - 624 - pos 142 + /// Execute an editor action on a document (non-browser fallback). 143 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 144 + pub fn execute_action(doc: &mut SignalEditorDocument, action: &EditorAction) -> bool { 145 + weaver_editor_core::execute_action(doc, action) 625 146 } 626 147 627 - /// Find word boundary forward from cursor. 628 - fn find_word_boundary_forward(doc: &SignalEditorDocument, cursor: usize) -> usize { 629 - use super::input::get_char_at; 630 - 631 - let len = doc.len_chars(); 632 - if cursor >= len { 633 - return len; 634 - } 635 - 636 - let mut pos = cursor; 637 - 638 - // Skip word characters first 639 - while pos < len { 640 - match get_char_at(doc.loro_text(), pos) { 641 - Some(c) if c.is_alphanumeric() || c == '_' => pos += 1, 642 - _ => break, 643 - } 644 - } 148 + /// Handle a keydown event with browser clipboard support. 149 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 150 + pub fn handle_keydown_with_bindings( 151 + doc: &mut SignalEditorDocument, 152 + config: &KeybindingConfig, 153 + combo: KeyCombo, 154 + range: Range, 155 + ) -> KeydownResult { 156 + use weaver_editor_browser::BrowserClipboard; 157 + use weaver_editor_core::handle_keydown_with_clipboard; 645 158 646 - // Then skip whitespace/punctuation 647 - while pos < len { 648 - match get_char_at(doc.loro_text(), pos) { 649 - Some(c) if c.is_alphanumeric() || c == '_' => break, 650 - Some(_) => pos += 1, 651 - None => break, 652 - } 653 - } 654 - 655 - pos 159 + let clipboard = BrowserClipboard::empty(); 160 + handle_keydown_with_clipboard(doc, config, combo, range, &clipboard) 656 161 } 657 162 658 - /// Handle a keydown event using the keybinding configuration. 659 - /// 660 - /// This handles keyboard shortcuts only. Text input and deletion 661 - /// are handled by beforeinput. Navigation (arrows, etc.) is passed 662 - /// through to the browser. 163 + /// Handle a keydown event (non-browser fallback). 164 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 663 165 pub fn handle_keydown_with_bindings( 664 166 doc: &mut SignalEditorDocument, 665 167 config: &KeybindingConfig, 666 168 combo: KeyCombo, 667 169 range: Range, 668 170 ) -> KeydownResult { 669 - // Look up keybinding (range is applied by lookup) 670 - if let Some(action) = config.lookup(&combo, range) { 671 - execute_action(doc, &action); 672 - return KeydownResult::Handled; 673 - } 674 - 675 - // No keybinding matched - check if this is navigation or content 676 - if combo.key.is_navigation() { 677 - return KeydownResult::PassThrough; 678 - } 679 - 680 - // Modifier-only keypresses should pass through 681 - if combo.key.is_modifier() { 682 - return KeydownResult::PassThrough; 683 - } 684 - 685 - // Content keys (typing, backspace, etc.) - let beforeinput handle 686 - KeydownResult::NotHandled 171 + weaver_editor_core::handle_keydown(doc, config, combo, range) 687 172 }
-70
crates/weaver-app/src/components/editor/beforeinput.rs
··· 1 - //! BeforeInput event handling for the editor. 2 - //! 3 - //! This module provides the primary input handling via the `beforeinput` event, 4 - //! which gives us semantic information about what the browser wants to do 5 - //! (insert text, delete backward, etc.) rather than raw key codes. 6 - //! 7 - //! The core logic is in `weaver_editor_browser::handle_beforeinput`. This module 8 - //! adds app-specific concerns like `pending_snap` for cursor snapping direction. 9 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 10 - use super::document::SignalEditorDocument; 11 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 12 - use dioxus::prelude::*; 13 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 - use weaver_editor_core::SnapDirection; 15 - 16 - // Re-export types from extracted crates. 17 - pub use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; 18 - pub use weaver_editor_core::InputType; 19 - 20 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 21 - pub use weaver_editor_browser::StaticRange; 22 - 23 - /// Determine the cursor snap direction hint for an input type. 24 - /// 25 - /// This is used to hint `dom_sync` which direction to snap the cursor if it 26 - /// lands on invisible content after an edit. 27 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 28 - fn snap_direction_for_input_type(input_type: &InputType) -> Option<SnapDirection> { 29 - match input_type { 30 - // Forward: cursor should snap toward new/remaining content after the edit. 31 - InputType::InsertLineBreak 32 - | InputType::InsertParagraph 33 - | InputType::DeleteContentForward 34 - | InputType::DeleteWordForward 35 - | InputType::DeleteEntireWordForward 36 - | InputType::DeleteSoftLineForward 37 - | InputType::DeleteHardLineForward => Some(SnapDirection::Forward), 38 - 39 - // Backward: cursor should snap toward content before the deleted range. 40 - InputType::DeleteContentBackward 41 - | InputType::DeleteWordBackward 42 - | InputType::DeleteEntireWordBackward 43 - | InputType::DeleteSoftLineBackward 44 - | InputType::DeleteHardLineBackward => Some(SnapDirection::Backward), 45 - 46 - // No snap hint for other operations. 47 - _ => None, 48 - } 49 - } 50 - 51 - /// Handle a beforeinput event. 52 - /// 53 - /// This is the main entry point for beforeinput-based input handling. 54 - /// Sets `pending_snap` for cursor snapping, then delegates to the browser crate. 55 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 56 - pub fn handle_beforeinput( 57 - doc: &mut SignalEditorDocument, 58 - ctx: BeforeInputContext<'_>, 59 - ) -> BeforeInputResult { 60 - // Set pending_snap hint before executing the action. 61 - if let Some(snap) = snap_direction_for_input_type(&ctx.input_type) { 62 - doc.pending_snap.set(Some(snap)); 63 - } 64 - 65 - // Get current range for the browser handler. 66 - let current_range = weaver_editor_browser::get_current_range(doc); 67 - 68 - // Delegate to browser crate's generic handler. 69 - weaver_editor_browser::handle_beforeinput(doc, &ctx, current_range) 70 - }
+70 -215
crates/weaver-app/src/components/editor/component.rs
··· 5 5 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 6 6 handle_keydown_with_bindings, 7 7 }; 8 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 9 - use super::beforeinput::handle_beforeinput; 10 - #[allow(unused_imports)] 11 - use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType}; 12 8 use super::document::{CompositionState, LoadedDocState, SignalEditorDocument}; 13 9 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 10 use super::dom_sync::update_paragraph_dom; 15 11 use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 16 - use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 17 12 #[allow(unused_imports)] 18 13 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 14 + use super::remote_cursors::RemoteCursors; 19 15 use super::storage; 20 16 use super::sync::{SyncStatus, load_and_merge_document}; 21 17 use super::toolbar::EditorToolbar; ··· 35 31 use jacquard::types::ident::AtIdentifier; 36 32 use weaver_api::sh_weaver::embed::images::Image; 37 33 use weaver_common::WeaverExt; 34 + #[allow(unused_imports)] 35 + use weaver_editor_browser::{BeforeInputContext, BeforeInputResult}; 36 + use weaver_editor_browser::{handle_copy, handle_cut, handle_paste}; 38 37 use weaver_editor_browser::{platform, update_syntax_visibility}; 38 + use weaver_editor_core::EditorDocument; 39 39 use weaver_editor_core::EditorImageResolver; 40 + #[allow(unused_imports)] 41 + use weaver_editor_core::InputType; 40 42 use weaver_editor_core::ParagraphRender; 41 43 use weaver_editor_core::SnapDirection; 42 44 use weaver_editor_core::apply_formatting; ··· 703 705 // This is the primary handler for text insertion, deletion, etc. 704 706 // Keydown only handles shortcuts now. 705 707 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 708 + type BeforeInputClosure = wasm_bindgen::closure::Closure<dyn FnMut(web_sys::InputEvent)>; 709 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 710 + let mut beforeinput_closure: Signal<Option<BeforeInputClosure>> = use_signal(|| None); 711 + 712 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 706 713 let doc_for_beforeinput = document.clone(); 707 714 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 708 715 use_effect(move || { 716 + use gloo_timers::callback::Timeout; 709 717 use wasm_bindgen::JsCast; 710 718 use wasm_bindgen::prelude::*; 711 719 ··· 725 733 let mut doc = doc_for_beforeinput.clone(); 726 734 let cached_paras = cached_paragraphs; 727 735 728 - let closure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 736 + let closure: BeforeInputClosure = Closure::wrap(Box::new(move |evt: web_sys::InputEvent| { 729 737 let input_type_str = evt.input_type(); 730 738 tracing::debug!(input_type = %input_type_str, "beforeinput"); 731 739 ··· 746 754 platform: &plat, 747 755 }; 748 756 749 - let result = handle_beforeinput(&mut doc, ctx); 757 + let current_range = weaver_editor_browser::get_current_range(&doc); 758 + let result = weaver_editor_browser::handle_beforeinput(&mut doc, &ctx, current_range); 750 759 751 760 match result { 752 761 BeforeInputResult::Handled => { ··· 765 774 let mut doc_for_timeout = doc.clone(); 766 775 let doc_len_before = doc.len_chars(); 767 776 768 - let window = web_sys::window(); 769 - if let Some(window) = window { 770 - let closure = Closure::once(move || { 771 - // Check if the document changed 772 - if doc_for_timeout.len_chars() == doc_len_before { 773 - // Nothing happened - execute fallback 774 - tracing::debug!("Android backspace fallback triggered"); 775 - // Refocus to work around virtual keyboard issues 776 - if let Some(window) = web_sys::window() { 777 - if let Some(doc) = window.document() { 778 - if let Some(elem) = doc.get_element_by_id(editor_id) { 779 - if let Some(html_elem) = 780 - elem.dyn_ref::<web_sys::HtmlElement>() 781 - { 782 - let _ = html_elem.blur(); 783 - let _ = html_elem.focus(); 784 - } 777 + Timeout::new(50, move || { 778 + if doc_for_timeout.len_chars() == doc_len_before { 779 + tracing::debug!("Android backspace fallback triggered"); 780 + // Refocus to work around virtual keyboard issues 781 + if let Some(window) = web_sys::window() { 782 + if let Some(dom_doc) = window.document() { 783 + if let Some(elem) = dom_doc.get_element_by_id(editor_id) { 784 + if let Some(html_elem) = 785 + elem.dyn_ref::<web_sys::HtmlElement>() 786 + { 787 + let _ = html_elem.blur(); 788 + let _ = html_elem.focus(); 785 789 } 786 790 } 787 791 } 788 - execute_action(&mut doc_for_timeout, &fallback_action); 789 792 } 790 - }); 791 - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 792 - closure.as_ref().unchecked_ref(), 793 - 50, 794 - ); 795 - closure.forget(); 796 - } 793 + execute_action(&mut doc_for_timeout, &fallback_action); 794 + } 795 + }) 796 + .forget(); // One-shot timer, runs and cleans up 797 797 } 798 798 } 799 799 ··· 805 805 tracing::debug!("Android: possible suggestion pick, deferring cursor sync"); 806 806 let paras = cached_paras; 807 807 let mut doc_for_timeout = doc.clone(); 808 - let window = web_sys::window(); 809 - if let Some(window) = window { 810 - let closure = Closure::once(move || { 811 - let paras = paras(); 812 - sync_cursor_from_dom(&mut doc_for_timeout, editor_id, &paras); 813 - }); 814 - let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0( 815 - closure.as_ref().unchecked_ref(), 816 - 20, 817 - ); 818 - closure.forget(); 819 - } 808 + 809 + Timeout::new(20, move || { 810 + let paras = paras(); 811 + sync_cursor_from_dom(&mut doc_for_timeout, editor_id, &paras); 812 + }) 813 + .forget(); // One-shot timer, runs and cleans up 820 814 } 821 815 } 822 816 } 823 - }) as Box<dyn FnMut(web_sys::InputEvent)>); 817 + }) 818 + as Box<dyn FnMut(web_sys::InputEvent)>); 824 819 825 820 let _ = editor 826 821 .add_event_listener_with_callback("beforeinput", closure.as_ref().unchecked_ref()); 827 - closure.forget(); 822 + 823 + // Store closure in signal for proper lifecycle management 824 + beforeinput_closure.set(Some(closure)); 825 + }); 826 + 827 + // Clean up event listener on unmount 828 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 829 + use_drop(move || { 830 + if let Some(closure) = beforeinput_closure.peek().as_ref() { 831 + if let Some(window) = web_sys::window() { 832 + if let Some(dom_document) = window.document() { 833 + if let Some(editor) = dom_document.get_element_by_id(editor_id) { 834 + use wasm_bindgen::JsCast; 835 + let _ = editor.remove_event_listener_with_callback( 836 + "beforeinput", 837 + closure.as_ref().unchecked_ref(), 838 + ); 839 + } 840 + } 841 + } 842 + } 828 843 }); 829 844 830 845 rsx! { ··· 1184 1199 move |evt| { 1185 1200 tracing::debug!("onclick fired - syncing cursor from DOM"); 1186 1201 let paras = cached_paragraphs(); 1202 + let spans = syntax_spans(); 1187 1203 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1188 1204 let _ = evt; 1189 1205 1190 - // Check if click target is a math-clickable element 1206 + // Check if click target is a math-clickable element. 1191 1207 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 1192 1208 { 1209 + let map = offset_map(); 1193 1210 use dioxus::web::WebEventExt; 1194 - use wasm_bindgen::JsCast; 1195 1211 1196 1212 let web_evt = evt.as_web_event(); 1197 1213 if let Some(target) = web_evt.target() { 1198 - if let Some(element) = target.dyn_ref::<web_sys::Element>() { 1199 - // Check element or ancestors for math-clickable 1200 - if let Ok(Some(math_el)) = element.closest(".math-clickable") { 1201 - if let Some(char_target) = math_el.get_attribute("data-char-target") { 1202 - if let Ok(offset) = char_target.parse::<usize>() { 1203 - tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 1204 - doc.cursor.write().offset = offset; 1205 - *doc.selection.write() = None; 1206 - // Update visibility FIRST so math-source is visible 1207 - let spans = syntax_spans(); 1208 - update_syntax_visibility(offset, None, &spans, &paras); 1209 - // Then set DOM selection 1210 - let map = offset_map(); 1211 - let _ = weaver_editor_browser::restore_cursor_position( 1212 - offset, 1213 - &map, 1214 - None, 1215 - ); 1216 - return; 1217 - } 1218 - } 1219 - } 1214 + if weaver_editor_browser::handle_math_click( 1215 + &target, &mut doc, &spans, &paras, &map, 1216 + ) { 1217 + return; 1220 1218 } 1221 1219 } 1222 1220 } 1223 1221 1224 1222 sync_cursor_from_dom(&mut doc, editor_id, &paras); 1225 - let spans = syntax_spans(); 1226 1223 let cursor_offset = doc.cursor.read().offset; 1227 1224 let selection = *doc.selection.read(); 1228 - update_syntax_visibility( 1229 - cursor_offset, 1230 - selection.as_ref(), 1231 - &spans, 1232 - &paras, 1233 - ); 1225 + update_syntax_visibility(cursor_offset, selection.as_ref(), &spans, &paras); 1234 1226 } 1235 1227 }, 1236 1228 ··· 1367 1359 if !final_text.is_empty() { 1368 1360 let mut delete_start = comp.start_offset; 1369 1361 while delete_start > 0 { 1370 - match get_char_at(doc.loro_text(), delete_start - 1) { 1362 + match doc.char_at(delete_start - 1) { 1371 1363 Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 1372 1364 _ => break, 1373 1365 } ··· 1569 1561 } 1570 1562 } 1571 1563 } 1572 - 1573 - /// Remote collaborator cursors overlay. 1574 - /// 1575 - /// Renders cursor indicators for each remote collaborator. 1576 - /// Uses the same offset mapping as local cursor restoration. 1577 - #[component] 1578 - fn RemoteCursors( 1579 - presence: Signal<weaver_common::transport::PresenceSnapshot>, 1580 - document: SignalEditorDocument, 1581 - render_cache: Signal<weaver_editor_browser::RenderCache>, 1582 - ) -> Element { 1583 - let presence_read = presence.read(); 1584 - let cursor_count = presence_read.collaborators.len(); 1585 - let cursors: Vec<_> = presence_read 1586 - .collaborators 1587 - .iter() 1588 - .filter_map(|c| { 1589 - c.cursor_position 1590 - .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) 1591 - }) 1592 - .collect(); 1593 - 1594 - if cursor_count > 0 { 1595 - tracing::debug!( 1596 - "RemoteCursors: {} collaborators, {} with cursors", 1597 - cursor_count, 1598 - cursors.len() 1599 - ); 1600 - } 1601 - 1602 - if cursors.is_empty() { 1603 - return rsx! {}; 1604 - } 1605 - 1606 - // Get flattened offset map from all paragraphs 1607 - let cache = render_cache.read(); 1608 - let offset_map: Vec<_> = cache 1609 - .paragraphs 1610 - .iter() 1611 - .flat_map(|p| p.offset_map.iter().cloned()) 1612 - .collect(); 1613 - 1614 - rsx! { 1615 - div { class: "remote-cursors-overlay", 1616 - for (display_name, color, position, selection) in cursors { 1617 - RemoteCursorIndicator { 1618 - key: "{display_name}-{position}", 1619 - display_name, 1620 - position, 1621 - selection, 1622 - color, 1623 - offset_map: offset_map.clone(), 1624 - } 1625 - } 1626 - } 1627 - } 1628 - } 1629 - 1630 - /// Single remote cursor indicator with DOM-based positioning. 1631 - #[component] 1632 - fn RemoteCursorIndicator( 1633 - display_name: String, 1634 - position: usize, 1635 - selection: Option<(usize, usize)>, 1636 - color: u32, 1637 - offset_map: Vec<weaver_editor_core::OffsetMapping>, 1638 - ) -> Element { 1639 - use weaver_editor_browser::{ 1640 - get_cursor_rect_relative, get_selection_rects_relative, rgba_u32_to_css, 1641 - rgba_u32_to_css_alpha, 1642 - }; 1643 - 1644 - let color_css = rgba_u32_to_css(color); 1645 - let selection_color_css = rgba_u32_to_css_alpha(color, 0.25); 1646 - 1647 - // Get cursor position relative to editor 1648 - let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor"); 1649 - 1650 - // Get selection rectangles if there's a selection 1651 - let selection_rects = if let Some((start, end)) = selection { 1652 - let (start, end) = if start <= end { 1653 - (start, end) 1654 - } else { 1655 - (end, start) 1656 - }; 1657 - get_selection_rects_relative(start, end, &offset_map, "markdown-editor") 1658 - } else { 1659 - vec![] 1660 - }; 1661 - 1662 - let Some(rect) = rect else { 1663 - tracing::debug!( 1664 - "RemoteCursorIndicator: no rect for position {} (offset_map len: {})", 1665 - position, 1666 - offset_map.len() 1667 - ); 1668 - return rsx! {}; 1669 - }; 1670 - 1671 - tracing::trace!( 1672 - "RemoteCursorIndicator: {} at ({}, {}) h={}, selection_rects={}", 1673 - display_name, 1674 - rect.x, 1675 - rect.y, 1676 - rect.height, 1677 - selection_rects.len() 1678 - ); 1679 - 1680 - let style = format!( 1681 - "left: {}px; top: {}px; --cursor-height: {}px; --cursor-color: {};", 1682 - rect.x, rect.y, rect.height, color_css 1683 - ); 1684 - 1685 - rsx! { 1686 - // Selection highlight rectangles (rendered behind cursor) 1687 - for (i, sel_rect) in selection_rects.iter().enumerate() { 1688 - div { 1689 - key: "sel-{i}", 1690 - class: "remote-selection", 1691 - style: "left: {sel_rect.x}px; top: {sel_rect.y}px; width: {sel_rect.width}px; height: {sel_rect.height}px; background-color: {selection_color_css};", 1692 - } 1693 - } 1694 - 1695 - div { 1696 - class: "remote-cursor", 1697 - style: "{style}", 1698 - 1699 - // Cursor caret line 1700 - div { class: "remote-cursor-caret" } 1701 - 1702 - // Name label 1703 - div { class: "remote-cursor-label", 1704 - "{display_name}" 1705 - } 1706 - } 1707 - } 1708 - }
+37
crates/weaver-app/src/components/editor/document.rs
··· 1131 1131 fn set_composition(&mut self, composition: Option<CompositionState>) { 1132 1132 self.composition.set(composition); 1133 1133 } 1134 + 1135 + fn undo(&mut self) -> bool { 1136 + // Sync Loro cursor to current position BEFORE undo 1137 + // so it tracks through the undo operation. 1138 + self.sync_loro_cursor(); 1139 + 1140 + let result = self.buffer.undo(); 1141 + if result { 1142 + // After undo, query Loro cursor for new position. 1143 + self.sync_cursor_from_loro(); 1144 + // Signal content change for re-render. 1145 + self.content_changed.set(()); 1146 + } 1147 + result 1148 + } 1149 + 1150 + fn redo(&mut self) -> bool { 1151 + // Sync Loro cursor to current position BEFORE redo. 1152 + self.sync_loro_cursor(); 1153 + 1154 + let result = self.buffer.redo(); 1155 + if result { 1156 + // After redo, query Loro cursor for new position. 1157 + self.sync_cursor_from_loro(); 1158 + // Signal content change for re-render. 1159 + self.content_changed.set(()); 1160 + } 1161 + result 1162 + } 1163 + 1164 + fn pending_snap(&self) -> Option<weaver_editor_core::SnapDirection> { 1165 + *self.pending_snap.read() 1166 + } 1167 + 1168 + fn set_pending_snap(&mut self, snap: Option<weaver_editor_core::SnapDirection>) { 1169 + self.pending_snap.set(snap); 1170 + } 1134 1171 }
-601
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::SignalEditorDocument; 8 - use weaver_editor_core::{FormatAction, SnapDirection, apply_formatting}; 9 - 10 - // Re-export ListContext from core - the logic is duplicated below for Loro-specific usage, 11 - // but the type itself comes from core. 12 - pub use weaver_editor_core::ListContext; 13 - 14 - // Re-export clipboard helpers from browser crate. 15 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 16 - pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type}; 17 - 18 - /// Check if we need to intercept this key event. 19 - /// Returns true for content-modifying operations, false for navigation. 20 - #[allow(unused)] 21 - pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 22 - use dioxus::prelude::keyboard_types::Key; 23 - 24 - let key = evt.key(); 25 - let mods = evt.modifiers(); 26 - 27 - // Handle Ctrl/Cmd shortcuts 28 - if mods.ctrl() || mods.meta() { 29 - if let Key::Character(ch) = &key { 30 - // Intercept our shortcuts: formatting (b/i), undo/redo (z/y), HTML export (e) 31 - match ch.as_str() { 32 - "b" | "i" | "z" | "y" => return true, 33 - "e" => return true, // Ctrl+E for HTML export/copy 34 - _ => {} 35 - } 36 - } 37 - // Intercept Cmd+Backspace (delete to start of line) and Cmd+Delete (delete to end) 38 - if matches!(key, Key::Backspace | Key::Delete) { 39 - return true; 40 - } 41 - // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.) 42 - return false; 43 - } 44 - 45 - // Intercept content modifications 46 - matches!( 47 - key, 48 - Key::Character(_) | Key::Backspace | Key::Delete | Key::Enter | Key::Tab 49 - ) 50 - } 51 - 52 - /// Handle keyboard events and update document state. 53 - #[allow(unused)] 54 - pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut SignalEditorDocument) { 55 - use dioxus::prelude::keyboard_types::Key; 56 - 57 - let key = evt.key(); 58 - let mods = evt.modifiers(); 59 - 60 - match key { 61 - Key::Character(ch) => { 62 - // Keyboard shortcuts first 63 - if mods.ctrl() { 64 - match ch.as_str() { 65 - "b" => { 66 - apply_formatting(doc, FormatAction::Bold); 67 - return; 68 - } 69 - "i" => { 70 - apply_formatting(doc, FormatAction::Italic); 71 - return; 72 - } 73 - "z" => { 74 - if mods.shift() { 75 - // Ctrl+Shift+Z = redo 76 - if let Ok(true) = doc.redo() { 77 - let max = doc.len_chars(); 78 - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 79 - } 80 - } else { 81 - // Ctrl+Z = undo 82 - if let Ok(true) = doc.undo() { 83 - let max = doc.len_chars(); 84 - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 85 - } 86 - } 87 - doc.selection.set(None); 88 - return; 89 - } 90 - "y" => { 91 - // Ctrl+Y = redo (alternative) 92 - if let Ok(true) = doc.redo() { 93 - let max = doc.len_chars(); 94 - doc.cursor.with_mut(|c| c.offset = c.offset.min(max)); 95 - } 96 - doc.selection.set(None); 97 - return; 98 - } 99 - "e" => { 100 - // Ctrl+E = copy as HTML (export) 101 - if let Some(sel) = *doc.selection.read() { 102 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 103 - if start != end { 104 - if let Some(markdown) = doc.slice(start, end) { 105 - let clean_md = 106 - markdown.replace('\u{200C}', "").replace('\u{200B}', ""); 107 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 108 - wasm_bindgen_futures::spawn_local(async move { 109 - if let Err(e) = copy_as_html(&clean_md).await { 110 - tracing::warn!("[COPY HTML] Failed: {:?}", e); 111 - } 112 - }); 113 - } 114 - } 115 - } 116 - return; 117 - } 118 - _ => {} 119 - } 120 - } 121 - 122 - // Insert character at cursor (replacing selection if any) 123 - let sel = doc.selection.write().take(); 124 - if let Some(sel) = sel { 125 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 126 - let _ = doc.replace_tracked(start, end.saturating_sub(start), &ch); 127 - doc.cursor.write().offset = start + ch.chars().count(); 128 - } else { 129 - // Clean up any preceding zero-width chars (gap scaffolding) 130 - let cursor_offset = doc.cursor.read().offset; 131 - let mut delete_start = cursor_offset; 132 - while delete_start > 0 { 133 - match get_char_at(doc.loro_text(), delete_start - 1) { 134 - Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 135 - _ => break, 136 - } 137 - } 138 - 139 - let zw_count = cursor_offset - delete_start; 140 - if zw_count > 0 { 141 - // Splice: delete zero-width chars and insert new char in one op 142 - let _ = doc.replace_tracked(delete_start, zw_count, &ch); 143 - doc.cursor.write().offset = delete_start + ch.chars().count(); 144 - } else if cursor_offset == doc.len_chars() { 145 - // Fast path: append at end 146 - let _ = doc.push_tracked(&ch); 147 - doc.cursor.write().offset = cursor_offset + ch.chars().count(); 148 - } else { 149 - let _ = doc.insert_tracked(cursor_offset, &ch); 150 - doc.cursor.write().offset = cursor_offset + ch.chars().count(); 151 - } 152 - } 153 - } 154 - 155 - Key::Backspace => { 156 - // Snap backward after backspace (toward deleted content) 157 - doc.pending_snap.set(Some(SnapDirection::Backward)); 158 - 159 - let sel = doc.selection.write().take(); 160 - if let Some(sel) = sel { 161 - // Delete selection 162 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 163 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 164 - doc.cursor.write().offset = start; 165 - } else if doc.cursor.read().offset > 0 { 166 - let cursor_offset = doc.cursor.read().offset; 167 - 168 - // Cmd+Backspace: delete to start of line 169 - if mods.meta() || mods.ctrl() { 170 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 171 - if line_start < cursor_offset { 172 - let _ = doc.remove_tracked(line_start, cursor_offset - line_start); 173 - doc.cursor.write().offset = line_start; 174 - } 175 - return; 176 - } 177 - 178 - // Check if we're about to delete a newline 179 - let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1); 180 - 181 - if prev_char == Some('\n') { 182 - let newline_pos = cursor_offset - 1; 183 - let mut delete_start = newline_pos; 184 - let mut delete_end = cursor_offset; 185 - 186 - // Check if there's another newline before this one (empty paragraph) 187 - // If so, delete both newlines to merge paragraphs 188 - if newline_pos > 0 { 189 - let prev_prev_char = get_char_at(doc.loro_text(), newline_pos - 1); 190 - if prev_prev_char == Some('\n') { 191 - // Empty paragraph case: delete both newlines 192 - delete_start = newline_pos - 1; 193 - } 194 - } 195 - 196 - // Also check if there's a zero-width char after cursor (inserted by Shift+Enter) 197 - if let Some(ch) = get_char_at(doc.loro_text(), delete_end) { 198 - if ch == '\u{200C}' || ch == '\u{200B}' { 199 - delete_end += 1; 200 - } 201 - } 202 - 203 - // Scan backwards through whitespace before the newline(s) 204 - while delete_start > 0 { 205 - let ch = get_char_at(doc.loro_text(), delete_start - 1); 206 - match ch { 207 - Some('\u{200C}') | Some('\u{200B}') => { 208 - delete_start -= 1; 209 - } 210 - Some('\n') => break, // stop at another newline 211 - _ => break, // stop at actual content 212 - } 213 - } 214 - 215 - // Delete from where we stopped to end (including any trailing zero-width) 216 - let _ = 217 - doc.remove_tracked(delete_start, delete_end.saturating_sub(delete_start)); 218 - doc.cursor.write().offset = delete_start; 219 - } else { 220 - // Normal backspace - delete one char 221 - let prev = cursor_offset - 1; 222 - let _ = doc.remove_tracked(prev, 1); 223 - doc.cursor.write().offset = prev; 224 - } 225 - } 226 - } 227 - 228 - Key::Delete => { 229 - // Snap forward after delete (toward remaining content) 230 - doc.pending_snap.set(Some(SnapDirection::Forward)); 231 - 232 - let sel = doc.selection.write().take(); 233 - if let Some(sel) = sel { 234 - // Delete selection 235 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 236 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 237 - doc.cursor.write().offset = start; 238 - } else { 239 - let cursor_offset = doc.cursor.read().offset; 240 - let doc_len = doc.len_chars(); 241 - 242 - // Cmd+Delete: delete to end of line 243 - if mods.meta() || mods.ctrl() { 244 - let line_end = find_line_end(doc.loro_text(), cursor_offset); 245 - if cursor_offset < line_end { 246 - let _ = doc.remove_tracked(cursor_offset, line_end - cursor_offset); 247 - } 248 - return; 249 - } 250 - 251 - if cursor_offset < doc_len { 252 - // Delete next char 253 - let _ = doc.remove_tracked(cursor_offset, 1); 254 - } 255 - } 256 - } 257 - 258 - // Arrow keys handled by browser, synced in onkeyup 259 - Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown => { 260 - // Browser handles these naturally 261 - } 262 - 263 - Key::Enter => { 264 - // Snap forward after enter (into new paragraph/line) 265 - doc.pending_snap.set(Some(SnapDirection::Forward)); 266 - 267 - let sel = doc.selection.write().take(); 268 - if let Some(sel) = sel { 269 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 270 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 271 - doc.cursor.write().offset = start; 272 - } 273 - 274 - let cursor_offset = doc.cursor.read().offset; 275 - if mods.shift() { 276 - // Shift+Enter: hard line break (soft break) 277 - let _ = doc.insert_tracked(cursor_offset, " \n\u{200C}"); 278 - doc.cursor.write().offset = cursor_offset + 3; 279 - } else if let Some(ctx) = detect_list_context(doc.loro_text(), cursor_offset) { 280 - // We're in a list item 281 - if is_list_item_empty(doc.loro_text(), cursor_offset, &ctx) { 282 - // Empty item - exit list by removing marker and inserting paragraph break 283 - let line_start = find_line_start(doc.loro_text(), cursor_offset); 284 - let line_end = find_line_end(doc.loro_text(), cursor_offset); 285 - 286 - // Delete the empty list item line INCLUDING its trailing newline 287 - // line_end points to the newline, so +1 to include it 288 - let delete_end = (line_end + 1).min(doc.len_chars()); 289 - 290 - // Use replace_tracked to atomically delete line and insert paragraph break 291 - let _ = doc.replace_tracked( 292 - line_start, 293 - delete_end.saturating_sub(line_start), 294 - "\n\n\u{200C}\n", 295 - ); 296 - doc.cursor.write().offset = line_start + 2; 297 - } else { 298 - // Non-empty item - continue list 299 - let continuation = match ctx { 300 - ListContext::Unordered { indent, marker } => { 301 - format!("\n{}{} ", indent, marker) 302 - } 303 - ListContext::Ordered { indent, number } => { 304 - format!("\n{}{}. ", indent, number + 1) 305 - } 306 - }; 307 - let len = continuation.chars().count(); 308 - let _ = doc.insert_tracked(cursor_offset, &continuation); 309 - doc.cursor.write().offset = cursor_offset + len; 310 - } 311 - } else { 312 - // Not in a list - normal paragraph break 313 - let _ = doc.insert_tracked(cursor_offset, "\n\n"); 314 - doc.cursor.write().offset = cursor_offset + 2; 315 - } 316 - } 317 - 318 - // Home/End handled by browser, synced in onkeyup 319 - Key::Home | Key::End => { 320 - // Browser handles these naturally 321 - } 322 - 323 - _ => {} 324 - } 325 - 326 - // Sync Loro cursor when edits affect paragraph boundaries 327 - // This ensures cursor position is tracked correctly through structural changes 328 - if doc.last_edit().is_some_and(|e| e.contains_newline) { 329 - doc.sync_loro_cursor(); 330 - } 331 - } 332 - 333 - /// Handle paste events and insert text at cursor. 334 - pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) { 335 - evt.prevent_default(); 336 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 337 - let _ = doc; 338 - 339 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 340 - { 341 - use dioxus::web::WebEventExt; 342 - use wasm_bindgen::JsCast; 343 - 344 - let base_evt = evt.as_web_event(); 345 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 346 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 347 - // Try our custom type first (internal paste), fall back to text/plain 348 - let text = data_transfer 349 - .get_data("text/x-weaver-md") 350 - .ok() 351 - .filter(|s| !s.is_empty()) 352 - .or_else(|| data_transfer.get_data("text/plain").ok()); 353 - 354 - if let Some(text) = text { 355 - // Delete selection if present 356 - let sel = doc.selection.write().take(); 357 - if let Some(sel) = sel { 358 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 359 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 360 - doc.cursor.write().offset = start; 361 - } 362 - 363 - // Insert pasted text 364 - let cursor_offset = doc.cursor.read().offset; 365 - let _ = doc.insert_tracked(cursor_offset, &text); 366 - doc.cursor.write().offset = cursor_offset + text.chars().count(); 367 - } 368 - } 369 - } else { 370 - tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 371 - } 372 - } 373 - } 374 - 375 - /// Handle cut events - extract text, write to clipboard, then delete. 376 - pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut SignalEditorDocument) { 377 - evt.prevent_default(); 378 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 379 - let _ = doc; 380 - 381 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 382 - { 383 - use dioxus::web::WebEventExt; 384 - use wasm_bindgen::JsCast; 385 - 386 - let base_evt = evt.as_web_event(); 387 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 388 - let cut_text = { 389 - let sel = doc.selection.write().take(); 390 - if let Some(sel) = sel { 391 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 392 - if start != end { 393 - // Extract text and strip zero-width chars 394 - let selected_text = doc.slice(start, end).unwrap_or_default(); 395 - let clean_text = selected_text 396 - .replace('\u{200C}', "") 397 - .replace('\u{200B}', ""); 398 - 399 - // Write to clipboard BEFORE deleting (sync fallback) 400 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 401 - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 402 - tracing::warn!("[CUT] Failed to set clipboard data: {:?}", e); 403 - } 404 - } 405 - 406 - // Now delete 407 - let _ = doc.remove_tracked(start, end.saturating_sub(start)); 408 - doc.cursor.write().offset = start; 409 - 410 - Some(clean_text) 411 - } else { 412 - None 413 - } 414 - } else { 415 - None 416 - } 417 - }; 418 - 419 - // Async: also write custom MIME type for internal paste detection 420 - if let Some(text) = cut_text { 421 - wasm_bindgen_futures::spawn_local(async move { 422 - if let Err(e) = write_clipboard_with_custom_type(&text).await { 423 - tracing::debug!("[CUT] Async clipboard write failed: {:?}", e); 424 - } 425 - }); 426 - } 427 - } 428 - } 429 - 430 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 431 - { 432 - let _ = evt; // suppress unused warning 433 - } 434 - } 435 - 436 - /// Handle copy events - extract text, clean it up, write to clipboard. 437 - pub fn handle_copy(evt: Event<ClipboardData>, doc: &SignalEditorDocument) { 438 - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 439 - { 440 - use dioxus::web::WebEventExt; 441 - use wasm_bindgen::JsCast; 442 - 443 - let base_evt = evt.as_web_event(); 444 - if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 445 - let sel = *doc.selection.read(); 446 - if let Some(sel) = sel { 447 - let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head)); 448 - if start != end { 449 - // Extract text 450 - let selected_text = doc.slice(start, end).unwrap_or_default(); 451 - 452 - // Strip zero-width chars used for gap handling 453 - let clean_text = selected_text 454 - .replace('\u{200C}', "") 455 - .replace('\u{200B}', ""); 456 - 457 - // Sync fallback: write text/plain via DataTransfer 458 - if let Some(data_transfer) = clipboard_evt.clipboard_data() { 459 - if let Err(e) = data_transfer.set_data("text/plain", &clean_text) { 460 - tracing::warn!("[COPY] Failed to set clipboard data: {:?}", e); 461 - } 462 - } 463 - 464 - // Async: also write custom MIME type for internal paste detection 465 - let text_for_async = clean_text.clone(); 466 - wasm_bindgen_futures::spawn_local(async move { 467 - if let Err(e) = write_clipboard_with_custom_type(&text_for_async).await { 468 - tracing::debug!("[COPY] Async clipboard write failed: {:?}", e); 469 - } 470 - }); 471 - 472 - // Prevent browser's default copy (which would copy rendered HTML) 473 - evt.prevent_default(); 474 - } 475 - } 476 - } 477 - } 478 - 479 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 480 - { 481 - let _ = (evt, doc); // suppress unused warnings 482 - } 483 - } 484 - 485 - /// Detect if cursor is in a list item and return context for continuation. 486 - /// 487 - /// Scans backwards to find start of current line, then checks for list marker. 488 - pub fn detect_list_context(text: &loro::LoroText, cursor_offset: usize) -> Option<ListContext> { 489 - // Find start of current line 490 - let line_start = find_line_start(text, cursor_offset); 491 - 492 - // Get the line content from start to cursor 493 - let line_end = find_line_end(text, cursor_offset); 494 - if line_start >= line_end { 495 - return None; 496 - } 497 - 498 - // Extract line text 499 - let line = text.slice(line_start, line_end).ok()?; 500 - 501 - // Parse indentation 502 - let indent: String = line 503 - .chars() 504 - .take_while(|c| *c == ' ' || *c == '\t') 505 - .collect(); 506 - let trimmed = &line[indent.len()..]; 507 - 508 - // Check for unordered list marker: "- " or "* " 509 - if trimmed.starts_with("- ") { 510 - return Some(ListContext::Unordered { 511 - indent, 512 - marker: '-', 513 - }); 514 - } 515 - if trimmed.starts_with("* ") { 516 - return Some(ListContext::Unordered { 517 - indent, 518 - marker: '*', 519 - }); 520 - } 521 - 522 - // Check for ordered list marker: "1. ", "2. ", "123. ", etc. 523 - if let Some(dot_pos) = trimmed.find(". ") { 524 - let num_part = &trimmed[..dot_pos]; 525 - if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { 526 - if let Ok(number) = num_part.parse::<usize>() { 527 - return Some(ListContext::Ordered { indent, number }); 528 - } 529 - } 530 - } 531 - 532 - None 533 - } 534 - 535 - /// Check if the current list item is empty (just the marker, no content after cursor). 536 - /// 537 - /// Used to determine whether Enter should continue the list or exit it. 538 - pub fn is_list_item_empty(text: &loro::LoroText, cursor_offset: usize, ctx: &ListContext) -> bool { 539 - let line_start = find_line_start(text, cursor_offset); 540 - let line_end = find_line_end(text, cursor_offset); 541 - 542 - // Get line content 543 - let line = match text.slice(line_start, line_end) { 544 - Ok(s) => s, 545 - Err(_) => return false, 546 - }; 547 - 548 - // Calculate expected marker length 549 - let marker_len = match ctx { 550 - ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " 551 - ListContext::Ordered { indent, number } => { 552 - indent.len() + number.to_string().len() + 2 // "1. " 553 - } 554 - }; 555 - 556 - // Item is empty if line length equals marker length (nothing after marker) 557 - line.len() <= marker_len 558 - } 559 - 560 - /// Get character at the given offset in LoroText. 561 - pub fn get_char_at(text: &loro::LoroText, offset: usize) -> Option<char> { 562 - text.char_at(offset).ok() 563 - } 564 - 565 - /// Find start of line containing offset. 566 - pub fn find_line_start(text: &loro::LoroText, offset: usize) -> usize { 567 - if offset == 0 { 568 - return 0; 569 - } 570 - // Only slice the portion before cursor 571 - let prefix = match text.slice(0, offset) { 572 - Ok(s) => s, 573 - Err(_) => return 0, 574 - }; 575 - prefix 576 - .chars() 577 - .enumerate() 578 - .filter(|(_, c)| *c == '\n') 579 - .last() 580 - .map(|(pos, _)| pos + 1) 581 - .unwrap_or(0) 582 - } 583 - 584 - /// Find end of line containing offset. 585 - pub fn find_line_end(text: &loro::LoroText, offset: usize) -> usize { 586 - let char_len = text.len_unicode(); 587 - if offset >= char_len { 588 - return char_len; 589 - } 590 - // Only slice from cursor to end 591 - let suffix = match text.slice(offset, char_len) { 592 - Ok(s) => s, 593 - Err(_) => return char_len, 594 - }; 595 - suffix 596 - .chars() 597 - .enumerate() 598 - .find(|(_, c)| *c == '\n') 599 - .map(|(i, _)| offset + i) 600 - .unwrap_or(char_len) 601 - }
+1 -5
crates/weaver-app/src/components/editor/mod.rs
··· 5 5 //! editing plain markdown text under the hood. 6 6 7 7 mod actions; 8 - mod beforeinput; 9 8 mod collab; 10 9 mod component; 11 10 mod document; 12 11 mod dom_sync; 13 12 mod image_upload; 14 - mod input; 15 13 mod log_buffer; 16 14 mod publish; 15 + mod remote_cursors; 17 16 mod report; 18 17 mod storage; 19 18 mod sync; ··· 21 20 22 21 #[cfg(test)] 23 22 mod tests; 24 - 25 - // Re-export DOM update strategy constant from browser crate. 26 - pub(crate) use weaver_editor_browser::FORCE_INNERHTML_UPDATE; 27 23 28 24 // Main component 29 25 pub use component::MarkdownEditor;
+145
crates/weaver-app/src/components/editor/remote_cursors.rs
··· 1 + //! Remote collaborator cursor overlays. 2 + //! 3 + //! Renders cursor indicators for each remote collaborator in a real-time 4 + //! editing session. Uses the same offset mapping as local cursor restoration. 5 + 6 + use dioxus::prelude::*; 7 + 8 + use super::document::SignalEditorDocument; 9 + 10 + /// Remote collaborator cursors overlay. 11 + /// 12 + /// Renders cursor indicators for each remote collaborator. 13 + /// Uses the same offset mapping as local cursor restoration. 14 + #[component] 15 + pub fn RemoteCursors( 16 + presence: Signal<weaver_common::transport::PresenceSnapshot>, 17 + document: SignalEditorDocument, 18 + render_cache: Signal<weaver_editor_browser::RenderCache>, 19 + ) -> Element { 20 + let presence_read = presence.read(); 21 + let cursor_count = presence_read.collaborators.len(); 22 + let cursors: Vec<_> = presence_read 23 + .collaborators 24 + .iter() 25 + .filter_map(|c| { 26 + c.cursor_position 27 + .map(|pos| (c.display_name.clone(), c.color, pos, c.selection)) 28 + }) 29 + .collect(); 30 + 31 + if cursor_count > 0 { 32 + tracing::debug!( 33 + "RemoteCursors: {} collaborators, {} with cursors", 34 + cursor_count, 35 + cursors.len() 36 + ); 37 + } 38 + 39 + if cursors.is_empty() { 40 + return rsx! {}; 41 + } 42 + 43 + // Get flattened offset map from all paragraphs. 44 + let cache = render_cache.read(); 45 + let offset_map: Vec<_> = cache 46 + .paragraphs 47 + .iter() 48 + .flat_map(|p| p.offset_map.iter().cloned()) 49 + .collect(); 50 + 51 + rsx! { 52 + div { class: "remote-cursors-overlay", 53 + for (display_name, color, position, selection) in cursors { 54 + RemoteCursorIndicator { 55 + key: "{display_name}-{position}", 56 + display_name, 57 + position, 58 + selection, 59 + color, 60 + offset_map: offset_map.clone(), 61 + } 62 + } 63 + } 64 + } 65 + } 66 + 67 + /// Single remote cursor indicator with DOM-based positioning. 68 + #[component] 69 + fn RemoteCursorIndicator( 70 + display_name: String, 71 + position: usize, 72 + selection: Option<(usize, usize)>, 73 + color: u32, 74 + offset_map: Vec<weaver_editor_core::OffsetMapping>, 75 + ) -> Element { 76 + use weaver_editor_browser::{ 77 + get_cursor_rect_relative, get_selection_rects_relative, rgba_u32_to_css, 78 + rgba_u32_to_css_alpha, 79 + }; 80 + 81 + let color_css = rgba_u32_to_css(color); 82 + let selection_color_css = rgba_u32_to_css_alpha(color, 0.25); 83 + 84 + // Get cursor position relative to editor. 85 + let rect = get_cursor_rect_relative(position, &offset_map, "markdown-editor"); 86 + 87 + // Get selection rectangles if there's a selection. 88 + let selection_rects = if let Some((start, end)) = selection { 89 + let (start, end) = if start <= end { 90 + (start, end) 91 + } else { 92 + (end, start) 93 + }; 94 + get_selection_rects_relative(start, end, &offset_map, "markdown-editor") 95 + } else { 96 + vec![] 97 + }; 98 + 99 + let Some(rect) = rect else { 100 + tracing::debug!( 101 + "RemoteCursorIndicator: no rect for position {} (offset_map len: {})", 102 + position, 103 + offset_map.len() 104 + ); 105 + return rsx! {}; 106 + }; 107 + 108 + tracing::trace!( 109 + "RemoteCursorIndicator: {} at ({}, {}) h={}, selection_rects={}", 110 + display_name, 111 + rect.x, 112 + rect.y, 113 + rect.height, 114 + selection_rects.len() 115 + ); 116 + 117 + let style = format!( 118 + "left: {}px; top: {}px; --cursor-height: {}px; --cursor-color: {};", 119 + rect.x, rect.y, rect.height, color_css 120 + ); 121 + 122 + rsx! { 123 + // Selection highlight rectangles (rendered behind cursor). 124 + for (i, sel_rect) in selection_rects.iter().enumerate() { 125 + div { 126 + key: "sel-{i}", 127 + class: "remote-selection", 128 + style: "left: {sel_rect.x}px; top: {sel_rect.y}px; width: {sel_rect.width}px; height: {sel_rect.height}px; background-color: {selection_color_css};", 129 + } 130 + } 131 + 132 + div { 133 + class: "remote-cursor", 134 + style: "{style}", 135 + 136 + // Cursor caret line. 137 + div { class: "remote-cursor-caret" } 138 + 139 + // Name label. 140 + div { class: "remote-cursor-label", 141 + "{display_name}" 142 + } 143 + } 144 + } 145 + }
+3 -4
crates/weaver-app/src/components/editor/sync.rs
··· 14 14 use jacquard::IntoStatic; 15 15 use jacquard::prelude::*; 16 16 use jacquard::types::ident::AtIdentifier; 17 - use jacquard::types::string::{AtUri, Cid, Did}; 17 + use jacquard::types::string::{AtUri, Cid}; 18 18 use loro::LoroDoc; 19 19 use loro::ToJson; 20 20 use weaver_api::com_atproto::repo::strong_ref::StrongRef; ··· 24 24 25 25 // Re-export crdt sync types for convenience. 26 26 pub use weaver_editor_crdt::{ 27 - CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, find_all_edit_roots, 28 - find_diffs_for_root, find_edit_root_for_draft, list_drafts, load_all_edit_states, 29 - load_edit_state_from_draft, load_edit_state_from_entry, 27 + CreateRootResult, PdsEditState, RemoteDraft, SyncResult, build_draft_uri, list_drafts, 28 + load_all_edit_states, load_edit_state_from_draft, load_edit_state_from_entry, 30 29 }; 31 30 32 31 /// Extract record embeds from a LoroDoc and pre-fetch their rendered content.
+14
crates/weaver-editor-browser/Cargo.toml
··· 62 62 default = [] 63 63 # Optional reactive state via dioxus-signals (framework-agnostic) 64 64 dioxus_signals = ["dep:dioxus-signals"] 65 + # Optional Dioxus event handlers for clipboard 66 + dioxus = ["dep:dioxus-core", "dep:dioxus-html", "dep:dioxus-web"] 65 67 66 68 [dependencies.dioxus-signals] 69 + version = "0.7" 70 + optional = true 71 + 72 + [dependencies.dioxus-core] 73 + version = "0.7" 74 + optional = true 75 + 76 + [dependencies.dioxus-html] 77 + version = "0.7" 78 + optional = true 79 + 80 + [dependencies.dioxus-web] 67 81 version = "0.7" 68 82 optional = true 69 83
+191
crates/weaver-editor-browser/src/clipboard.rs
··· 1 + //! Browser clipboard implementation. 2 + //! 3 + //! Implements `ClipboardPlatform` for browser environments using the 4 + //! ClipboardEvent's DataTransfer API for sync access and the async 5 + //! Clipboard API for custom MIME types. 6 + 7 + use weaver_editor_core::ClipboardPlatform; 8 + 9 + /// Browser clipboard context wrapping a ClipboardEvent's DataTransfer. 10 + /// 11 + /// Created from a clipboard event (copy, cut, paste) to provide sync 12 + /// clipboard access. Also spawns async tasks for custom MIME types. 13 + pub struct BrowserClipboard { 14 + data_transfer: Option<web_sys::DataTransfer>, 15 + } 16 + 17 + impl BrowserClipboard { 18 + /// Create from a ClipboardEvent. 19 + /// 20 + /// Call this in your copy/cut/paste event handler. 21 + pub fn from_event(evt: &web_sys::ClipboardEvent) -> Self { 22 + Self { 23 + data_transfer: evt.clipboard_data(), 24 + } 25 + } 26 + 27 + /// Create an empty clipboard context (for testing or non-event contexts). 28 + pub fn empty() -> Self { 29 + Self { 30 + data_transfer: None, 31 + } 32 + } 33 + } 34 + 35 + impl ClipboardPlatform for BrowserClipboard { 36 + fn write_text(&self, text: &str) { 37 + // Sync write via DataTransfer (immediate fallback). 38 + if let Some(dt) = &self.data_transfer { 39 + if let Err(e) = dt.set_data("text/plain", text) { 40 + tracing::warn!("Clipboard sync write failed: {:?}", e); 41 + } 42 + } 43 + 44 + // Async write for custom MIME type (enables internal paste detection). 45 + let text = text.to_string(); 46 + wasm_bindgen_futures::spawn_local(async move { 47 + if let Err(e) = crate::events::write_clipboard_with_custom_type(&text).await { 48 + tracing::debug!("Clipboard async write failed: {:?}", e); 49 + } 50 + }); 51 + } 52 + 53 + fn write_html(&self, html: &str, plain_text: &str) { 54 + // Sync write of plain text fallback. 55 + if let Some(dt) = &self.data_transfer { 56 + if let Err(e) = dt.set_data("text/plain", plain_text) { 57 + tracing::warn!("Clipboard sync write (plain) failed: {:?}", e); 58 + } 59 + } 60 + 61 + // Async write for HTML. 62 + let html = html.to_string(); 63 + let plain = plain_text.to_string(); 64 + wasm_bindgen_futures::spawn_local(async move { 65 + if let Err(e) = write_html_to_clipboard(&html, &plain).await { 66 + tracing::warn!("Clipboard HTML write failed: {:?}", e); 67 + } 68 + }); 69 + } 70 + 71 + fn read_text(&self) -> Option<String> { 72 + let dt = self.data_transfer.as_ref()?; 73 + 74 + // Try our custom MIME type first (internal paste). 75 + if let Ok(text) = dt.get_data("text/x-weaver-md") { 76 + if !text.is_empty() { 77 + return Some(text); 78 + } 79 + } 80 + 81 + // Fall back to plain text. 82 + dt.get_data("text/plain").ok().filter(|s| !s.is_empty()) 83 + } 84 + } 85 + 86 + /// Write HTML and plain text to clipboard using the async Clipboard API. 87 + /// 88 + /// This uses the navigator.clipboard API and doesn't require a clipboard event. 89 + /// Suitable for keyboard-triggered copy operations like CopyAsHtml. 90 + pub async fn write_html_to_clipboard( 91 + html: &str, 92 + plain_text: &str, 93 + ) -> Result<(), wasm_bindgen::JsValue> { 94 + use js_sys::{Array, Object, Reflect}; 95 + use wasm_bindgen::JsValue; 96 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 97 + 98 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 99 + let clipboard = window.navigator().clipboard(); 100 + 101 + // Create HTML blob. 102 + let html_parts = Array::new(); 103 + html_parts.push(&JsValue::from_str(html)); 104 + let html_opts = BlobPropertyBag::new(); 105 + html_opts.set_type("text/html"); 106 + let html_blob = Blob::new_with_str_sequence_and_options(&html_parts, &html_opts)?; 107 + 108 + // Create plain text blob. 109 + let text_parts = Array::new(); 110 + text_parts.push(&JsValue::from_str(plain_text)); 111 + let text_opts = BlobPropertyBag::new(); 112 + text_opts.set_type("text/plain"); 113 + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 114 + 115 + // Create ClipboardItem with both types. 116 + let item_data = Object::new(); 117 + Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 118 + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 119 + 120 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 121 + let items = Array::new(); 122 + items.push(&clipboard_item); 123 + 124 + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 125 + tracing::debug!("Wrote {} bytes of HTML to clipboard", html.len()); 126 + Ok(()) 127 + } 128 + 129 + // === Dioxus event handlers === 130 + 131 + /// Handle a Dioxus paste event. 132 + /// 133 + /// Extracts text from the clipboard event and inserts at cursor. 134 + #[cfg(feature = "dioxus")] 135 + pub fn handle_paste<D: weaver_editor_core::EditorDocument>( 136 + evt: dioxus_core::Event<dioxus_html::ClipboardData>, 137 + doc: &mut D, 138 + ) { 139 + use dioxus_web::WebEventExt; 140 + use wasm_bindgen::JsCast; 141 + 142 + evt.prevent_default(); 143 + 144 + let base_evt = evt.as_web_event(); 145 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 146 + let clipboard = BrowserClipboard::from_event(clipboard_evt); 147 + weaver_editor_core::clipboard_paste(doc, &clipboard); 148 + } else { 149 + tracing::warn!("[PASTE] Failed to cast to ClipboardEvent"); 150 + } 151 + } 152 + 153 + /// Handle a Dioxus cut event. 154 + /// 155 + /// Copies selection to clipboard, then deletes it. 156 + #[cfg(feature = "dioxus")] 157 + pub fn handle_cut<D: weaver_editor_core::EditorDocument>( 158 + evt: dioxus_core::Event<dioxus_html::ClipboardData>, 159 + doc: &mut D, 160 + ) { 161 + use dioxus_web::WebEventExt; 162 + use wasm_bindgen::JsCast; 163 + 164 + evt.prevent_default(); 165 + 166 + let base_evt = evt.as_web_event(); 167 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 168 + let clipboard = BrowserClipboard::from_event(clipboard_evt); 169 + weaver_editor_core::clipboard_cut(doc, &clipboard); 170 + } 171 + } 172 + 173 + /// Handle a Dioxus copy event. 174 + /// 175 + /// Copies selection to clipboard. Only prevents default if there was a selection. 176 + #[cfg(feature = "dioxus")] 177 + pub fn handle_copy<D: weaver_editor_core::EditorDocument>( 178 + evt: dioxus_core::Event<dioxus_html::ClipboardData>, 179 + doc: &D, 180 + ) { 181 + use dioxus_web::WebEventExt; 182 + use wasm_bindgen::JsCast; 183 + 184 + let base_evt = evt.as_web_event(); 185 + if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() { 186 + let clipboard = BrowserClipboard::from_event(clipboard_evt); 187 + if weaver_editor_core::clipboard_copy(doc, &clipboard) { 188 + evt.prevent_default(); 189 + } 190 + } 191 + }
+43 -46
crates/weaver-editor-browser/src/events.rs
··· 254 254 Ok(result.as_string()) 255 255 } 256 256 257 - /// Copy markdown as rendered HTML to clipboard. 258 - /// 259 - /// Renders the markdown to HTML and writes both text/html and text/plain 260 - /// representations to the clipboard. 261 - pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> { 262 - use js_sys::{Array, Object, Reflect}; 263 - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 264 - 265 - // Render markdown to HTML using ClientWriter. 266 - let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter(); 267 - let mut html = String::new(); 268 - weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( 269 - parser, &mut html, markdown, 270 - ) 271 - .run() 272 - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; 273 - 274 - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 275 - let clipboard = window.navigator().clipboard(); 276 - 277 - // Create blobs for both HTML and plain text. 278 - let parts = Array::new(); 279 - parts.push(&JsValue::from_str(&html)); 280 - 281 - let html_opts = BlobPropertyBag::new(); 282 - html_opts.set_type("text/html"); 283 - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; 284 - 285 - let text_opts = BlobPropertyBag::new(); 286 - text_opts.set_type("text/plain"); 287 - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; 288 - 289 - // Create ClipboardItem with both types. 290 - let item_data = Object::new(); 291 - Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; 292 - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 293 - 294 - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 295 - let items = Array::new(); 296 - items.push(&clipboard_item); 297 - 298 - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; 299 - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); 300 - Ok(()) 301 - } 302 - 303 257 // === BeforeInput handler === 304 258 305 259 use crate::FORCE_INNERHTML_UPDATE; ··· 564 518 | InputType::Unknown(_) => BeforeInputResult::PassThrough, 565 519 } 566 520 } 521 + 522 + // === Math click handling === 523 + 524 + use weaver_editor_core::{OffsetMapping, SyntaxSpanInfo}; 525 + 526 + /// Check if a click target is a math-clickable element. 527 + /// 528 + /// Returns the target character offset if the click was on a `.math-clickable` 529 + /// element with a valid `data-char-target` attribute, None otherwise. 530 + pub fn get_math_click_offset(target: &web_sys::EventTarget) -> Option<usize> { 531 + use wasm_bindgen::JsCast; 532 + 533 + let element = target.dyn_ref::<web_sys::Element>()?; 534 + let math_el = element.closest(".math-clickable").ok()??; 535 + let char_target = math_el.get_attribute("data-char-target")?; 536 + char_target.parse().ok() 537 + } 538 + 539 + /// Handle a click that might be on a math element. 540 + /// 541 + /// If the click target is a math-clickable element, this updates the cursor, 542 + /// clears selection, updates visibility, and restores the DOM cursor position. 543 + /// 544 + /// Returns true if the click was handled (was on a math element), false otherwise. 545 + /// When this returns false, the caller should handle the click normally. 546 + pub fn handle_math_click<D: EditorDocument>( 547 + target: &web_sys::EventTarget, 548 + doc: &mut D, 549 + syntax_spans: &[SyntaxSpanInfo], 550 + paragraphs: &[ParagraphRender], 551 + offset_map: &[OffsetMapping], 552 + ) -> bool { 553 + if let Some(offset) = get_math_click_offset(target) { 554 + tracing::debug!("math-clickable clicked, moving cursor to {}", offset); 555 + doc.set_cursor_offset(offset); 556 + doc.set_selection(None); 557 + crate::update_syntax_visibility(offset, None, syntax_spans, paragraphs); 558 + let _ = crate::restore_cursor_position(offset, offset_map, None); 559 + true 560 + } else { 561 + false 562 + } 563 + }
+10 -4
crates/weaver-editor-browser/src/lib.rs
··· 47 47 /// Set to `true` for maximum control, `false` for smoother typing experience. 48 48 pub const FORCE_INNERHTML_UPDATE: bool = true; 49 49 50 + pub mod clipboard; 50 51 pub mod color; 51 52 pub mod cursor; 52 53 pub mod dom_sync; ··· 68 69 69 70 // Event handling 70 71 pub use events::{ 71 - BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_current_range, 72 - get_data_from_event, get_input_type_from_event, get_target_range_from_event, 73 - handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text, 74 - write_clipboard_with_custom_type, 72 + BeforeInputContext, BeforeInputResult, StaticRange, get_current_range, get_data_from_event, 73 + get_input_type_from_event, get_math_click_offset, get_target_range_from_event, 74 + handle_beforeinput, handle_math_click, is_composing, parse_browser_input_type, 75 + read_clipboard_text, write_clipboard_with_custom_type, 75 76 }; 76 77 77 78 // Platform detection ··· 82 83 83 84 // Color utilities 84 85 pub use color::{rgba_u32_to_css, rgba_u32_to_css_alpha}; 86 + 87 + // Clipboard 88 + pub use clipboard::{BrowserClipboard, write_html_to_clipboard}; 89 + #[cfg(feature = "dioxus")] 90 + pub use clipboard::{handle_copy, handle_cut, handle_paste};
+7 -2
crates/weaver-editor-browser/src/visibility.rs
··· 19 19 //! } 20 20 //! ``` 21 21 22 - use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo, VisibilityState}; 22 + use weaver_editor_core::{ParagraphRender, Selection, SyntaxSpanInfo}; 23 23 24 24 /// Update syntax span visibility in the DOM based on cursor position. 25 25 /// ··· 43 43 ) { 44 44 use wasm_bindgen::JsCast; 45 45 46 - let visibility = VisibilityState::calculate(cursor_offset, selection, syntax_spans, paragraphs); 46 + let visibility = weaver_editor_core::VisibilityState::calculate( 47 + cursor_offset, 48 + selection, 49 + syntax_spans, 50 + paragraphs, 51 + ); 47 52 48 53 let Some(window) = web_sys::window() else { 49 54 return;
+23
crates/weaver-editor-core/src/document.rs
··· 64 64 /// Set the composition state. 65 65 fn set_composition(&mut self, composition: Option<CompositionState>); 66 66 67 + // === Required: Cursor snap hint === 68 + 69 + /// Get the pending snap direction hint. 70 + /// 71 + /// This hints which direction the cursor should snap after an edit 72 + /// when the cursor lands on invisible syntax. Forward for insertions 73 + /// (snap toward new content), backward for deletions (snap toward 74 + /// remaining content). 75 + fn pending_snap(&self) -> Option<crate::SnapDirection>; 76 + 77 + /// Set the pending snap direction hint. 78 + fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>); 79 + 67 80 // === Provided: Convenience accessors === 68 81 69 82 /// Get the cursor offset. ··· 266 279 selection: Option<Selection>, 267 280 last_edit: Option<EditInfo>, 268 281 composition: Option<CompositionState>, 282 + pending_snap: Option<crate::SnapDirection>, 269 283 } 270 284 271 285 impl<T: TextBuffer + UndoManager + Default> Default for PlainEditor<T> { ··· 283 297 selection: None, 284 298 last_edit: None, 285 299 composition: None, 300 + pending_snap: None, 286 301 } 287 302 } 288 303 ··· 338 353 339 354 fn set_composition(&mut self, composition: Option<CompositionState>) { 340 355 self.composition = composition; 356 + } 357 + 358 + fn pending_snap(&self) -> Option<crate::SnapDirection> { 359 + self.pending_snap 360 + } 361 + 362 + fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>) { 363 + self.pending_snap = snap; 341 364 } 342 365 } 343 366
+122 -2
crates/weaver-editor-core/src/execute.rs
··· 4 4 //! operations to any type implementing `EditorDocument`. The logic is generic 5 5 //! and platform-agnostic. 6 6 7 + use crate::SnapDirection; 7 8 use crate::actions::{EditorAction, FormatAction, Range}; 8 9 use crate::document::EditorDocument; 10 + use crate::platform::{ClipboardPlatform, clipboard_copy, clipboard_cut, clipboard_paste}; 9 11 use crate::text_helpers::{ 10 12 ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, 11 13 find_word_boundary_forward, is_list_item_empty, 12 14 }; 13 15 use crate::types::Selection; 14 16 17 + /// Determine the cursor snap direction hint for an action. 18 + /// 19 + /// Forward means cursor should snap toward new/remaining content (insertions). 20 + /// Backward means cursor should snap toward content before edit (deletions). 21 + pub fn snap_direction_for_action(action: &EditorAction) -> Option<SnapDirection> { 22 + match action { 23 + // Forward: cursor should snap toward new/remaining content. 24 + EditorAction::InsertLineBreak { .. } 25 + | EditorAction::InsertParagraph { .. } 26 + | EditorAction::DeleteForward { .. } 27 + | EditorAction::DeleteWordForward { .. } 28 + | EditorAction::DeleteToLineEnd { .. } 29 + | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), 30 + 31 + // Backward: cursor should snap toward content before edit. 32 + EditorAction::DeleteBackward { .. } 33 + | EditorAction::DeleteWordBackward { .. } 34 + | EditorAction::DeleteToLineStart { .. } 35 + | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), 36 + 37 + _ => None, 38 + } 39 + } 40 + 15 41 /// Execute an editor action on a document. 16 42 /// 17 43 /// This is the central dispatch point for all editor operations. 44 + /// Sets the appropriate snap direction hint before executing. 18 45 /// Returns true if the action was handled and the document was modified. 46 + /// 47 + /// Note: Clipboard operations (Cut, Copy, CopyAsHtml, Paste) return false here. 48 + /// Use [`execute_action_with_clipboard`] if you have a clipboard platform available. 19 49 pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool { 50 + // Set pending snap direction before executing action. 51 + if let Some(snap) = snap_direction_for_action(action) { 52 + doc.set_pending_snap(Some(snap)); 53 + } 54 + 20 55 match action { 21 56 EditorAction::Insert { text, range } => execute_insert(doc, text, *range), 22 57 EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range), ··· 41 76 EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"), 42 77 EditorAction::InsertLink => execute_insert_link(doc), 43 78 EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => { 44 - // Clipboard operations are handled by platform layer. 79 + // Clipboard operations need platform - use execute_action_with_clipboard. 45 80 false 46 81 } 47 82 EditorAction::Paste { range: _ } => { 48 - // Paste is handled by platform layer with clipboard access. 83 + // Paste needs platform - use execute_action_with_clipboard. 49 84 false 50 85 } 51 86 EditorAction::SelectAll => execute_select_all(doc), 52 87 EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset), 53 88 EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset), 89 + } 90 + } 91 + 92 + /// Execute an editor action with clipboard support. 93 + /// 94 + /// Like [`execute_action`], but also handles clipboard operations (Cut, Copy, Paste, CopyAsHtml) 95 + /// using the provided platform implementation. 96 + pub fn execute_action_with_clipboard<D, P>(doc: &mut D, action: &EditorAction, clipboard: &P) -> bool 97 + where 98 + D: EditorDocument, 99 + P: ClipboardPlatform, 100 + { 101 + match action { 102 + EditorAction::Copy => clipboard_copy(doc, clipboard), 103 + EditorAction::Cut => clipboard_cut(doc, clipboard), 104 + EditorAction::Paste { range: _ } => clipboard_paste(doc, clipboard), 105 + EditorAction::CopyAsHtml => crate::platform::clipboard_copy_as_html(doc, clipboard), 106 + // Delegate everything else to the regular execute_action. 107 + _ => execute_action(doc, action), 54 108 } 55 109 } 56 110 ··· 551 605 format!("\n{}{}. ", indent, number + 1) 552 606 } 553 607 } 608 + } 609 + 610 + // === Keydown handling === 611 + 612 + use crate::actions::{KeyCombo, KeybindingConfig, KeydownResult}; 613 + 614 + /// Handle a keydown event using the keybinding configuration. 615 + /// 616 + /// This handles keyboard shortcuts only. Text input and deletion 617 + /// are handled by beforeinput. Navigation (arrows, etc.) is passed 618 + /// through to the browser/platform. 619 + /// 620 + /// For clipboard operations, use [`handle_keydown_with_clipboard`] instead. 621 + pub fn handle_keydown<D: EditorDocument>( 622 + doc: &mut D, 623 + config: &KeybindingConfig, 624 + combo: KeyCombo, 625 + range: Range, 626 + ) -> KeydownResult { 627 + // Look up keybinding (range is applied by lookup). 628 + if let Some(action) = config.lookup(&combo, range) { 629 + execute_action(doc, &action); 630 + return KeydownResult::Handled; 631 + } 632 + 633 + check_passthrough(&combo) 634 + } 635 + 636 + /// Handle a keydown event with clipboard support. 637 + /// 638 + /// Like [`handle_keydown`], but uses the provided clipboard platform 639 + /// for clipboard operations (Cut, Copy, Paste, CopyAsHtml). 640 + pub fn handle_keydown_with_clipboard<D, P>( 641 + doc: &mut D, 642 + config: &KeybindingConfig, 643 + combo: KeyCombo, 644 + range: Range, 645 + clipboard: &P, 646 + ) -> KeydownResult 647 + where 648 + D: EditorDocument, 649 + P: ClipboardPlatform, 650 + { 651 + // Look up keybinding (range is applied by lookup). 652 + if let Some(action) = config.lookup(&combo, range) { 653 + execute_action_with_clipboard(doc, &action, clipboard); 654 + return KeydownResult::Handled; 655 + } 656 + 657 + check_passthrough(&combo) 658 + } 659 + 660 + /// Check if a key combo should pass through to the platform. 661 + fn check_passthrough(combo: &KeyCombo) -> KeydownResult { 662 + // Navigation keys should pass through. 663 + if combo.key.is_navigation() { 664 + return KeydownResult::PassThrough; 665 + } 666 + 667 + // Modifier-only keypresses should pass through. 668 + if combo.key.is_modifier() { 669 + return KeydownResult::PassThrough; 670 + } 671 + 672 + // Content keys (typing, backspace, etc.) - let beforeinput handle. 673 + KeydownResult::NotHandled 554 674 } 555 675 556 676 #[cfg(test)]
+9 -2
crates/weaver-editor-core/src/lib.rs
··· 42 42 pub use undo::{UndoManager, UndoableBuffer}; 43 43 pub use visibility::VisibilityState; 44 44 pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; 45 - pub use platform::{CursorPlatform, CursorSync, PlatformError}; 45 + pub use platform::{ 46 + ClipboardPlatform, CursorPlatform, CursorSync, PlatformError, clipboard_copy, 47 + clipboard_copy_as_html, clipboard_cut, clipboard_paste, render_markdown_to_html, 48 + strip_zero_width, 49 + }; 46 50 pub use actions::{ 47 51 EditorAction, FormatAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, 48 52 Modifiers, Range, 49 53 }; 50 - pub use execute::{apply_formatting, execute_action}; 54 + pub use execute::{ 55 + apply_formatting, execute_action, execute_action_with_clipboard, handle_keydown, 56 + handle_keydown_with_clipboard, snap_direction_for_action, 57 + }; 51 58 pub use text_helpers::{ 52 59 ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start, 53 60 find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty,
+170
crates/weaver-editor-core/src/platform.rs
··· 103 103 F: FnOnce(usize), 104 104 G: FnOnce(usize, usize); 105 105 } 106 + 107 + /// Platform-specific clipboard operations. 108 + /// 109 + /// Implementations handle the low-level clipboard access (sync and async paths 110 + /// as appropriate for the platform). Document-level operations (selection 111 + /// extraction, cursor updates) are handled by the `clipboard_*` functions 112 + /// in this module. 113 + pub trait ClipboardPlatform { 114 + /// Write plain text to clipboard. 115 + /// 116 + /// For browsers, implementations should use both the sync DataTransfer API 117 + /// (for immediate fallback) and the async Clipboard API (for custom MIME types). 118 + fn write_text(&self, text: &str); 119 + 120 + /// Write markdown rendered as HTML to clipboard. 121 + /// 122 + /// The `plain_text` is the original markdown, `html` is the rendered output. 123 + /// Both should be written to clipboard with appropriate MIME types. 124 + fn write_html(&self, html: &str, plain_text: &str); 125 + 126 + /// Read text from clipboard. 127 + /// 128 + /// For browsers, this reads from the paste event's DataTransfer. 129 + /// Returns None if no text is available. 130 + fn read_text(&self) -> Option<String>; 131 + } 132 + 133 + /// Strip zero-width characters used for formatting gaps. 134 + /// 135 + /// The editor uses ZWNJ (U+200C) and ZWSP (U+200B) to create cursor positions 136 + /// within invisible formatting syntax. These should be stripped when copying 137 + /// text to the clipboard. 138 + pub fn strip_zero_width(text: &str) -> String { 139 + text.replace('\u{200C}', "").replace('\u{200B}', "") 140 + } 141 + 142 + /// Copy selected text from document to clipboard. 143 + /// 144 + /// Returns true if text was copied, false if no selection. 145 + pub fn clipboard_copy<D: crate::EditorDocument, P: ClipboardPlatform>( 146 + doc: &D, 147 + platform: &P, 148 + ) -> bool { 149 + let Some(sel) = doc.selection() else { 150 + return false; 151 + }; 152 + 153 + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); 154 + if start == end { 155 + return false; 156 + } 157 + 158 + let Some(text) = doc.slice(start..end) else { 159 + return false; 160 + }; 161 + 162 + let clean_text = strip_zero_width(&text); 163 + platform.write_text(&clean_text); 164 + true 165 + } 166 + 167 + /// Cut selected text from document to clipboard. 168 + /// 169 + /// Copies the selection to clipboard, then deletes it from the document. 170 + /// Returns true if text was cut, false if no selection. 171 + pub fn clipboard_cut<D: crate::EditorDocument, P: ClipboardPlatform>( 172 + doc: &mut D, 173 + platform: &P, 174 + ) -> bool { 175 + let Some(sel) = doc.selection() else { 176 + return false; 177 + }; 178 + 179 + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); 180 + if start == end { 181 + return false; 182 + } 183 + 184 + let Some(text) = doc.slice(start..end) else { 185 + return false; 186 + }; 187 + 188 + let clean_text = strip_zero_width(&text); 189 + platform.write_text(&clean_text); 190 + 191 + // Delete selection. 192 + doc.delete(start..end); 193 + doc.set_selection(None); 194 + 195 + true 196 + } 197 + 198 + /// Paste text from clipboard into document. 199 + /// 200 + /// Replaces any selection with the pasted text, or inserts at cursor. 201 + /// Returns true if text was pasted, false if clipboard was empty. 202 + pub fn clipboard_paste<D: crate::EditorDocument, P: ClipboardPlatform>( 203 + doc: &mut D, 204 + platform: &P, 205 + ) -> bool { 206 + let Some(text) = platform.read_text() else { 207 + return false; 208 + }; 209 + 210 + if text.is_empty() { 211 + return false; 212 + } 213 + 214 + // Delete selection if present. 215 + if let Some(sel) = doc.selection() { 216 + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); 217 + if start != end { 218 + doc.delete(start..end); 219 + doc.set_cursor_offset(start); 220 + } 221 + } 222 + doc.set_selection(None); 223 + 224 + // Insert at cursor. 225 + let cursor = doc.cursor_offset(); 226 + doc.insert(cursor, &text); 227 + 228 + true 229 + } 230 + 231 + /// Render markdown to HTML using the ClientWriter. 232 + /// 233 + /// Uses a minimal context with no embed resolution, suitable for clipboard operations. 234 + pub fn render_markdown_to_html(markdown: &str) -> Option<String> { 235 + use crate::markdown_weaver::Parser; 236 + use crate::weaver_renderer::atproto::ClientWriter; 237 + 238 + let parser = Parser::new(markdown).into_offset_iter(); 239 + let mut html = String::new(); 240 + ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) 241 + .run() 242 + .ok()?; 243 + Some(html) 244 + } 245 + 246 + /// Copy selected text as rendered HTML to clipboard. 247 + /// 248 + /// Renders the selected markdown to HTML and writes both representations 249 + /// to the clipboard. Returns true if text was copied, false if no selection. 250 + pub fn clipboard_copy_as_html<D: crate::EditorDocument, P: ClipboardPlatform>( 251 + doc: &D, 252 + platform: &P, 253 + ) -> bool { 254 + let Some(sel) = doc.selection() else { 255 + return false; 256 + }; 257 + 258 + let (start, end) = (sel.start().min(sel.end()), sel.start().max(sel.end())); 259 + if start == end { 260 + return false; 261 + } 262 + 263 + let Some(text) = doc.slice(start..end) else { 264 + return false; 265 + }; 266 + 267 + let clean_text = strip_zero_width(&text); 268 + 269 + let Some(html) = render_markdown_to_html(&clean_text) else { 270 + return false; 271 + }; 272 + 273 + platform.write_html(&html, &clean_text); 274 + true 275 + }
+44
docs/graph-data.json
··· 1979 1979 "created_at": "2026-01-06T17:45:55.076540964-05:00", 1980 1980 "updated_at": "2026-01-06T17:45:55.076540964-05:00", 1981 1981 "metadata_json": "{\"confidence\":95}" 1982 + }, 1983 + { 1984 + "id": 182, 1985 + "change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 1986 + "node_type": "action", 1987 + "title": "Split component.rs - extract remote_cursors.rs and platform input handlers to browser crate", 1988 + "description": null, 1989 + "status": "pending", 1990 + "created_at": "2026-01-06T17:48:09.576571460-05:00", 1991 + "updated_at": "2026-01-06T17:48:09.576571460-05:00", 1992 + "metadata_json": "{\"confidence\":85}" 1993 + }, 1994 + { 1995 + "id": 183, 1996 + "change_id": "9f5f3214-ed3e-4a5b-94bf-3a24ed61b05c", 1997 + "node_type": "outcome", 1998 + "title": "RemoteCursors extracted to remote_cursors.rs, beforeinput closure lifecycle fixed (proper storage + cleanup), component.rs 1708→1586 lines, 88 tests pass", 1999 + "description": null, 2000 + "status": "pending", 2001 + "created_at": "2026-01-06T17:56:50.444969376-05:00", 2002 + "updated_at": "2026-01-06T17:56:50.444969376-05:00", 2003 + "metadata_json": "{\"confidence\":95}" 1982 2004 } 1983 2005 ], 1984 2006 "edges": [ ··· 3994 4016 "weight": 1.0, 3995 4017 "rationale": "Action completed", 3996 4018 "created_at": "2026-01-06T17:46:00.638988346-05:00" 4019 + }, 4020 + { 4021 + "id": 185, 4022 + "from_node_id": 176, 4023 + "to_node_id": 182, 4024 + "from_change_id": "f1cb9bd9-9102-44b8-9d99-bae1ef085723", 4025 + "to_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4026 + "edge_type": "leads_to", 4027 + "weight": 1.0, 4028 + "rationale": "Part of editor organization", 4029 + "created_at": "2026-01-06T17:48:13.609765445-05:00" 4030 + }, 4031 + { 4032 + "id": 186, 4033 + "from_node_id": 182, 4034 + "to_node_id": 183, 4035 + "from_change_id": "7a9e3b35-e3c2-4081-8e62-74da292b1c08", 4036 + "to_change_id": "9f5f3214-ed3e-4a5b-94bf-3a24ed61b05c", 4037 + "edge_type": "leads_to", 4038 + "weight": 1.0, 4039 + "rationale": "Action completed", 4040 + "created_at": "2026-01-06T17:56:55.250053755-05:00" 3997 4041 } 3998 4042 ] 3999 4043 }